diff --git a/.eslintignore b/.eslintignore index d568d086..08643bac 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,4 +8,4 @@ /tests/fixtures/** /tests/performance/** /tmp/** -/src/vendor/** +/src/vendor/** \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 53067ba4..69b301ef 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -61,7 +61,12 @@ } ], "no-plusplus": ["warn", { "allowForLoopAfterthoughts": true }], - "prettier/prettier": "warn", + "prettier/prettier": [ + "warn", + { + "endOfLine": "auto" + } + ], "react/require-default-props": "off", "no-await-in-loop": "off", "camelcase": "off", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index efc92b5e..5302d9f1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' - name: Install run: yarn install --frozen-lockfile @@ -28,7 +33,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' - name: Install run: yarn install --frozen-lockfile @@ -58,7 +68,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' - name: Install run: yarn install --frozen-lockfile diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 2f305f28..b0e8a0e0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: persist-credentials: false # Fetch all history for Sentry to properly create the release diff --git a/package.json b/package.json index 4e0ca5cd..92c73493 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@sentry/react": "^6.12.0", "@sentry/tracing": "^6.12.0", + "@types/lodash": "^4.14.192", "@types/react-map-gl": "^6.1.3", "axios": "^0.21.4", "cheerio": "^1.0.0-rc.3", @@ -23,6 +24,7 @@ "html-entities": "^2.3.3", "immer": "^9.0.6", "js-cookie": "^3.0.1", + "lodash": "^4.17.21", "mapbox-gl": "^2.4.1", "node-sass": "^6.0.1", "normalize.css": "^8.0.1", @@ -32,6 +34,7 @@ "react-map-gl": "5.2.11", "react-overlays": "^5.1.1", "react-resize-panel": "^0.3.5", + "react-router-dom": "^6.17.0", "react-scripts": "5.0.1", "react-tooltip": "^5.5.1", "react-transition-group": "^4.4.2", @@ -84,7 +87,7 @@ "secrets:linux": "echo Enter Bitwarden Password: && read BW_PASSWORD && (bw logout || exit 0) && export BW_SESSION=`bw login product@bitsofgood.org $BW_PASSWORD --raw` && npm run secrets:get", "secrets:windows": "set /p BW_PASSWORD=Enter Bitwarden Password:&& (bw logout || VER>NUL) && npm run secrets:login", "secrets:login": "FOR /F %a IN ('bw login product@bitsofgood.org %BW_PASSWORD% --raw') DO SET BW_SESSION=%a && npm run secrets:get", - "secrets:get": "bw sync && bw get item gt-scheduler/.env.development.local | fx .notes > \".env\"" + "secrets:get": "bw sync && bw get item gt-scheduler/website/.env.development.local | fx .notes > \".env\"" }, "eslintConfig": { "extends": "react-app" diff --git a/public/bitsOfGood.png b/public/bitsOfGood.png deleted file mode 100644 index 3c1115d3..00000000 Binary files a/public/bitsOfGood.png and /dev/null differ diff --git a/public/bitsOfGood.svg b/public/bitsOfGood.svg new file mode 100644 index 00000000..ccf0ba51 --- /dev/null +++ b/public/bitsOfGood.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/compare_panel.png b/public/compare_panel.png new file mode 100644 index 00000000..c8df6275 Binary files /dev/null and b/public/compare_panel.png differ diff --git a/public/compare_schedule.png b/public/compare_schedule.png new file mode 100644 index 00000000..c1c78ddd Binary files /dev/null and b/public/compare_schedule.png differ diff --git a/public/donate.png b/public/donate.png new file mode 100644 index 00000000..8acddde3 Binary files /dev/null and b/public/donate.png differ diff --git a/public/exportIcon.svg b/public/exportIcon.svg new file mode 100644 index 00000000..e69de29b diff --git a/public/invitation-succesful.png b/public/invitation-succesful.png new file mode 100644 index 00000000..b1c0da0e Binary files /dev/null and b/public/invitation-succesful.png differ diff --git a/public/invitation-succesful.svg b/public/invitation-succesful.svg new file mode 100644 index 00000000..354ba55d --- /dev/null +++ b/public/invitation-succesful.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/AccountDropdown/index.tsx b/src/components/AccountDropdown/index.tsx index 6ec7d0eb..4f683bfe 100644 --- a/src/components/AccountDropdown/index.tsx +++ b/src/components/AccountDropdown/index.tsx @@ -1,13 +1,15 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useContext } from 'react'; import { faCaretDown, faSignOutAlt, faSignInAlt, faUserCircle, + faAdjust, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { AccountContextValue, SignedIn } from '../../contexts/account'; +import { ThemeContext } from '../../contexts'; import LoginModal from '../LoginModal'; import { DropdownMenu, DropdownMenuAction } from '../Select'; import Spinner from '../Spinner'; @@ -40,6 +42,12 @@ export default function AccountDropdown({ const [loginOpen, setLoginOpen] = useState(false); const hideLogin = useCallback(() => setLoginOpen(false), []); + const [theme, setTheme] = useContext(ThemeContext); + const handleThemeChange = useCallback(() => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + }, [theme, setTheme]); + if (!isAuthEnabled) return null; let items: DropdownMenuAction[]; @@ -63,6 +71,11 @@ export default function AccountDropdown({ onClick: (): void => state.signOut(), id: 'sign-out-dropdown', }, + { + label: 'Theme', + icon: faAdjust, + onClick: handleThemeChange, + }, ]; circleContent = ; disabled = false; @@ -77,6 +90,11 @@ export default function AccountDropdown({ }, id: 'sign-in-button-dropdown', }, + { + label: 'Theme', + icon: faAdjust, + onClick: handleThemeChange, + }, ]; circleContent = ( + } {currentTabIndex === 1 && } {currentTabIndex === 2 && } - {/* Fake calendar used to capture screenshots */}
diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index 883546e5..632eebb5 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -2,9 +2,11 @@ import produce, { Immutable, Draft, original, castDraft } from 'immer'; import React, { useCallback, useMemo } from 'react'; import { - ScheduleContextValue, TermsContext, ScheduleContext, + ScheduleContextValue, + FriendContext, + FriendContextValue, } from '../../contexts'; import { AccountContext, AccountContextValue } from '../../contexts/account'; import { Oscar } from '../../data/beans'; @@ -15,6 +17,10 @@ import { TermScheduleData, ScheduleVersion, ScheduleData, + FriendTermData, + FriendInfo, + FriendScheduleData, + FriendShareData, } from '../../data/types'; import { lexicographicCompare } from '../../utils/misc'; import { @@ -22,6 +28,7 @@ import { StageLoadTerms, StageEnsureValidTerm, StageLoadAccount, + StageLoadRawFriendData, StageLoadRawScheduleDataHybrid, StageMigrateScheduleData, StageCreateScheduleDataProducer, @@ -29,7 +36,12 @@ import { StageLoadOscarData, StageExtractScheduleVersion, StageSkeletonProps, + StageCreateFriendDataProducer, + StageExtractFriendTermData, + StageLoadRawFriendScheduleDataFromFirebaseFunction, + StageExtractFriendInfo, } from './stages'; +import { softError, ErrorWithFields } from '../../log'; import { Term } from '../../types'; export type DataLoaderProps = { @@ -100,48 +112,70 @@ export default function DataLoader({ termScheduleData, updateTermScheduleData, }): React.ReactElement => ( - - {({ oscar }): React.ReactElement => ( - ( + - {({ - currentVersion, - scheduleVersion, - updateScheduleVersion, - }): React.ReactElement => ( - ( + - {children} - + {({ + currentVersion, + scheduleVersion, + updateScheduleVersion, + }): React.ReactElement => ( + + {children} + + )} + )} - + )} - + )} )} @@ -204,6 +238,87 @@ function GroupLoadScheduleData({ ); } +type GroupLoadFriendScheduleDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + children: (props: { + friendScheduleData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +function GroupLoadFriendScheduleData({ + skeletonProps, + accountState, + currentTerm, + children, +}: GroupLoadFriendScheduleDataProps): React.ReactElement { + return ( + + {({ rawFriendData, setFriendData }): React.ReactElement => ( + + {({ updateFriendData }): React.ReactElement => ( + + {({ + termFriendData, + updateFriendTermData, + }): React.ReactElement => ( + + {({ rawFriendScheduleData }): React.ReactElement => ( + + {({ + friendScheduleData, + updateFriendInfo, + }): React.ReactElement => ( + <> + {children({ + friendScheduleData, + updateFriendTermData, + updateFriendInfo, + })} + + )} + + )} + + )} + + )} + + )} + + ); +} + type ContextProviderProps = { terms: Term[]; currentTerm: string; @@ -224,6 +339,15 @@ type ContextProviderProps = { ) => void | Immutable ) => void; accountState: AccountContextValue; + friendScheduleData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; children: React.ReactNode; }; @@ -245,6 +369,9 @@ function ContextProvider({ termScheduleData, updateTermScheduleData, accountState, + friendScheduleData, + updateFriendTermData, + updateFriendInfo, children, }: ContextProviderProps): React.ReactElement { // Create a `updateSchedule` function @@ -286,13 +413,62 @@ function ContextProvider({ return versions; }, [termScheduleData.versions]); + const allFriends = useMemo< + Record> + >(() => { + const f = {} as Record>; + Object.entries(termScheduleData.versions).forEach( + ([versionId, { friends }]) => { + f[versionId] = friends; + } + ); + return f; + }, [termScheduleData.versions]); + // Get all version-related actions - const { addNewVersion, deleteVersion, renameVersion, cloneVersion } = - useVersionActions({ - updateTermScheduleData, - setVersion, - currentVersion, - }); + const { + addNewVersion, + deleteVersion, + renameVersion, + cloneVersion, + deleteFriendRecord, + } = useVersionActions({ + updateTermScheduleData, + setVersion, + currentVersion, + }); + + // Create a rename friend function. + const renameFriend = useCallback( + (id: string, newName: string): void => { + updateFriendInfo((draft) => { + const existingDraft = draft[id]; + if (existingDraft === undefined) { + softError( + new ErrorWithFields({ + message: + "renameFriend called with current friend id that doesn't exist; ignoring", + fields: { + allFriendNames: Object.entries(draft).map( + ([friendId, { name }]) => ({ + id: friendId, + name, + }) + ), + id, + friendCount: Object.keys(draft).length, + newName, + }, + }) + ); + return; + } + + existingDraft.name = newName; + }); + }, + [updateFriendInfo] + ); // Memoize the context values so that they are stable const scheduleContextValue = useMemo( @@ -302,7 +478,10 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + allFriends, + currentFriends: scheduleVersion.friends ?? {}, ...castDraft(scheduleVersion.schedule), + versions: termScheduleData.versions, }, { setTerm, @@ -311,6 +490,7 @@ function ContextProvider({ setCurrentVersion: setVersion, addNewVersion, deleteVersion, + deleteFriendRecord, renameVersion, cloneVersion, }, @@ -320,23 +500,43 @@ function ContextProvider({ oscar, currentVersion, allVersionNames, + allFriends, + scheduleVersion.friends, scheduleVersion.schedule, setTerm, patchSchedule, updateSchedule, setVersion, addNewVersion, + deleteFriendRecord, deleteVersion, renameVersion, cloneVersion, + termScheduleData.versions, ] ); + const friendContextValue = useMemo( + () => [ + { + friends: friendScheduleData, + }, + { + renameFriend, + updateFriendTermData, + updateFriendInfo, + }, + ], + [friendScheduleData, renameFriend, updateFriendTermData, updateFriendInfo] + ); + return ( - {children} + + {children} + diff --git a/src/components/AppDataLoader/stages.tsx b/src/components/AppDataLoader/stages.tsx index a371d749..425b5a0f 100644 --- a/src/components/AppDataLoader/stages.tsx +++ b/src/components/AppDataLoader/stages.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Immutable, Draft, castDraft } from 'immer'; +import React, { useMemo } from 'react'; +import { Immutable, Draft, castDraft, castImmutable } from 'immer'; import { Oscar } from '../../data/beans'; import useDownloadOscarData from '../../data/hooks/useDownloadOscarData'; @@ -9,9 +9,16 @@ import LoadingDisplay from '../LoadingDisplay'; import { SkeletonContent, AppSkeleton, AppSkeletonProps } from '../App/content'; import { AnyScheduleData, + defaultFriendData, + FriendData, + FriendTermData, + FriendIds, + FriendInfo, ScheduleData, ScheduleVersion, TermScheduleData, + RawFriendScheduleData, + FriendScheduleData, } from '../../data/types'; import useRawScheduleDataFromStorage from '../../data/hooks/useRawScheduleDataFromStorage'; import useExtractSchedule from '../../data/hooks/useExtractScheduleVersion'; @@ -23,6 +30,11 @@ import useUIStateFromStorage from '../../data/hooks/useUIStateFromStorage'; import { AccountContextValue, SignedIn } from '../../contexts/account'; import useFirebaseAuth from '../../data/hooks/useFirebaseAuth'; import useRawScheduleDataFromFirebase from '../../data/hooks/useRawScheduleDataFromFirebase'; +import useRawFriendDataFromFirebase from '../../data/hooks/useRawFriendDataFromFirebase'; +import useFriendDataProducer from '../../data/hooks/useFriendDataProducer'; +import useExtractFriendTermData from '../../data/hooks/useExtractFriendTermData'; +import useRawFriendScheduleDataFromFirebaseFunction from '../../data/hooks/useRawFriendScheduleDataFromFirebaseFunction'; +import useExtractFriendInfo from '../../data/hooks/useExtractFriendInfo'; // Each of the components in this file is a "stage" -- // a component that takes in a render function for its `children` prop @@ -256,7 +268,6 @@ export function StageLoadRawScheduleDataFromFirebase({ children, }: StageLoadRawScheduleDataFromFirebaseProps): React.ReactElement { const loadingState = useRawScheduleDataFromFirebase(accountState); - if (loadingState.type !== 'loaded') { return ( @@ -521,3 +532,245 @@ export function StageExtractScheduleVersion({ return <>{children({ ...loadingState.result })}; } + +export type StageLoadRawFriendDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + children: (props: { + rawFriendData: Immutable; + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendData({ + skeletonProps, + accountState, + currentTerm, + children, +}: StageLoadRawFriendDataProps): React.ReactElement { + const friendDataSignedOut = useMemo(() => { + const friendData = castDraft({ ...defaultFriendData }); + friendData.terms[currentTerm] = { accessibleSchedules: {} }; + return castImmutable(friendData); + }, [currentTerm]); + + if (accountState.type === 'signedOut') { + return ( + <> + {children({ + rawFriendData: friendDataSignedOut, + setFriendData: () => { + /* empty */ + }, + })} + + ); + } + + return StageLoadRawFriendDataFromFirebase({ + skeletonProps, + accountState, + children, + }); +} + +export type StageLoadRawFriendDataFromFirebaseProps = { + skeletonProps?: StageSkeletonProps; + accountState: SignedIn; + children: (props: { + rawFriendData: Immutable; + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendDataFromFirebase({ + skeletonProps, + accountState, + children, +}: StageLoadRawFriendDataFromFirebaseProps): React.ReactElement { + const loadingState = useRawFriendDataFromFirebase(accountState); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return <>{children({ ...loadingState.result })}; +} + +export type StageCreateFriendDataProducerProps = { + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + children: (props: { + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageCreateFriendDataProducer({ + setFriendData, + children, +}: StageCreateFriendDataProducerProps): React.ReactElement { + const { updateFriendData } = useFriendDataProducer({ setFriendData }); + return <>{children({ updateFriendData })}; +} + +export type StageExtractFriendTermDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + rawFriendData: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + children: (props: { + termFriendData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageExtractFriendTermData({ + skeletonProps, + accountState, + currentTerm, + rawFriendData, + updateFriendData, + children, +}: StageExtractFriendTermDataProps): React.ReactElement { + const loadingState = useExtractFriendTermData({ + currentTerm, + rawFriendData, + updateFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return <>{children({ ...loadingState.result })}; +} + +export type StageLoadRawFriendScheduleDataFromFirebaseFunctionProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + termFriendData: Immutable; + children: (props: { + rawFriendScheduleData: RawFriendScheduleData; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendScheduleDataFromFirebaseFunction({ + skeletonProps, + accountState, + currentTerm, + termFriendData, + children, +}: // eslint-disable-next-line max-len +StageLoadRawFriendScheduleDataFromFirebaseFunctionProps): React.ReactElement { + const loadingState = useRawFriendScheduleDataFromFirebaseFunction({ + currentTerm, + termFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return ( + <> + {children({ + rawFriendScheduleData: { ...loadingState.result.friendScheduleData }, + })} + + ); +} + +export type StageExtractFriendInfo = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + rawFriendScheduleData: RawFriendScheduleData; + friendInfo: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + children: (props: { + friendScheduleData: Immutable; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageExtractFriendInfo({ + skeletonProps, + accountState, + rawFriendScheduleData, + friendInfo, + updateFriendData, + children, +}: StageExtractFriendInfo): React.ReactElement { + const loadingState = useExtractFriendInfo({ + rawFriendScheduleData, + friendInfo, + updateFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return ( + <> + {children({ + ...loadingState.result, + })} + + ); +} diff --git a/src/components/Attribution/index.tsx b/src/components/Attribution/index.tsx index 2f36a8e2..59cb0eb3 100644 --- a/src/components/Attribution/index.tsx +++ b/src/components/Attribution/index.tsx @@ -1,5 +1,8 @@ import React from 'react'; +import { faGithub } from '@fortawesome/free-brands-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button } from '..'; import { classes } from '../../utils/misc'; import { DESKTOP_BREAKPOINT } from '../../constants'; import useScreenWidth from '../../hooks/useScreenWidth'; @@ -10,6 +13,15 @@ export default function Attribution(): React.ReactElement { const mobile = !useScreenWidth(DESKTOP_BREAKPOINT); return (
+ {!mobile ? ( + + ) : ( +
+ )} +

Copyright (c) 2023 with{' '} @@ -22,6 +34,7 @@ export default function Attribution(): React.ReactElement { .

+

 

); } diff --git a/src/components/Attribution/stylesheet.scss b/src/components/Attribution/stylesheet.scss index c031a548..efb83f5d 100644 --- a/src/components/Attribution/stylesheet.scss +++ b/src/components/Attribution/stylesheet.scss @@ -7,7 +7,7 @@ box-sizing: border-box; display: flex; align-items: center; - justify-content: center; + justify-content: space-between; text-align: center; color: inherit; border-top: 1px solid $color-border; @@ -20,4 +20,8 @@ flex-wrap: wrap; justify-content: center; } + + .githubText { + margin-left: 5px; + } } \ No newline at end of file diff --git a/src/components/Calendar/index.tsx b/src/components/Calendar/index.tsx index adae1a80..0d8fecd7 100644 --- a/src/components/Calendar/index.tsx +++ b/src/components/Calendar/index.tsx @@ -1,14 +1,16 @@ import React, { useContext } from 'react'; +import { Immutable } from 'immer'; +import { FriendScheduleData } from '../../data/types'; import { Section } from '../../data/beans'; import { CLOSE, DAYS, OPEN } from '../../constants'; import { classes, timeToShortString } from '../../utils/misc'; -import { SectionBlocks, EventBlocks } from '..'; -import { ScheduleContext } from '../../contexts'; +import { SectionBlocks, EventBlocks, CompareBlocks } from '..'; +import { ScheduleContext, FriendContext } from '../../contexts'; import { makeSizeInfoKey } from '../TimeBlocks'; import { EventBlockPosition } from '../EventBlocks'; import { SectionBlockPosition } from '../SectionBlocks'; -import { Period } from '../../types'; +import { Period, Event } from '../../types'; import useMedia from '../../hooks/useMedia'; import './stylesheet.scss'; @@ -18,38 +20,57 @@ export type CalendarProps = { overlayCrns: string[]; preview?: boolean; capture?: boolean; + compare?: boolean; + pinnedFriendSchedules?: string[]; + pinSelf?: boolean; + overlayFriendSchedules?: string[]; isAutosized?: boolean; }; // Object for storing Event object and Meeting object in the same array. -type CommmonMeetingObject = { +type CommonMeetingObject = { id: string; days: string[]; period: Period; event: boolean; }; +type FriendCrnData = { + friend: string; + scheduleId: string; + scheduleName: string; + crn: string; +}; + +type FriendEventData = { + friend: string; + scheduleId: string; + scheduleName: string; + id: string; + event: Event; +}; + export default function Calendar({ className, overlayCrns, preview = false, capture = false, + compare = false, + pinnedFriendSchedules = [], + pinSelf = true, + overlayFriendSchedules = [], isAutosized = false, }: CalendarProps): React.ReactElement { - const [{ pinnedCrns, oscar, events }] = useContext(ScheduleContext); + const [{ pinnedCrns, oscar, events, currentVersion, versions }] = + useContext(ScheduleContext); - // Contains the rowIndex's and rowSize's passed into each crn's TimeBlocks - // e.g. crnSizeInfo[crn][day]["period.start-period.end"].rowIndex - const crnSizeInfo: Record< - string, - Record> - > = {}; + const [{ friends }] = useContext(FriendContext); - // Contains the rowIndex's and rowSize's passed into each custom event's - // TimeBlocks, consistent with the rowIndex's and rowSize's of crns - const eventSizeInfo: Record< + // Contains the rowIndex's and rowSize's passed into each crn's TimeBlocks + // e.g. meetingSizeInfo[crn/id][day]["period.start-period.end"].rowIndex + const meetingSizeInfo: Record< string, - Record> + Record> > = {}; const daysRef = React.useRef(null); @@ -88,11 +109,14 @@ export default function Calendar({ }); }; - const crns = Array.from(new Set([...pinnedCrns, ...(overlayCrns || [])])); + const crns = + pinSelf && !compare + ? Array.from(new Set([...pinnedCrns, ...(overlayCrns || [])])) + : []; // Find section using crn and convert the meetings into // an array of CommonMeetingObject - const crnMeetings: (CommmonMeetingObject | null)[] = crns + const crnMeetings: (CommonMeetingObject | null)[] = crns .flatMap((crn) => { const section = oscar.findSection(crn); if (section == null) return null; @@ -104,27 +128,28 @@ export default function Calendar({ days: meeting.days, period: meeting.period, event: false, - } as CommmonMeetingObject; + } as CommonMeetingObject; }); return temp; }) .filter((m) => m != null); - const meetings: CommmonMeetingObject[] = - crnMeetings as CommmonMeetingObject[]; - - // Add events to meetings array - meetings.push( - ...events.map((event) => { - return { - id: event.id, - days: event.days, - period: event.period, - event: true, - } as CommmonMeetingObject; - }) - ); + const meetings: CommonMeetingObject[] = crnMeetings as CommonMeetingObject[]; + + if (!compare || pinSelf) { + // Add events to meetings array + meetings.push( + ...events.map((event) => { + return { + id: event.id, + days: event.days, + period: event.period, + event: true, + } as CommonMeetingObject; + }) + ); + } // Sort meetings by meeting length meetings.sort( @@ -132,6 +157,83 @@ export default function Calendar({ a.period.end - a.period.start - (b.period.end - b.period.start) ?? 0 ); + const userSchedules: { data: FriendCrnData; overlay: boolean }[] = []; + const userEvents: { data: FriendEventData; overlay: boolean }[] = []; + if (compare) { + /* + Create a dummy friend schedule data object for self schedules for + conforming types to iterate over all schedules in one go + */ + const selfFriend: Immutable = { + self: { + name: 'Me', + email: '', + versions, + }, + }; + const allUsers = { ...friends, ...selfFriend }; + + Object.values(allUsers).forEach((friend) => + Object.entries(friend.versions) + .filter( + (schedule) => + pinnedFriendSchedules.includes(schedule[0]) || + overlayFriendSchedules.includes(schedule[0]) + ) + .forEach((schedule) => { + const friendMeetings: CommonMeetingObject[] = []; + schedule[1].schedule.pinnedCrns.forEach((crn) => { + userSchedules.push({ + data: { + friend: friend.name, + scheduleName: schedule[1].name, + scheduleId: schedule[0], + crn, + } as FriendCrnData, + overlay: !pinnedFriendSchedules.includes(schedule[0]), + }); + + const section = oscar.findSection(crn); + if (section == null) return; + section.meetings + .filter((m) => m.period) + .forEach((meeting) => { + friendMeetings.push({ + id: `${schedule[0]}-${crn}`, + days: meeting.days, + period: meeting.period, + event: false, + } as CommonMeetingObject); + }); + }); + schedule[1].schedule.events.forEach((event) => { + userEvents.push({ + data: { + friend: friend.name, + scheduleName: schedule[1].name, + scheduleId: schedule[0], + id: event.id, + event, + } as FriendEventData, + overlay: !pinnedFriendSchedules.includes(schedule[0]), + }); + friendMeetings.push({ + id: `${schedule[0]}-${event.id}`, + days: event.days, + period: event.period, + event: true, + } as CommonMeetingObject); + }); + friendMeetings.sort( + (a, b) => + a.period.end - a.period.start - (b.period.end - b.period.start) ?? + 0 + ); + meetings.push(...friendMeetings); + }) + ); + } + // Populates crnSizeInfo and eventSizeInfo by iteratively finding the // next time block's rowSize and rowIndex (1 more than // greatest of already processed connected blocks), updating @@ -141,21 +243,13 @@ export default function Calendar({ if (period == null) return; meeting.days.forEach((day) => { - const crnPeriodInfos = Object.values(crnSizeInfo) - .flatMap((days) => - days != null ? Object.values(days[day] ?? {}) : [] - ) - .flatMap((info) => (info == null ? [] : [info])); - - const eventPeriodInfos = Object.values(eventSizeInfo) - .flatMap((days) => - days != null ? Object.values(days[day] ?? {}) : [] + const dayPeriodInfos = Object.values(meetingSizeInfo) + .flatMap( + (days) => (days != null ? Object.values(days[day] ?? {}) : []) ) - .flatMap((info) => (info == null ? [] : [info])); - - const dayPeriodInfos: (SectionBlockPosition | EventBlockPosition)[] = - crnPeriodInfos; - dayPeriodInfos.push(...eventPeriodInfos); + .flatMap((info) => + info == null ? [] : [info] + ); const curRowSize = dayPeriodInfos .filter( @@ -176,13 +270,13 @@ export default function Calendar({ curRowSize ); - if (!meeting.event) { - const courseSizeInfo = crnSizeInfo[meeting.id] || {}; - crnSizeInfo[meeting.id] = courseSizeInfo; + const mSizeInfo = meetingSizeInfo[meeting.id] || {}; + meetingSizeInfo[meeting.id] = mSizeInfo; - const daySizeInfo = courseSizeInfo[day] || {}; - courseSizeInfo[day] = daySizeInfo; + const daySizeInfo = mSizeInfo[day] || {}; + mSizeInfo[day] = daySizeInfo; + if (!meeting.event) { daySizeInfo[makeSizeInfoKey(period)] = { period, crn: meeting.id, @@ -190,13 +284,7 @@ export default function Calendar({ rowSize: curRowSize, }; } else { - const evtSizeInfo = eventSizeInfo[meeting.id] || {}; - eventSizeInfo[meeting.id] = evtSizeInfo; - - const eventDaySizeInfo = evtSizeInfo[day] || {}; - evtSizeInfo[day] = eventDaySizeInfo; - - eventDaySizeInfo[makeSizeInfoKey(meeting.period)] = { + daySizeInfo[makeSizeInfoKey(period)] = { period: meeting.period, id: meeting.id, rowIndex: curRowSize - 1, @@ -285,11 +373,12 @@ export default function Calendar({ {pinnedCrnsByFirstMeeting.map((crn) => ( ( ))} {events && events.map((event) => ( ))} + {compare && + userSchedules.map(({ data, overlay }) => ( + { + if (meeting === null) { + setSelectedMeeting(null); + } else { + setSelectedMeeting([ + `${data.scheduleId}-${data.crn}`, + meeting[0], + meeting[1], + ]); + } + }} + deviceHasHover={deviceHasHover} + canBeTabFocused={!isAutosized && !capture} + /> + ))} + {compare && + userEvents.map(({ data, overlay }) => ( + { + if (meeting === null) { + setSelectedMeeting(null); + } else { + setSelectedMeeting([ + `${data.scheduleId}-${data.id}`, + meeting[0], + meeting[1], + ]); + } + }} + /> + ))}
{!preview && hiddenSections.length > 0 && (
diff --git a/src/components/CombinationContainer/index.tsx b/src/components/CombinationContainer/index.tsx index 0a8adb2a..4cab9168 100644 --- a/src/components/CombinationContainer/index.tsx +++ b/src/components/CombinationContainer/index.tsx @@ -20,7 +20,13 @@ import './stylesheet.scss'; const List = _List as unknown as React.ComponentType; const AutoSizer = _AutoSizer as unknown as React.ComponentType; -export default function CombinationContainer(): React.ReactElement { +export type ComparisonPanelProps = { + compare?: boolean; +}; + +export default function CombinationContainer({ + compare = false, +}: ComparisonPanelProps): React.ReactElement { const [ { oscar, @@ -45,70 +51,87 @@ export default function CombinationContainer(): React.ReactElement { [oscar, desiredCourses, pinnedCrns, excludedCrns, events] ); const sortedCombinations = useMemo( - () => oscar.sortCombinations(combinations, sortingOptionIndex), - [oscar, combinations, sortingOptionIndex] + () => oscar.sortCombinations(combinations, sortingOptionIndex, events), + [oscar, combinations, sortingOptionIndex, events] ); return ( <>
- + patchSchedule({ sortingOptionIndex: newSortingOptionIndex }) + } + current={sortingOptionIndex} + options={oscar.sortingOptions.map((sortingOption, i) => ({ + id: i, + label: sortingOption.label, + }))} + /> + +
+ + {({ width, height }): React.ReactElement => ( + { + const { crns } = sortedCombinations[index] as Combination; + return ( +
+
setOverlayCrns(crns)} + onMouseLeave={(): void => setOverlayCrns([])} + onClick={(): void => + patchSchedule({ + pinnedCrns: [...pinnedCrns, ...crns], + }) + } + > +
{index + 1}
+ +
+
+ ); + }} + /> + )} +
+
+ + )}
void; +}; + +export default function CompareBlocks({ + className, + owner, + scheduleId, + scheduleName, + crn, + overlay = false, + capture, + sizeInfo, + includeDetailsPopover, + includeContent, + canBeTabFocused = false, + deviceHasHover = true, + selectedMeeting, + onSelectMeeting, +}: CompareBlocksProps): React.ReactElement | null { + const [{ oscar }] = useContext(ScheduleContext); + + const section = oscar.findSection(crn); + if (section == null) return null; + + return ( +
+ {section.meetings.map((meeting, i) => { + const { period } = meeting; + if (period == null) return; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/CompareBlocks/stylesheet.scss b/src/components/CompareBlocks/stylesheet.scss new file mode 100644 index 00000000..37f39ea9 --- /dev/null +++ b/src/components/CompareBlocks/stylesheet.scss @@ -0,0 +1,16 @@ +.mobile .TimeBlocks:not(.capture) .meeting .meeting-wrapper { + .ids { + .course-id { + flex: 1; + } + + .section-id { + display: none; + } + } + + .where, + .instructors { + display: none; + } +} \ No newline at end of file diff --git a/src/components/ComparisonContainer/index.tsx b/src/components/ComparisonContainer/index.tsx new file mode 100644 index 00000000..333ef66d --- /dev/null +++ b/src/components/ComparisonContainer/index.tsx @@ -0,0 +1,876 @@ +import React, { + useState, + useContext, + useCallback, + useId, + useEffect, +} from 'react'; +import { + faPencil, + faCircleXmark, + faXmark, + faPalette, + faShareFromSquare, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import axios from 'axios'; + +import { classes, getRandomColor } from '../../utils/misc'; +import { + ScheduleContext, + FriendContext, + AccountContext, + SignedIn, +} from '../../contexts'; +import Button from '../Button'; +import Modal from '../Modal'; +import { AutoFocusInput } from '../Select'; +import { Palette } from '..'; +import { ErrorWithFields, softError } from '../../log'; +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; +import InvitationModal from '../InvitationModal'; +import ComparisonContainerShareBack from '../ComparisonContainerShareBack/ComparisonContainerShareBack'; +import { ScheduleDeletionRequest } from '../../types'; + +import './stylesheet.scss'; + +export type SharedSchedule = { + email: string; + name: string; + schedules: { + id: string; + name: string; + color: string; + }[]; +}; + +export type DeleteInfo = { + id: string; + type: string; + name: string; + owner?: string; + ownerName?: string; +} | null; + +export type EditInfo = { + id: string; + owner?: string; + type: string; +} | null; + +export type ComparisonContainerProps = { + handleCompareSchedules: ( + compare?: boolean, + pinnedSchedules?: string[], + pinSelf?: boolean, + expanded?: boolean, + overlaySchedules?: string[] + ) => void; + pinnedSchedules: string[]; + shareBackRemount: number; +}; + +export default function ComparisonContainer({ + handleCompareSchedules, + pinnedSchedules, + shareBackRemount, +}: ComparisonContainerProps): React.ReactElement { + const [selected, setSelected] = useState(pinnedSchedules); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [editInfo, setEditInfo] = useState(null); + const [editValue, setEditValue] = useState(''); + const [paletteInfo, setPaletteInfo] = useState(); + const [invitationModalOpen, setInvitationModalOpen] = useState(false); + const [invitationModalEmail, setInvitationModalEmail] = useState(''); + + const [ + { allVersionNames, currentVersion, colorMap, term }, + { deleteVersion, renameVersion, patchSchedule }, + ] = useContext(ScheduleContext); + + const [{ friends }, { renameFriend }] = useContext(FriendContext); + + const accountContext = useContext(AccountContext); + + useEffect(() => { + const newColorMap = { ...colorMap }; + allVersionNames.forEach((versionName) => { + const version = versionName.id; + if (!(version in newColorMap)) { + newColorMap[version] = getRandomColor(); + } + }); + if (!(currentVersion in newColorMap)) { + newColorMap[currentVersion] = getRandomColor(); + } + Object.entries(friends).forEach((friend) => { + if (!(friend[0] in newColorMap)) { + newColorMap[friend[0]] = getRandomColor(); + } + Object.keys(friend[1].versions).forEach((schedule) => { + if (!(schedule in newColorMap)) { + newColorMap[schedule] = getRandomColor(); + } + }); + }); + if (Object.keys(newColorMap).length !== Object.keys(colorMap).length) { + patchSchedule({ colorMap: newColorMap }); + } + }, [friends, currentVersion, colorMap, patchSchedule, allVersionNames]); + + const handleEdit = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (editValue.trim() === '') return; + if (editInfo?.type === 'Version') { + renameVersion(editInfo?.id, editValue.trim()); + } else if (editInfo?.type === 'User') { + renameFriend(editInfo?.id, editValue.trim()); + } + setEditInfo(null); + setEditValue(''); + } + + if (e.key === 'Escape') { + setEditInfo(null); + setEditValue(''); + } + }, + [editInfo, editValue, renameVersion, renameFriend] + ); + + const handleNameEditOnBlur = useCallback(() => { + if (editValue.trim() === '') return; + if (editInfo?.type === 'User') { + renameFriend(editInfo?.id, editValue.trim()); + } + if (editInfo?.type === 'Version') { + renameVersion(editInfo?.id, editValue.trim()); + } + setEditInfo(null); + setEditValue(''); + }, [editInfo, editValue, renameFriend, renameVersion]); + + const deleteSchedulesFromInvitee = useCallback( + async (senderId: string, versions: string[]) => { + const data = JSON.stringify({ + IDToken: await (accountContext as SignedIn).getToken(), + peerUserId: senderId, + term, + versions, + owner: false, + } as ScheduleDeletionRequest); + + const friend = friends[senderId]; + if (friend) { + axios + .post( + `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, + `data=${data}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ) + .then(() => { + const newColorMap = { ...colorMap }; + versions.forEach((schedule) => { + delete newColorMap[schedule]; + }); + setSelected( + selected.filter( + (selectedId: string) => + !Object.keys(friend.versions).includes(selectedId) + ) + ); + patchSchedule({ colorMap: newColorMap }); + // updateFriendTermData((draft) => { + // delete draft.accessibleSchedules[senderId]; + // }); + }) + .catch((err) => { + throw err; + }); + } + }, + [accountContext, term, colorMap, friends, patchSchedule, selected] + ); + + // remove all versions of a particular friend from user (invitee) view + const handleRemoveFriend = useCallback( + (ownerId: string) => { + const friend = friends[ownerId]; + if (friend) { + const versions = Object.keys(friend.versions); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + deleteSchedulesFromInvitee(ownerId, versions).catch((err) => { + softError( + new ErrorWithFields({ + message: 'Failed to delete user schedule', + source: err, + fields: { + user: (accountContext as SignedIn).id, + sender: ownerId, + term, + versions, + }, + }) + ); + }); + } + }, + [friends, deleteSchedulesFromInvitee, accountContext, term] + ); + + const handleRemoveSchedule = useCallback( + (id: string, ownerId: string) => { + deleteSchedulesFromInvitee(ownerId, [id]).catch((err) => { + softError( + new ErrorWithFields({ + message: 'Failed to delete user schedule', + source: err, + fields: { + user: (accountContext as SignedIn).id, + sender: ownerId, + term, + versions: [id], + }, + }) + ); + }); + }, + [deleteSchedulesFromInvitee, accountContext, term] + ); + + const handleToggleSchedule = useCallback( + (id: string) => { + if (selected.includes(id)) { + setSelected(selected.filter((selectedId: string) => selectedId !== id)); + handleCompareSchedules( + undefined, + selected.filter((selectedId: string) => selectedId !== id), + undefined + ); + } else { + setSelected(selected.concat([id])); + handleCompareSchedules(undefined, selected.concat([id]), undefined); + } + }, + [selected, handleCompareSchedules] + ); + + const setFriendScheduleColor = useCallback( + (color: string, id: string) => { + const newColorMap = { ...colorMap }; + newColorMap[id] = color; + patchSchedule({ colorMap: newColorMap }); + }, + [colorMap, patchSchedule] + ); + + const sortedFriendsArray = Object.entries(friends).sort( + ([, friendA], [, friendB]) => friendA.name.localeCompare(friendB.name) + ); + + return ( +
+ { + setInvitationModalOpen(false); + }} + inputEmail={invitationModalEmail} + /> +
+
+
+

My Schedule

+ {allVersionNames + // .filter((version) => version.id === currentVersion) + .map((version) => { + return ( + { + handleToggleSchedule(version.id); + }} + checkboxColor={ + selected.includes(version.id) ? colorMap[version.id] : '' + } + name={version.name} + // placeholder functions + handleEditSchedule={(): void => { + setEditInfo({ + id: version.id, + type: 'Version', + }); + setEditValue(version.name); + }} + handleRemoveSchedule={(): void => { + setDeleteConfirm({ + id: version.id, + type: 'Version', + name: version.name, + }); + }} + hasDelete={allVersionNames.length >= 2} + editOnChange={( + e: React.ChangeEvent + ): void => setEditValue(e.target.value)} + editOnKeyDown={handleEdit} + editInfo={editInfo} + setEditInfo={setEditInfo} + editValue={editValue} + hasPalette + setFriendScheduleColor={(color: string): void => { + setFriendScheduleColor(color, version.id); + }} + color={colorMap[version.id]} + paletteInfo={paletteInfo} + setPaletteInfo={setPaletteInfo} + handleNameEditOnBlur={handleNameEditOnBlur} + hoverFriendSchedule={(): void => { + handleCompareSchedules( + undefined, + undefined, + undefined, + undefined, + [version.id] + ); + }} + unhoverFriendSchedule={(): void => { + handleCompareSchedules( + undefined, + undefined, + undefined, + undefined, + [] + ); + }} + /> + ); + })} +
+
+

Shared with me

+ {Object.keys(friends).length !== 0 ? ( + sortedFriendsArray.map(([friendId, friend]) => { + return ( +
+ { + setEditInfo({ + id: friendId, + type: 'User', + }); + setEditValue(friend.name); + }} + handleRemoveSchedule={(): void => { + setDeleteConfirm({ + id: friendId, + type: 'User', + name: friend.name, + }); + }} + hasTooltip + editOnChange={( + e: React.ChangeEvent + ): void => setEditValue(e.target.value)} + editOnKeyDown={handleEdit} + editInfo={editInfo} + setEditInfo={setEditInfo} + editValue={editValue} + setInvitationModalEmail={setInvitationModalEmail} + setInvitationModalOpen={setInvitationModalOpen} + handleNameEditOnBlur={handleNameEditOnBlur} + /> +
+

{friend.email}

+
+ {Object.entries(friend.versions).map( + ([scheduleId, schedule]) => { + return ( + + handleToggleSchedule(scheduleId) + } + checkboxColor={ + selected.includes(scheduleId) + ? colorMap[scheduleId] + : '' + } + name={schedule.name} + handleEditSchedule={(): void => { + setEditInfo({ + id: scheduleId, + owner: friendId, + type: 'Schedule', + }); + setEditValue(schedule.name); + }} + handleRemoveSchedule={(): void => { + setDeleteConfirm({ + id: scheduleId, + type: 'Schedule', + name: schedule.name, + owner: friendId, + ownerName: friend.name, + }); + }} + hasPalette + hasEdit={false} + setFriendScheduleColor={(color: string): void => { + setFriendScheduleColor(color, scheduleId); + }} + color={colorMap[scheduleId]} + paletteInfo={paletteInfo} + setPaletteInfo={setPaletteInfo} + hoverFriendSchedule={(): void => { + handleCompareSchedules( + undefined, + undefined, + undefined, + undefined, + [scheduleId] + ); + }} + unhoverFriendSchedule={(): void => { + handleCompareSchedules( + undefined, + undefined, + undefined, + undefined, + [] + ); + }} + handleNameEditOnBlur={handleNameEditOnBlur} + /> + ); + } + )} + +
+ ); + }) + ) : ( +
+

+ No schedules are currently shared with you. +

+

+ Accept invitations from other users to see their schedules on + this view. +

+
+ )} +
+ +
+
+
+ ); +} + +type ScheduleRowProps = { + id: string; + type: string; + owner?: string; + hasCheck?: boolean; + onClick?: () => void; + checkboxColor?: string; + email?: string; + name: string; + handleEditSchedule: () => void; + handleRemoveSchedule: () => void; + setInvitationModalOpen?: React.Dispatch>; + setInvitationModalEmail?: React.Dispatch>; + hasPalette?: boolean; + hasEdit?: boolean; + hasDelete?: boolean; + hasTooltip?: boolean; + setFriendScheduleColor?: (color: string) => void; + color?: string; + paletteInfo?: string; + setPaletteInfo?: (info: string) => void; + editOnChange?: (e: React.ChangeEvent) => void; + editOnKeyDown?: (e: React.KeyboardEvent) => void; + editInfo?: EditInfo; + setEditInfo?: (info: EditInfo) => void; + editValue?: string; + hoverFriendSchedule?: () => void; + unhoverFriendSchedule?: () => void; + handleNameEditOnBlur?: () => void; +}; + +function ScheduleRow({ + id, + type, + owner, + hasCheck = true, + onClick, + checkboxColor, + email, + name, + handleEditSchedule, + handleRemoveSchedule, + hasPalette = false, + hasEdit = true, + hasDelete = true, + hasTooltip = false, + setFriendScheduleColor, + color, + paletteInfo, + setPaletteInfo, + editOnChange, + editOnKeyDown, + editInfo, + setEditInfo, + editValue, + setInvitationModalOpen, + setInvitationModalEmail, + hoverFriendSchedule, + unhoverFriendSchedule, + handleNameEditOnBlur, +}: ScheduleRowProps): React.ReactElement { + const tooltipId = useId(); + const [tooltipHover, setTooltipHover] = useState(false); + const [divHover, setDivHover] = useState(false); + const [showPaletteTooltip, setShowPaletteTooltip] = useState(false); + const [showShareTooltip, setShowShareTooltip] = useState(false); + const [showEditTooltip, setShowEditTooltip] = useState(false); + const [showRemoveTooltip, setShowRemoveTooltip] = useState(false); + + const edit = + hasEdit && + editInfo != null && + editInfo.type === type && + editInfo.id === id && + editInfo.owner === owner; + + const palette = hasPalette && paletteInfo === id; + + return ( +
{ + if (type === 'Schedule' || type === 'Version') { + hoverFriendSchedule?.(); + } + }} + onMouseLeave={(): void => { + if (type === 'Schedule' || type === 'Version') { + unhoverFriendSchedule?.(); + } + }} + > +
setDivHover(true)} + onMouseLeave={(): void => setDivHover(false)} + > + {hasCheck && ( +
+ )} + {setEditInfo && edit && ( + + )} + {!edit && ( + <> +
setTooltipHover(true)} + onMouseLeave={(): void => setTooltipHover(false)} + onClick={onClick} + > +
+

{name}

+
+ {hasTooltip && email !== name && ( + +

{email}

+
+ )} +
+
+ + )} + {(divHover || edit) && hasPalette && setPaletteInfo && ( +
setShowPaletteTooltip(true)} + onMouseLeave={(): void => setShowPaletteTooltip(false)} + id={`${tooltipId}-palette`} + > + + + Edit Color + +
+ )} + {(divHover || edit) && + hasEdit && + setInvitationModalOpen !== undefined && + setInvitationModalEmail !== undefined && + email && ( +
setShowShareTooltip(true)} + onMouseLeave={(): void => setShowShareTooltip(false)} + id={`${tooltipId}-share`} + > + + + Share Back + +
+ )} + {(divHover || edit) && hasEdit && ( +
setShowEditTooltip(true)} + onMouseLeave={(): void => setShowEditTooltip(false)} + id={`${tooltipId}-edit`} + > + + + Edit + +
+ )} + {(divHover || edit) && hasDelete && ( +
setShowRemoveTooltip(true)} + onMouseLeave={(): void => setShowRemoveTooltip(false)} + id={`${tooltipId}-delete`} + > + + + Remove + +
+ )} +
+ {hasPalette && palette && setFriendScheduleColor && setPaletteInfo && ( + setPaletteInfo('')} + /> + )} +
+ ); +} + +type ComparisonModalProps = { + deleteConfirm: DeleteInfo; + setDeleteConfirm: (deleteConfirm: DeleteInfo) => void; + deleteVersion: (id: string) => void; + handleRemoveFriend: (id: string) => void; + handleRemoveSchedule: (id: string, owner: string) => void; +}; + +function ComparisonModal({ + deleteConfirm, + setDeleteConfirm, + deleteVersion, + handleRemoveFriend, + handleRemoveSchedule, +}: ComparisonModalProps): React.ReactElement { + return ( + setDeleteConfirm(null)} + buttons={[ + { + label: 'Remove', + onClick: (): void => { + if (deleteConfirm != null) { + if (deleteConfirm.type === 'Version') { + deleteVersion(deleteConfirm.id); + } else if (deleteConfirm.type === 'User') { + handleRemoveFriend(deleteConfirm.id); + } else { + handleRemoveSchedule( + deleteConfirm.id, + deleteConfirm.owner ?? '' + ); + } + } + setDeleteConfirm(null); + }, + }, + ]} + preserveChildrenWhileHiding + > + + {deleteConfirm?.type === 'Version' && ( +
+

Delete confirmation

+

+ Are you sure you want to delete schedule “ + {deleteConfirm?.name ?? ''}”? +

+
+ )} + {deleteConfirm?.type === 'User' && ( +
+

Remove User

+

+ Are you sure you want to remove the following user's schedules + from your view? +

+

+ User: {deleteConfirm?.name} +

+

+ You will not be able to see any of their schedules unless the owner + sends another invite for each one. +

+
+ )} + {deleteConfirm?.type === 'Schedule' && ( +
+

Remove Schedule

+

+ Are you sure you want to remove the following schedule from your + view? +

+

+ Schedule: {deleteConfirm?.name}
+ Owner: {deleteConfirm?.ownerName} +

+

+ You will not be able to see it unless the owner sends another + invite. +

+
+ )} +
+ ); +} diff --git a/src/components/ComparisonContainer/stylesheet.scss b/src/components/ComparisonContainer/stylesheet.scss new file mode 100644 index 00000000..4f68ce89 --- /dev/null +++ b/src/components/ComparisonContainer/stylesheet.scss @@ -0,0 +1,298 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap'); +@import '../../variables'; + +.comparison-container { + height: auto; + .comparison-body { + display: flex; + flex-direction: column; + + .comparison-content { + position: relative; + overflow-x: hidden; + overflow-y: auto; + flex: 1 1 auto; + + p { + margin: 0px; + overflow: hidden; + font-size: 14px; + font-weight: 400; + line-height: normal; + + &.content-title { + font-weight: 700; + margin: 16px 12px 0px 12px; + border-bottom: 2px solid $color-border; + } + + &.my-schedule-title { + font-weight: 700; + margin: 0px 12px 0px 12px; + border-bottom: 2px solid $color-border; + } + + &.shared-with { + margin-bottom: 4px; + } + } + + .friend { + padding-bottom: 10px; + } + + .friend-email { + display: flex; + align-items: center; + p { + padding: 0px 2px 2px 12px; + text-align: center; + } + } + + .checked { + p { + font-weight: 700; + font-size: 14px; + } + } + + .schedule-name { + cursor: pointer; + } + + .friend-name { + p { + font-weight: 575; + font-size: 16px; + } + } + + .no-shared-schedules { + margin: 8px 12px; + + p { + font-size: 13px; + font-weight: 400; + font-style: italic; + white-space: pre-wrap; + } + + & > p { + margin: 0px 0px 6px; + } + } + + .schedule-row { + .checkbox-container { + display: flex; + align-items: center; + position: relative; + height: 22px; + + .checkbox { + margin-left: 12px; + border: 1px solid; + border-radius: 3px; + border-color: var(--theme-fg); + transition-duration: $theme-switch-transition-duration; + transition-property: border-color; + + width: 12px; + height: 12px; + + &:hover { + cursor: pointer; + } + + &.indented { + margin-left: 24px; + } + } + + .name { + margin-left: 12px; + margin-right: 12px; + min-width: 0; + flex-shrink: 1; + + &.check { + margin-left: 8px; + } + + p { + text-overflow: ellipsis; + } + } + + .edit-input { + height: 22px; + border-radius: 4px; + padding: 4px; + flex: 1 1; + font-size: 12px; + font-weight: 500; + outline: none; + border: 1px solid var(--theme-fg); + margin-left: 8px; + min-width: 0px; + + &.check { + margin-left: 4px; + } + } + + .spacing { + flex: 1; + } + + .tooltip { + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + } + + .icon { + width: 27px; + height: 22px; + padding: 0px; + opacity: 0; + } + + &:hover { + background-color: $color-border; + + .icon { + opacity: 1; + } + + .name { + margin-right: 0px; + } + } + + &.editing { + .icon { + opacity: 1; + } + } + + &.schedule-checkbox { + margin-top: 4px; + margin-bottom: 2px; + } + } + + .palette { + margin: 0px 12px; + height: 50px; + border-radius: 4px; + overflow: hidden; + &.indented { + margin-left: 24px; + } + } + } + + .shareback-panel { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 12px; + margin-left: 10px; + margin-right: 10px; + border: 1px solid $color-neutral; + border-radius: 8px; + padding: 8px 10px; + gap: 6px; + + p { + font-size: 12px; + line-height: normal; + font-style: oblique; + overflow: visible; + } + + .shareback-button { + width: 100px; + height: 24px; + padding: 6px 10px; + font-size: 9px; + color: var(--theme-bg); + background-color: var(--theme-fg); + border-radius: 8px; + border: 1px solid rgba(128, 128, 128, 0.2); + margin-left: 5px; + font-weight: 650; + cursor: pointer; + + &:hover { + @include dark { + background: $modal-foreground-color-dark; + } + + @include light { + background: $modal-foreground-color-light; + } + } + } + + .dont-shareback-button { + width: 100px; + height: 24px; + padding: 6px 10px; + font-size: 9px; + color: var(--theme-fg); + background-color: var(--theme-bg); + border-radius: 8px; + border: 1px solid $color-neutral; + margin-right: 5px; + font-weight: 650; + cursor: pointer; + + &:hover { + @include dark { + background: $modal-foreground-color-light; + } + + @include light { + background: $modal-foreground-color-dark; + } + } + } + } + } + } +} + +.mobile .comparison-container { + flex: 1; + border-right: none; + + .scroller { + width: auto; + } +} + +.shared-schedule-modal { + h2 { + font-weight: 700; + font-size: 24px; + line-height: 28px; + } + + p { + font-size: 14px; + line-height: 18px; + overflow-wrap: break-word; + } + + .cancel-button { + width: 26px; + height: 26px; + position: absolute; + top: 11px; + right: 11px; + border-radius: 50%; + color: $color-neutral; + } +} diff --git a/src/components/ComparisonContainerShareBack/ComparisonContainerShareBack.tsx b/src/components/ComparisonContainerShareBack/ComparisonContainerShareBack.tsx new file mode 100644 index 00000000..4e1a1b8b --- /dev/null +++ b/src/components/ComparisonContainerShareBack/ComparisonContainerShareBack.tsx @@ -0,0 +1,89 @@ +import React, { useContext, useMemo } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; + +import { ScheduleContext } from '../../contexts'; + +type ComparisonContainerShareBack = { + friendId: string; + friendName: string; + friendEmail: string; + setModalEmail: React.Dispatch>; + setModalOpen: React.Dispatch>; +}; + +export default function ComparisonContainerShareBack({ + friendName, + friendEmail, + friendId, + setModalEmail, + setModalOpen, +}: ComparisonContainerShareBack): React.ReactElement | null { + const [{ allFriends, allVersionNames }] = useContext(ScheduleContext); + + const [hasSeen, setHasSeen] = useLocalStorageState( + `share-back-invitation-${friendId}`, + { + defaultValue: false, + storageSync: true, + } + ); + + const schedulesShared = useMemo(() => { + return Object.keys(allFriends) + .map((version_id) => { + if ( + friendId && + allFriends[version_id] && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + friendId in allFriends[version_id]! + ) { + const versionName = allVersionNames.filter( + (v) => v.id === version_id + ); + if (versionName.length > 0) { + return versionName[0]?.name; + } + } + return undefined; + }) + .filter((v) => v) as string[]; + }, [friendId, allFriends, allVersionNames]); + + if (hasSeen || schedulesShared.length === allVersionNames.length) { + return null; + } + + return ( +
+
+

+ You have {friendName}'s schedule. Would you like + to share yours back? +

+
+
+ + + +
+
+ ); +} diff --git a/src/components/ComparisonPanel/index.tsx b/src/components/ComparisonPanel/index.tsx new file mode 100644 index 00000000..ad0a5a3f --- /dev/null +++ b/src/components/ComparisonPanel/index.tsx @@ -0,0 +1,176 @@ +import React, { useState, useContext, useId, useCallback } from 'react'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import { faShare } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { CombinationContainer, ComparisonContainer } from '..'; +import { AccountContext } from '../../contexts/account'; +import { classes } from '../../utils/misc'; +import InvitationModal from '../InvitationModal'; +import LoginModal from '../LoginModal'; +import InvitationAcceptModal from '../InvitationAcceptModal/InvitationAcceptModal'; + +import './stylesheet.scss'; + +export type ComparisonPanelProps = { + handleCompareSchedules: ( + compare?: boolean, + pinnedSchedules?: string[], + pinSelf?: boolean, + expanded?: boolean, + overlaySchedules?: string[] + ) => void; + pinnedSchedules: string[]; + compare: boolean; + expanded: boolean; +}; + +export default function ComparisonPanel({ + handleCompareSchedules, + pinnedSchedules, + compare, + expanded, +}: ComparisonPanelProps): React.ReactElement { + const [hover, setHover] = useState(false); + const [tooltipY, setTooltipY] = useState(0); + const [invitationOpen, setInvitationOpen] = useState(false); + // const [hoverCompare, setHoverCompare] = useState(false); + // const [tooltipYCompare, setTooltipYCompare] = useState(0); + const tooltipId = useId(); + const [loginOpen, setLoginOpen] = useState(false); + const hideLogin = useCallback(() => setLoginOpen(false), []); + + const hideInvitation = useCallback(() => setInvitationOpen(false), []); + + const { type } = useContext(AccountContext); + + const handleHover = useCallback((e: React.MouseEvent) => { + setHover(true); + setTooltipY(e.clientY); + }, []); + + const handleOpenInvitation = useCallback(() => { + if (type === 'signedIn') { + setInvitationOpen(true); + } else { + setLoginOpen(true); + } + }, [type]); + + const handleTogglePanel = useCallback(() => { + if (type === 'signedIn') { + handleCompareSchedules(!compare, undefined, undefined); + } else { + setLoginOpen(true); + } + }, [type, compare, handleCompareSchedules]); + + const [shareBackRemount, setShareBackRemount] = useState(0); + + return ( +
+ +
{ + handleCompareSchedules(undefined, undefined, undefined, !expanded); + setHover(false); + }} + onMouseEnter={(e: React.MouseEvent): void => { + handleHover(e); + }} + onMouseLeave={(): void => setHover(false)} + id={tooltipId} + > +
+
+
+
+
+ +

{expanded ? 'Collapse' : 'Expand for More Options'}

+
+
+
+ +
+ +
+
+

Compare Schedules

+

{compare ? 'On' : 'Off'}

+
+ ); +} diff --git a/src/components/ComparisonPanel/stylesheet.scss b/src/components/ComparisonPanel/stylesheet.scss new file mode 100644 index 00000000..d2b99e01 --- /dev/null +++ b/src/components/ComparisonPanel/stylesheet.scss @@ -0,0 +1,234 @@ +@import '../../variables'; + +.comparison-panel { + display: flex; + flex-direction: row; + + .drawer { + width: 13px; + border-width: 0px 0px 0px 1px; + border-color: rgba(255, 255, 255, 0.5); + border-style: solid; + display: flex; + flex-direction: column; + align-items: center; + + &.opened { + border-color: $color-border; + border-width: 0px 1px 0px 1px; + } + + &:hover { + background: $color-border; + } + + .drawer-line { + flex: 1; + width: 5px; + border-width: 0px 1px 0px 1px; + border-color: rgba(255, 255, 255, 0.5); + border-style: solid; + &.opened { + border-color: $color-border; + } + } + + .icon { + display: flex; + align-items: center; + height: 48px; + transform: scale(0.9, 1.5); + + .arrow { + position: absolute; + height: 10px; + width: 10px; + right: -8px; + border-width: 0px 2px 2px 0px; + border-color: rgba(255, 255, 255, 1); + border-style: solid; + transform: rotate(135deg); + + &.right { + left: -8px; + transform: rotate(-45deg); + border-color: $color-border; + } + } + } + + .tooltip { + background: black; + border-radius: 4px; + z-index: 10; + + p { + margin: 0px; + font-size: 12px; + font-weight: 400; + } + } + } + + .panel { + display: flex; + flex: 0 0 1; + flex-direction: column; + align-items: stretch; + width: 256px; + transition: width 0.15s; + overflow-y: auto; + + .comparison-header { + display: flex; + align-items: center; + margin: 11px 12px 20px 12px; + + p { + margin: 0px; + overflow: hidden; + + &.header-title { + flex: 1; + font-size: 16px; + } + + &.header-text { + font-size: 12px; + margin-right: 4px; + } + } + + .switch { + display: inline-block; + height: 19px; + position: relative; + width: 43px; + } + + .switch input { + display: none; + } + + .slider { + background-color: $color-neutral; + bottom: 0; + cursor: pointer; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: 0.4s; + border-radius: 19px; + } + + .slider:before { + background-color: #ffffff; + bottom: 2px; + content: ''; + height: 15px; + left: 2px; + position: absolute; + transition: 0.4s; + width: 15px; + border-radius: 50%; + } + + input.checked + .slider { + background-color: #589bd5; + } + + input.checked + .slider:before { + transform: translateX(24px); + } + } + + .comparison-overlay { + background-color: var(--theme-bg); + + pointer-events: none; + opacity: 0; + transition-duration: 0.15s, $theme-switch-transition-duration, + $theme-switch-transition-duration; + transition-property: opacity, color, background-color; + + &.left { + position: fixed; + top: 64px; + left: 0px; + width: 320px; + bottom: 41px; + } + + &.right { + position: absolute; + top: 100px; + width: 256px; + height: 360px; + } + + &.open { + pointer-events: all; + opacity: 0.4; + } + } + + .overlay-tooltip { + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + font-size: 12px; + text-align: center; + p { + margin: 0px; + } + } + + &.closed { + width: 0px; + } + + .combination { + flex: 1; + + .content-title { + font-weight: 700; + font-size: 14px; + margin: 18px 12px 4px 12px; + border-bottom: 2px solid $color-border; + } + + .CombinationContainer { + height: 100%; + } + } + } + .invite-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + + .invite-button { + align-items: center; + font-size: 14px; + font-weight: 700; + color: var(--theme-fg); + border-radius: 10px; + background-color: $color-border; + display: flex; + padding: 10px 47px 10px 47px; + border: none; + gap: 5px; + + &:hover { + @include dark { + background: $modal-foreground-color-light; + } + + @include light { + background: $modal-foreground-color-dark; + } + } + } + } +} diff --git a/src/components/CourseAdd/stylesheet.scss b/src/components/CourseAdd/stylesheet.scss index d7e9070f..82937869 100644 --- a/src/components/CourseAdd/stylesheet.scss +++ b/src/components/CourseAdd/stylesheet.scss @@ -73,4 +73,4 @@ padding: 4px; font-size: .8em; } -} +} \ No newline at end of file diff --git a/src/components/CourseContainer/stylesheet.scss b/src/components/CourseContainer/stylesheet.scss index 6e2b4ab6..b82aae35 100644 --- a/src/components/CourseContainer/stylesheet.scss +++ b/src/components/CourseContainer/stylesheet.scss @@ -28,7 +28,7 @@ .updated-at { color: $color-neutral; - font-size: .8em; + font-size: 0.8em; } } diff --git a/src/components/CourseNavMenu/index.tsx b/src/components/CourseNavMenu/index.tsx index 07a74b70..1596bbaa 100644 --- a/src/components/CourseNavMenu/index.tsx +++ b/src/components/CourseNavMenu/index.tsx @@ -20,6 +20,7 @@ export default function CourseNavMenu({
{items.map((item, idx) => ( onChangeItem(idx)} diff --git a/src/components/DonateBanner/index.tsx b/src/components/DonateBanner/index.tsx new file mode 100644 index 00000000..a8613ab7 --- /dev/null +++ b/src/components/DonateBanner/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import useLocalStorageState from 'use-local-storage-state'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import Button from '../Button'; +import { DESKTOP_BREAKPOINT } from '../../constants'; +import useScreenWidth from '../../hooks/useScreenWidth'; + +import './stylesheet.scss'; + +const BANNER_LOCAL_STORAGE_KEY = '2024-04-01-spr2024-donate-banner'; + +export default function DonateBanner(): React.ReactElement { + const [hasSeen, setHasSeen] = useLocalStorageState(BANNER_LOCAL_STORAGE_KEY, { + defaultValue: false, + storageSync: true, + }); + const mobile = !useScreenWidth(DESKTOP_BREAKPOINT); + + return ( +
+ {!hasSeen ? ( +
+
+ + {!mobile + ? 'Help keep GT Scheduler and its amazing features running!' + : 'Help us and'} + + + {!mobile ? 'Donate today.' : 'donate today.'} + + + + +
+ ) : ( +
+ )} +
+ ); +} diff --git a/src/components/DonateBanner/stylesheet.scss b/src/components/DonateBanner/stylesheet.scss new file mode 100644 index 00000000..ac338e1e --- /dev/null +++ b/src/components/DonateBanner/stylesheet.scss @@ -0,0 +1,23 @@ +.banner { + width: 100%; + height: fit-content; + color: white; + background-color: #C56E5B; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + + .donateButton { + display: inline-block; + padding: 10px 6px; + } + + .donateButton:hover { + text-decoration: underline; + } + + .spacer { + width: 49px; + } +} \ No newline at end of file diff --git a/src/components/Event/index.tsx b/src/components/Event/index.tsx index 3b3e87d5..70e54d12 100644 --- a/src/components/Event/index.tsx +++ b/src/components/Event/index.tsx @@ -4,6 +4,7 @@ import { faPencil, faPalette, faTrash, + faClone, } from '@fortawesome/free-solid-svg-icons'; import { @@ -11,6 +12,7 @@ import { getContentClassName, periodToString, daysToString, + getRandomColor, } from '../../utils/misc'; import { ActionRow, EventAdd, Palette } from '..'; import { ScheduleContext } from '../../contexts'; @@ -31,6 +33,32 @@ export default function Event({ const [{ events, colorMap }, { patchSchedule }] = useContext(ScheduleContext); const [formShown, setFormShown] = useState(false); + const handleDuplicateEvent = useCallback(() => { + const eventId = new Date().getTime().toString(); + const newEvent = { + id: eventId, + name: event.name, + period: { + start: event.period.start, + end: event.period.end, + }, + days: event.days, + }; + + patchSchedule({ + events: [...castDraft(events), castDraft(newEvent)], + colorMap: { ...colorMap, [eventId]: getRandomColor() }, + }); + }, [ + colorMap, + event.days, + event.name, + event.period.end, + event.period.start, + events, + patchSchedule, + ]); + const handleRemoveEvent = useCallback( (id: string) => { const newColorMap = { ...colorMap }; @@ -70,6 +98,12 @@ export default function Event({ id: `${event.id}-color`, onClick: (): void => setPaletteShown(!paletteShown), }, + { + icon: faClone, + tooltip: 'Duplicate Event', + id: `${event.id}-duplicate`, + onClick: (): void => handleDuplicateEvent(), + }, { icon: faTrash, tooltip: `Remove Event`, diff --git a/src/components/EventBlocks/index.tsx b/src/components/EventBlocks/index.tsx index e88af9ce..6f987ea7 100644 --- a/src/components/EventBlocks/index.tsx +++ b/src/components/EventBlocks/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext, useRef } from 'react'; +import React, { useState, useContext, useRef, useEffect } from 'react'; import { Immutable, castDraft } from 'immer'; import { daysToString, periodToString } from '../../utils/misc'; @@ -20,14 +20,18 @@ export interface EventBlockPosition extends TimeBlockPosition { export type EventBlocksProps = { className?: string; event: Immutable; + owner?: string; + scheduleName?: string; + scheduleId?: string; + overlay?: boolean; capture: boolean; includeDetailsPopover: boolean; includeContent: boolean; sizeInfo: SizeInfo; canBeTabFocused?: boolean; deviceHasHover?: boolean; - daysRef: React.RefObject; - timesRef: React.RefObject; + daysRef?: React.RefObject; + timesRef?: React.RefObject; selectedMeeting?: [meetingIndex: number, day: string] | null; onSelectMeeting?: ( meeting: [meetingIndex: number, day: string] | null @@ -37,6 +41,10 @@ export type EventBlocksProps = { export default function EventBlocks({ className, event, + owner, + scheduleName, + scheduleId, + overlay = false, capture, sizeInfo, includeDetailsPopover, @@ -48,6 +56,18 @@ export default function EventBlocks({ selectedMeeting, onSelectMeeting, }: EventBlocksProps): React.ReactElement | null { + const popover = scheduleName + ? [ + { + name: 'Owner', + content: owner, + }, + { + name: 'Schedule', + content: scheduleName, + }, + ] + : []; const [tempStart, setTempStart] = useState(event.period.start); // Store these in refs since the event handlers won't be re generated @@ -55,6 +75,10 @@ export default function EventBlocks({ const tempStartRef = useRef(event.period.start); const tempDaysRef = useRef([...event.days]); + useEffect(() => { + setTempStart(event.period.start); + }, [event.period.start]); + // Save original style of the block const savedStyleRef = useRef(); const savedClassListRef = useRef(); @@ -144,7 +168,7 @@ export default function EventBlocks({ e: MouseEvent, ref: React.RefObject ): void => { - if (!ref.current || !timesRef.current || !daysRef.current) return; + if (!ref.current || !timesRef?.current || !daysRef?.current) return; // math which calculates the new start time by calculating mouse // position proportional to calendar size, then we find new time @@ -213,7 +237,7 @@ export default function EventBlocks({ ] : [] } - popover={[ + popover={popover.concat([ { name: 'Name', content: event.name, @@ -225,13 +249,15 @@ export default function EventBlocks({ periodToString(event.period), ].join(' '), }, - ]} + ])} + overlay={overlay} capture={capture} sizeInfo={sizeInfo} includeDetailsPopover={!dragging && includeDetailsPopover} includeContent={includeContent} canBeTabFocused={canBeTabFocused} onSelectMeeting={onSelectMeeting} + schedule={scheduleId} selectedMeeting={selectedMeeting} deviceHasHover={deviceHasHover} handleMouseDown={handleMouseDown} diff --git a/src/components/Feedback/stylesheet.scss b/src/components/Feedback/stylesheet.scss index 59f0ddec..18c1a17c 100644 --- a/src/components/Feedback/stylesheet.scss +++ b/src/components/Feedback/stylesheet.scss @@ -24,7 +24,7 @@ --feedback-outer-color: #{$theme-light-background}; --feedback-inner-color: #{$theme-light-card-background}; } - + background-color: var(--feedback-outer-color); // Include theme switch transition @@ -57,25 +57,25 @@ display: flex; flex-direction: column; } - + .text { margin-top: 10px; font-size: 16px; margin-bottom: 20px; } - - .FeedbackTitle { + + .FeedbackTitle { font-size: 24px; margin-bottom: 16px; margin-top: 0; } - + .FormButtons { display: flex; flex-direction: row; align-items: flex-start; justify-content: space-between; - + & div { display: inline-block; justify-content: space-around; @@ -87,10 +87,10 @@ transition-property: background-color; } } - + .FormButton { vertical-align: middle; - height:40px; + height: 40px; width: 40px; border-radius: 5px; &.active { @@ -106,12 +106,12 @@ align-items: flex-start; justify-content: space-between; } - + .score { font-size: 14px; - color: #808080; + color: $color-neutral; } - + .FeedbackTextArea { margin-top: 20px; border: none; @@ -131,10 +131,10 @@ transition-property: background-color; &::placeholder { - color: #808080; + color: $color-neutral; } } - + .SubmitButton { position: relative; width: 100px; @@ -148,7 +148,7 @@ margin-right: auto; color: white; } - + .CloseIcon { position: absolute; top: 0; diff --git a/src/components/HeaderActionBar/index.tsx b/src/components/HeaderActionBar/index.tsx index 7e4ca2ee..f7c4c57a 100644 --- a/src/components/HeaderActionBar/index.tsx +++ b/src/components/HeaderActionBar/index.tsx @@ -1,25 +1,24 @@ -import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faDownload, faCalendarAlt, faPaste, - faAdjust, faCaretDown, + faHandHoldingDollar, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import React, { useCallback, useContext } from 'react'; +import React, { useCallback, useState } from 'react'; -import { Button } from '..'; +import { Button, InvitationModal } from '..'; import { LARGE_MOBILE_BREAKPOINT, LARGE_DESKTOP_BREAKPOINT, } from '../../constants'; -import { ThemeContext } from '../../contexts'; import useMedia from '../../hooks/useMedia'; import { AccountContextValue } from '../../contexts/account'; import { classes } from '../../utils/misc'; import { DropdownMenu, DropdownMenuAction } from '../Select'; import AccountDropdown from '../AccountDropdown'; +import ShareIcon from '../ShareIcon'; import './stylesheet.scss'; @@ -53,11 +52,9 @@ export default function HeaderActionBar({ onDownloadCalendar = (): void => undefined, enableDownloadCalendar = false, }: HeaderActionBarProps): React.ReactElement { - const [theme, setTheme] = useContext(ThemeContext); - const handleThemeChange = useCallback(() => { - const newTheme = theme === 'light' ? 'dark' : 'light'; - setTheme(newTheme); - }, [theme, setTheme]); + const [invitationOpen, setInvitationOpen] = useState(false); + + const hideInvitation = useCallback(() => setInvitationOpen(false), []); // Coalesce the export options into the props for a single const enableExport = @@ -99,6 +96,19 @@ export default function HeaderActionBar({ return (
+ +
- +
Export
- - - +
diff --git a/src/components/HeaderActionBar/stylesheet.scss b/src/components/HeaderActionBar/stylesheet.scss index f268cec9..c4581e1e 100644 --- a/src/components/HeaderActionBar/stylesheet.scss +++ b/src/components/HeaderActionBar/stylesheet.scss @@ -5,6 +5,14 @@ align-items: stretch; justify-content: flex-end; + .invite-button { + .circle { + margin: 4px 0px 0px 8px; + width: 8px; + color: #ff7337; + } + } + @media (max-width: $desktop-breakpoint) { flex: 1; margin-left: 0; diff --git a/src/components/HeaderDisplay/index.tsx b/src/components/HeaderDisplay/index.tsx index 864c2088..290df570 100644 --- a/src/components/HeaderDisplay/index.tsx +++ b/src/components/HeaderDisplay/index.tsx @@ -128,10 +128,10 @@ export default function HeaderDisplay({ )} {/* Left-aligned logo */} - +
{/* Term selector */} {termsState.type === 'loaded' ? ( diff --git a/src/components/HeaderDisplay/stylesheet.scss b/src/components/HeaderDisplay/stylesheet.scss index 775598cd..9bebfdf8 100644 --- a/src/components/HeaderDisplay/stylesheet.scss +++ b/src/components/HeaderDisplay/stylesheet.scss @@ -12,6 +12,11 @@ font-weight: bold; white-space: pre; padding: 0 16px; + + display: flex; + justify-content: center; + align-items: center; + user-select: none; } .tabs { diff --git a/src/components/InformationModal/index.tsx b/src/components/InformationModal/index.tsx index 401b4bbf..4f6459a1 100644 --- a/src/components/InformationModal/index.tsx +++ b/src/components/InformationModal/index.tsx @@ -1,17 +1,21 @@ import React, { useEffect, useState } from 'react'; import useLocalStorageState from 'use-local-storage-state'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { classes } from '../../utils/misc'; import { DESKTOP_BREAKPOINT } from '../../constants'; import Modal from '../Modal'; import useScreenWidth from '../../hooks/useScreenWidth'; +import Button from '../Button'; import './stylesheet.scss'; // Key to mark when a user has already been shown the information modal. // Update this when updating the contents of the modal. -const MODAL_LOCAL_STORAGE_KEY = '2023-04-05-spr2023-new-features-announcement'; +const MODAL_LOCAL_STORAGE_KEY = '2024-04-01-spr2024-new-features-announcement'; const OUTDATED_LOCAL_STORAGE_KEY = [ + '2023-04-05-spr2023-new-features-announcement', '2021-10-27-spr2022-schedule-versions-account-sync', '2023-03-05-spr2023-oscar-migration', ]; @@ -22,11 +26,25 @@ const OUTDATED_LOCAL_STORAGE_KEY = [ * Additionally, make sure to change `MODAL_LOCAL_STORAGE_KEY` * with another unique value that has never been used before. */ -export function InformationModalContent(): React.ReactElement { + +export type InformationModalContentProps = { + setShow: (show: boolean) => void; +}; + +export function InformationModalContent({ + setShow, +}: InformationModalContentProps): React.ReactElement { return ( <> + GT Scheduler Logo @@ -38,36 +56,35 @@ export function InformationModalContent(): React.ReactElement { margin: '16px auto', }} > - New Feature: Recurring Events + New Feature: Compare Schedules -

April 6, 2023

+

April 2, 2024

Hello Yellow Jackets! We are excited to announce a new feature for GT Scheduler.

- Use Recurring Events to block out meetings, work shifts, and any other - weekly events you may need to schedule your classes around. -
-
- Add, edit, and delete events in the Recurring Events tab, next to the - Courses tab. + Share your schedule with other students and they can share theirs + back. Then toggle "Compare Schedules" and click on the other + students' schedules to compare.

- Drag and drop (web-only) events on the schedule view to adjust times. + However, to keep GT Scheduler and its amazing features, we need to + maintain our costs. Please consider donating to help keep GT Scheduler + running!

Event Blocks
Drag Drop
@@ -104,10 +121,18 @@ export default function InformationModal(): React.ReactElement { className={classes('InformationModal', mobile && 'mobile')} show={show} onHide={(): void => setShow(false)} - buttons={[{ label: 'Got it!', onClick: (): void => setShow(false) }]} - width={800} + buttons={[ + { + label: 'Donate Today', + onClick: (): void => { + setShow(false); + window.open('https://donorbox.org/gt-scheduler'); + }, + }, + ]} + width={850} > - + ); } diff --git a/src/components/InformationModal/stylesheet.scss b/src/components/InformationModal/stylesheet.scss index 13adc8d9..833fc785 100644 --- a/src/components/InformationModal/stylesheet.scss +++ b/src/components/InformationModal/stylesheet.scss @@ -1,23 +1,38 @@ +@import '../../variables.scss'; + .InformationModal { - align-items: center; + .close-button { + width: 16px; + height: 16px; + position: absolute; + right: 12px; + top: 12px; + padding: 15px; + color: $color-neutral; + border-radius: 50px; + } .information-content { display: flex; + position: relative; width: 100%; align-items: center; justify-content: center; + margin-bottom: 20px; + padding-left: 15%; + padding-right: 15%; p { margin: 0px; - width: 270px; + width: 50%; } .information-images { display: flex; + overflow: hidden; + align-items: center; .information-image { width: auto; - height: 275px; + height: 25vh; margin-left: 16px; display: block; - border: #505050 3px solid; - border-radius: 12px; overflow: hidden; } } @@ -31,6 +46,7 @@ width: 100%; align-items: center; justify-content: center; + padding: 0; p { margin: auto; width: 80vw; @@ -38,16 +54,15 @@ } .information-images { display: flex; + justify-content: space-evenly; .information-spacer { - flex: 1; + flex: .5; } .information-image { width: auto; - height: 65vw; + height: 45vw; margin: 16px 0px 0px 0px; display: block; - border: #505050 3px solid; - border-radius: 12px; overflow: hidden; } } diff --git a/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx b/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx new file mode 100644 index 00000000..3db335ca --- /dev/null +++ b/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx @@ -0,0 +1,328 @@ +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { FriendContext, ScheduleContext } from '../../contexts'; +import Button from '../Button'; +import Modal from '../Modal'; +import InvitationModal from '../InvitationModal'; +import LoginModal from '../LoginModal'; +import SuccesfulInvitationImage from '../SuccesfulInvitationImage'; + +import './stylesheet.scss'; + +export type InvitationAcceptModalProps = { + handleCompareSchedules: ( + compare?: boolean, + pinnedSchedules?: string[], + pinSelf?: boolean, + expanded?: boolean + ) => void; + setShareBackRemount: React.Dispatch>; +}; + +export default function InvitationAcceptModal({ + handleCompareSchedules, + setShareBackRemount, +}: InvitationAcceptModalProps): React.ReactElement { + const [modalOpen, setModalOpen] = useState(false); + const [invitationModalOpen, setInvitationModalOpen] = + useState(false); + const [loginModalOpen, setLoginModalOpen] = useState(false); + const [friendID, setFriendID] = useState(); + const [email, setEmail] = useState(); + + const [searchParams] = useSearchParams(); + + const navigate = useNavigate(); + + const [{ friends }] = useContext(FriendContext); + const [{ allFriends, allVersionNames }, { setTerm }] = + useContext(ScheduleContext); + + const schedulesShared = useMemo(() => { + return Object.keys(allFriends) + .map((version_id) => { + if ( + friendID && + allFriends[version_id] && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + friendID in allFriends[version_id]! + ) { + const versionName = allVersionNames.filter( + (v) => v.id === version_id + ); + if (versionName.length > 0) { + return versionName[0]?.name; + } + } + return undefined; + }) + .filter((v) => v) as string[]; + }, [friendID, allFriends, allVersionNames]); + + const schedulesReceived = useMemo((): string[] | undefined => { + if (friendID) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return Object.keys(friends[friendID]!.versions) + .map((version_id): string | undefined => { + return friends[friendID]?.versions[version_id]?.name; + }) + .filter((name) => name) as string[]; + } + return undefined; + }, [friendID, friends]); + + const friendName = useMemo((): string | undefined => { + if (friendID) { + return friends[friendID]?.name; + } + return undefined; + }, [friendID, friends]); + + useEffect(() => { + if ( + !searchParams.get('inviteId') || + !searchParams.get('status') || + !searchParams.get('email') + ) { + setModalOpen(false); + return; + } + + const tempEmail: string | null = searchParams.get('email'); + + if (friends) { + Object.keys(friends).forEach((f_i) => { + if (friends[f_i] && friends[f_i]?.email === tempEmail) { + setFriendID(f_i); + } + }); + + setModalOpen(true); + } + + if ( + searchParams.get('status') === 'success' && + searchParams.get('term') !== null + ) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setTerm(searchParams.get('term')!); + } + }, [searchParams, friends, setTerm]); + + useEffect(() => { + if (searchParams.get('email') !== null && searchParams.get('email')) { + setEmail(searchParams.get('email') ?? ''); + } + }, [searchParams]); + + const onHide = (): void => { + setModalOpen(!modalOpen); + navigate('/'); + handleCompareSchedules(true, undefined, undefined, true); + }; + + return ( + <> + { + setInvitationModalOpen(false); + }} + inputEmail={email} + /> + + { + setLoginModalOpen(false); + }} + /> + + { + onHide(); + setLoginModalOpen(true); + }, + }, + ] + : schedulesShared && + schedulesShared.length !== allVersionNames.length && + searchParams.get('status') === 'success' + ? [ + { + label: 'No', + onClick: onHide, + cancel: true, + }, + { + label: 'Share Back', + onClick: (): void => { + onHide(); + localStorage.setItem( + `share-back-invitation-${friendID ?? ''}`, + 'true' + ); + setShareBackRemount(1); + setInvitationModalOpen(true); + }, + }, + ] + : [] + } + > + + {searchParams.get('status') === 'success' ? ( + + ) : ( + + )} + + + ); +} + +type SuccessContentProps = { + email: string; + name: string; + schedulesReceived: string[]; + schedulesSent: string[]; +}; + +function SuccessContent({ + name, + email, + schedulesReceived, + schedulesSent, +}: SuccessContentProps): React.ReactElement { + return ( +
+
+ You have successfully added a new schedule to your view! +
+ + + +
+ You will now be able to see {email}'s schedule! +
+
+ Schedules {`${name}`} has shared with you: + {schedulesReceived && + schedulesReceived.map((version, i) => { + if (i !== schedulesReceived.length - 1) { + return ( + + {' '} + {version}, + + ); + } + return ( + + {' '} + {version} + + ); + })} + {schedulesReceived?.length === 0 ? ( + + None + + ) : null} +
+
+ Schedules you have shared with {`${name}`}: + {schedulesSent && + schedulesSent.map((version, i) => { + if (i !== schedulesSent.length - 1) { + return ( + + {' '} + {version}, + + ); + } + return ( + + {' '} + {version} + + ); + })} + {schedulesSent?.length === 0 ? ( + + None + + ) : null} +
+
+ ); +} + +type FailureContentProps = { + error: string; +}; + +function FailureContent({ error }: FailureContentProps): React.ReactElement { + return ( +
+ buzz +
Failed to add new schedules
+
+ {error === 'invalid-invite' + ? 'Invalid Invite' + : error === 'invite-expired' + ? 'Invite Expired' + : error === 'not-logged-in' + ? 'Not Logged In' + : error === 'already-accepted-all' + ? 'Schedules Already Accepted' + : "Something's wrong here.."} +
+
+ {error === 'invalid-invite' ? ( + + The invite request is invalid, please ask the user for a new + invite. + + ) : error === 'invite-expired' ? ( + + The invite request has expired, please ask the user for a new + invite. + + ) : error === 'not-logged-in' ? ( + + Login and click on the invite link again to add your friend's + schedule to your view. + + ) : error === 'already-accepted-all' ? ( + You have already accepted these schedules from the user! + ) : ( + + An unknown error occurred on our end, please ask the user for a new + invite! + + )} +
+
+ ); +} diff --git a/src/components/InvitationAcceptModal/stylesheet.scss b/src/components/InvitationAcceptModal/stylesheet.scss new file mode 100644 index 00000000..e9155851 --- /dev/null +++ b/src/components/InvitationAcceptModal/stylesheet.scss @@ -0,0 +1,55 @@ +@import '../../variables.scss'; + +.invitation-accept-modal-content { + .heading { + font-size: 24px; + color: var(--theme-fg); + text-align: center; + font-weight: 700; + } + + .modal-image { + width: 250px; + padding-top: 20px; + } + + .error-sub-heading { + color: var(--theme-fg); + font-size: 18px; + padding: 10px; + font-weight: 700; + } + + .error-message { + text-align: center; + } + + .sub-heading { + color: var(--theme-fg); + font-size: 14px; + padding-top: 15px; + text-align: center; + } + + .buzz-image { + width: 200px; + height: 200px; + margin: 10px; + } + + padding: 5% 10% 5% 10%; + display: flex; + flex-direction: column; + align-items: center; +} + +.remove-close-button { + width: 16px; + height: 16px; + position: absolute; + right: 12px; + top: 12px; + padding: 15px; + color: $color-neutral; + border-radius: 50px; +} diff --git a/src/components/InvitationModal/index.tsx b/src/components/InvitationModal/index.tsx new file mode 100644 index 00000000..c31f8c76 --- /dev/null +++ b/src/components/InvitationModal/index.tsx @@ -0,0 +1,726 @@ +import React, { + KeyboardEvent, + useCallback, + useContext, + useState, + useRef, + useMemo, +} from 'react'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import { + faAngleDown, + faAngleUp, + faCheck, + faCircle, + faClose, + faLink, + faXmark, + faPaperPlane, + faXmarkCircle, + faCircleCheck, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import copy from 'copy-to-clipboard'; + +import { ApiErrorResponse, FriendShareData } from '../../data/types'; +import { ScheduleContext } from '../../contexts'; +import { DESKTOP_BREAKPOINT, CLOUD_FUNCTION_BASE_URL } from '../../constants'; +import useScreenWidth from '../../hooks/useScreenWidth'; +import { classes } from '../../utils/misc'; +import Modal from '../Modal'; +import Button from '../Button'; +import { AccountContext, SignedIn } from '../../contexts/account'; +import { ErrorWithFields, softError } from '../../log'; +import Spinner from '../Spinner'; +import { ScheduleDeletionRequest } from '../../types'; +import useDeepCompareEffect from '../../hooks/useDeepCompareEffect'; + +import './stylesheet.scss'; + +/** + * Inner content of the invitation modal. + */ +export type InvitationModalContentProps = { + inputEmail?: string; +}; + +export function InvitationModalContent({ + inputEmail, +}: InvitationModalContentProps): React.ReactElement { + const [removeInvitationOpen, setRemoveInvitationOpen] = useState(false); + const [toRemoveInfo, setToRemoveInfo] = useState({ + version: { id: '', name: '' }, + friendId: '', + }); + const [otherSchedulesVisible, setOtherSchedulesVisible] = useState(false); + const [expirationDropdownVisible, setExpirationDropdownVisible] = + useState(false); + const [selectedExpiration, setSelectedExpiration] = useState('Never'); + + // All choices sent in seconds + const expirationChoices = useMemo( + (): Record => ({ + Never: 356 * 24 * 3600, + '1 week': 7 * 24 * 3600, + '1 day': 24 * 3600, + '1 hour': 3600, + }), + [] + ); + + const [{ currentVersion, term, allVersionNames, allFriends }] = + useContext(ScheduleContext); + const accountContext = useContext(AccountContext); + const mobile = !useScreenWidth(DESKTOP_BREAKPOINT); + + const input = useRef(null); + const [validMessage, setValidMessage] = useState(''); + const [validClassName, setValidClassName] = useState(''); + const [emailIcon, setEmailIcon] = useState('send'); + const [linkButtonClassName, setLinkButtonClassName] = useState(''); + const [linkLoading, setLinkLoading] = useState(false); + const [checkedSchedules, setCheckedSchedules] = useState([currentVersion]); + // const [invitationLink, setInvitationLink] = useState(''); + const [emailInput, setEmailInput] = useState(inputEmail ?? ''); + + const redirectURL = useMemo( + () => window.location.href.split('/#')[0] ?? '/', + [] + ); + + const handleChangeSearch = useCallback( + (e: React.ChangeEvent) => { + setEmailInput(e.target.value); + setValidMessage(''); + setValidClassName(''); + setEmailIcon('send'); + }, + [setEmailInput] + ); + + const sendInvitation = useCallback(async (): Promise => { + const IdToken = await (accountContext as SignedIn).getToken(); + const data = JSON.stringify({ + IDToken: IdToken, + term, + versions: checkedSchedules, + redirectURL, + friendEmail: input.current?.value, + }); + + return axios.post( + `${CLOUD_FUNCTION_BASE_URL}/createFriendInvitation`, + `data=${data}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + }, [accountContext, term, redirectURL, checkedSchedules]); + + // verify email with a regex and send invitation if valid + const verifyEmail = useCallback((): void => { + if (!input.current?.value) { + return; + } + + setEmailIcon('spinner'); + if (!/^\S+@\S+\.\S+$/.test(input.current.value)) { + setValidMessage('Invalid email, please try again!'); + setEmailIcon('send'); + return setValidClassName('invalid-email'); + } + const numNotAccepted = Object.entries(allFriends).reduce( + (acc, [versionId]) => { + if (!checkedSchedules.includes(versionId)) { + return acc; + } + const versionFriends = allFriends[versionId] as Record< + string, + FriendShareData + >; + + // if friend accepted, don't increment numNotAccepted + return Object.keys(versionFriends).some((f) => { + return ( + versionFriends[f]?.email === input.current?.value && + (versionFriends[f]?.status === 'Accepted' || + versionFriends[f]?.status === 'Pending') + ); + }) + ? acc + : acc + 1; + }, + 0 + ); + + if (numNotAccepted === 0) { + setValidMessage('User has already been invited to selected schedules.'); + setEmailIcon('send'); + return setValidClassName('invalid-email'); + } + + sendInvitation() + .then(() => { + if (input.current) { + input.current.value = ''; + } + setValidMessage('Invite successfully sent!'); + setValidClassName('valid-email'); + setEmailInput(''); + setEmailIcon('checkmark'); + }) + .catch((err) => { + setValidClassName('invalid-email'); + setEmailIcon('send'); + const error = err as AxiosError; + if (error.response) { + const apiError = error.response.data as ApiErrorResponse; + setValidMessage(apiError.message); + return; + } + setValidMessage('Error sending invitation. Please try again later.'); + softError( + new ErrorWithFields({ + message: 'send email invitation failed', + source: err, + fields: { + user: (accountContext as SignedIn).id, + friendEmail: input.current?.value, + term, + versionIds: checkedSchedules, + }, + }) + ); + }); + }, [accountContext, sendInvitation, allFriends, checkedSchedules, term]); + + const getInvitationLink = useCallback(async (): Promise< + AxiosResponse<{ link: string }> + > => { + const IdToken = await (accountContext as SignedIn).getToken(); + const data = JSON.stringify({ + IDToken: IdToken, + term, + versions: checkedSchedules, + redirectURL, + validFor: expirationChoices[selectedExpiration], + }); + return axios.post( + `${CLOUD_FUNCTION_BASE_URL}/createFriendInvitationLink`, + `data=${data}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + }, [ + accountContext, + term, + redirectURL, + checkedSchedules, + expirationChoices, + selectedExpiration, + ]); + + const createLink = useCallback(async (): Promise => { + setLinkLoading(true); + setLinkButtonClassName(''); + await getInvitationLink() + .then((response) => { + copy(response.data.link); + }) + .catch((err) => { + setLinkButtonClassName('link-failure'); + softError( + new ErrorWithFields({ + message: 'invite link creation failed', + source: err, + fields: { + user: (accountContext as SignedIn).id, + term, + versionIds: checkedSchedules, + validFor: selectedExpiration, + }, + }) + ); + throw err; + }); + }, [ + accountContext, + term, + getInvitationLink, + checkedSchedules, + selectedExpiration, + ]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + verifyEmail(); + break; + default: + return; + } + e.preventDefault(); + }, + [verifyEmail] + ); + + // delete invitation or remove schedules from already accepted invitation + const handleDelete = useCallback( + async (versionId: string, friendId: string): Promise => { + const data = JSON.stringify({ + IDToken: await (accountContext as SignedIn).getToken(), + peerUserId: friendId, + term, + versions: [versionId], + owner: true, + } as ScheduleDeletionRequest); + axios + .post( + `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, + `data=${data}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ) + .catch((err) => { + throw err; + }); + }, + [accountContext, term] + ); + + function showRemoveInvitation( + version: { id: string; name: string }, + friendId: string + ): void { + setRemoveInvitationOpen(true); + setToRemoveInfo({ version, friendId }); + } + + // delete friend from record of friends and close modal + const hideRemoveInvitation = useCallback( + (confirm: boolean) => { + setRemoveInvitationOpen(false); + if (confirm) { + handleDelete(toRemoveInfo.version.id, toRemoveInfo.friendId).catch( + (err) => { + softError( + new ErrorWithFields({ + message: 'delete friend record from sender failed', + source: err, + fields: { + user: (accountContext as SignedIn).id, + friend: toRemoveInfo.friendId, + term, + version: toRemoveInfo.version.id, + }, + }) + ); + } + ); + } + }, + [toRemoveInfo, handleDelete, accountContext, term] + ); + + // show a fake loader when options change + useDeepCompareEffect(() => { + setLinkButtonClassName(''); + setLinkLoading(true); + setTimeout(() => { + setLinkLoading(false); + }, 200); + }, [checkedSchedules, selectedExpiration]); + + return ( +
+
+

Share Schedule

+

+ Enter an email associated with another user's GT-Scheduler + account & we'll send them an invite via email to import this + schedule into their view +

+
+
+ +
+ +
+
{validMessage}
+
+ {allVersionNames.slice(0, 3).map((v) => ( + + ))} + {allVersionNames.length > 3 && ( +
+
+ setOtherSchedulesVisible(!otherSchedulesVisible) + } + > +

Other

+ +
+ {otherSchedulesVisible && ( +
setOtherSchedulesVisible(false)} + /> + )} +
+ {otherSchedulesVisible && + allVersionNames + .slice(3) + .map((v) => ( + + ))} +
+
+ )} +
+
+
+
+ {allVersionNames.map((v) => { + return ( +
+

+ Users Invited to View {v.name} +

+ {allFriends[v.id] && + Object.keys(allFriends[v.id] as Record) + .length !== 0 ? ( +
+ {Object.entries( + allFriends[v.id] as Record + ).map(([friendId, friend]) => ( +
+
+

{friend.email}

+ + + Status: {friend.status} + +
+
+ ))} +
+ ) : ( +
+ No friends have been invited +
+ )}{' '} +
+ ); + })} +
+
+
+
+ +
+
+ Link expires: +
{ + setExpirationDropdownVisible(!expirationDropdownVisible); + }} + > + {selectedExpiration} + +
+
+ {expirationDropdownVisible && ( +
setExpirationDropdownVisible(false)} + /> + )} +
+ {expirationDropdownVisible && + Object.keys(expirationChoices).map((exp) => ( + { + setSelectedExpiration(exp); + setExpirationDropdownVisible(false); + }} + > + {exp} + + ))} +
+
+
+
+ + )[toRemoveInfo.friendId]?.email ?? '' + } + /> +
+ ); +} + +export type RemoveInvitationModalContentProps = { + versionName: string; + currentInvitee: string; +}; + +export function RemoveInvitationModalContent({ + versionName, + currentInvitee, +}: RemoveInvitationModalContentProps): React.ReactElement { + return ( +
+
+

Remove Access

+

+ Are you sure you want to remove the following user from having access + schedule: {versionName}? +

+

+ User: {currentInvitee} +

+

+ This user will only gain access to this schedule if you send them + another invitation +

+
+
+ ); +} + +export type InvitationModalProps = { + show: boolean; + onHide: () => void; + inputEmail?: string; +}; + +/** + * Component that can be used to show the invitaion modal. + */ +export default function InvitationModal({ + show, + onHide, + inputEmail, +}: InvitationModalProps): React.ReactElement { + return ( + + + + + ); +} + +export type RemoveInvitationModalProps = { + showRemove: boolean; + onHideRemove: (confirm: boolean) => void; + versionName: string; + currentInvitee: string; +}; + +function RemoveInvitationModal({ + showRemove, + onHideRemove, + versionName, + currentInvitee, +}: RemoveInvitationModalProps): React.ReactElement { + return ( + onHideRemove(false)} + buttons={[ + { label: 'Remove', onClick: () => onHideRemove(true), cancel: true }, + ]} + width={550} + > + + + + ); +} + +export type ShareScheduleCheckboxProps = { + checkedSchedules: string[]; + setCheckedSchedules: React.Dispatch>; + version: { id: string; name: string }; + isOther: boolean; +}; + +function ShareScheduleCheckbox({ + checkedSchedules, + setCheckedSchedules, + version, + isOther, +}: ShareScheduleCheckboxProps): React.ReactElement { + return ( +
{ + const newChecked = checkedSchedules; + if (!newChecked.includes(version.id)) { + newChecked.push(version.id); + } else if (newChecked.length > 1) { + newChecked.splice(newChecked.indexOf(version.id), 1); + } + setCheckedSchedules([...newChecked]); + }} + > + +

{version.name}

+
+ ); +} diff --git a/src/components/InvitationModal/stylesheet.scss b/src/components/InvitationModal/stylesheet.scss new file mode 100644 index 00000000..4e9d01d5 --- /dev/null +++ b/src/components/InvitationModal/stylesheet.scss @@ -0,0 +1,542 @@ +@import '../../variables'; + +.invitation-modal { + .modal__content { + padding: 0px; + } + + .remove-close-button { + width: 16px; + height: 16px; + position: absolute; + right: 12px; + top: 12px; + padding: 15px; + color: $color-neutral; + border-radius: 50px; + } + + .invitation-modal-content { + text-align: center; + overflow: hidden; + padding: 20px 40px; + background-color: var(--theme-bg); + color: var(--theme-fg); + + //Style for email input + .top-block { + display: flex; + flex-direction: column; + justify-content: center; + padding: 10px 0px 5px 0px; + + .modal-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 0.9em; + } + + .valid-email { + text-align: start; + color: #22b531; + font-size: 12px; + font-weight: bold; + padding: 3px 0px; + opacity: 1; + } + + .invalid-email { + text-align: start; + color: #ff2121; + font-size: 12px; + font-weight: bold; + padding-top: 3px; + opacity: 1; + } + + .email-input-block { + display: flex; + flex-direction: row; + justify-content: space-between; + padding-top: 5px; + align-items: center; + column-gap: 5px; + + .email-input { + display: flex; + flex-direction: column; + position: relative; + flex-grow: 1; + + .email:has(+ .invalid-email) { + border: 1px solid #ff2121; + } + } + + input[type='email'] { + padding: 5px; + line-height: 12px; + padding: 12px; + border-radius: 6px; + background-color: rgb(35, 35, 35); + border: 1px solid rgb(35, 35, 35); + outline: none; + + &:focus { + border-color: rgba(255, 255, 255, 0.5); + } + + @include light { + background-color: rgba($color-neutral, 0.3); + border: 1px solid rgba($color-neutral, 0.3); + + &:focus { + border-color: rgba(0, 0, 0, 0.5); + } + } + } + + .send-button { + width: 50px; + align-self: stretch; + font-size: 14px; + color: white; + border-radius: 6px; + background-color: #c56e5b; + border: none; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: #e2944b; + cursor: pointer; + } + } + + .disabled-send-button { + background-color: $color-neutral; + + &:hover { + background-color: $color-neutral; + cursor: default; + } + } + + .email-button-spinner { + background-color: #d89758; + + &:hover { + cursor: default; + } + } + + .email-button-checkmark { + background-color: #22b531; + + &:hover { + background-color: #22b531; + cursor: default; + } + + .email-button-check-icon { + width: 20px; + height: 20px; + } + } + } + + .share-schedule-checkboxes { + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 15px 0px 15px 2px; + + .other-schedules-button { + display: flex; + flex-direction: row; + align-items: center; + + &:hover { + cursor: pointer; + } + } + + .other-schedules-text { + text-decoration: underline; + margin-right: 5px; + margin-bottom: 0px; + } + + .other-schedules-list { + @include popup; + + margin: 0; + padding: 2px 0px; + z-index: 2; + position: absolute; + max-width: 150px; + overflow-x: auto; + overflow-y: auto; + max-height: 300px; + @include dark { + background-color: $theme-dark-background; + } + + @include light { + background-color: $theme-light-background; + } + } + } + } + + .divider { + height: 1.5px; + display: flex; + align-items: stretch !important; + border: none; + background-color: var(--theme-card-bg); + margin: 0px -40px; + } + + //Style for Invited Users + + .invited-users { + padding-top: 15px; + justify-content: center; + overflow-y: scroll; + height: 215px; + } + + .no-invited-users { + font-size: 14px; + padding: 10px 0px 25px 0px; + } + + .shared-emails { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-content: flex-start; + row-gap: 8px; + padding-bottom: 25px; + color: var(--theme-fg); + + .individual-shared-email { + @include card; + background-color: var(--theme-card-bg); + padding: 4px 8px; + margin: 4px 8px 4px 0px; + height: 100%; + display: flex; + flex-direction: row; + justify-content: center; + border-radius: 16px; + width: max-content; + max-width: 100%; + min-width: min-content; + font-size: 0.9em; + cursor: pointer; + + @include light { + background-color: rgba($color-neutral, 0.5); + opacity: 0.75; + } + + .email-text { + line-height: 100%; + } + } + } + + .email-and-status { + display: flex; + flex-direction: column; + height: 26px; + } + + .Pending { + border-color: rgba(205, 165, 24, 0.5); + border-style: solid; + border-width: 1.5px; + } + + .Accepted { + border-color: rgba(34, 181, 49, 0.5); + border-style: solid; + border-width: 1.5px; + } + + .status-tooltip { + background-color: rgba(0, 0, 0, 1); + border-radius: 4px; + } + + .button-remove { + width: 10px; + height: 10px; + background-color: transparent; + position: relative; + align-self: center; + + .circle { + width: 12px; + height: 12px; + padding: 10px; + position: absolute; + position: absolute; + color: white; + + @include light { + color: $color-neutral; + } + } + + .remove { + width: 10px; + height: 10px; + padding: 15px; + color: var(--theme-card-bg); + position: relative; + display: inline-flex; + } + + &:hover { + .circle { + color: $color-neutral; + + @include light { + color: var(--theme-fg); + } + } + + background-color: transparent; + } + } + } + + .modal-footer { + display: flex; + flex-direction: column; + padding-top: 24px; + + .link-options { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .link-generated:hover { + cursor: pointer; + } + + .copy-link-button { + background: var(--theme-card-bg); + border-radius: 10px; + padding: 10px 15px; + border: none; + display: flex; + flex-direction: row; + align-items: center; + color: var(--theme-fg); + + .link-icon-container { + display: flex; + width: 20px; + margin-right: 5px; + } + + .copy-link-icon { + margin-right: 5px; + } + + &:hover { + @include dark { + background: $modal-foreground-color-light; + } + + @include light { + background: $modal-foreground-color-dark; + } + } + } + + .link-success { + color: #22b531; + } + + .link-failure { + color: #ff2121; + } + + .expiration { + position: relative; + + .expiration-display { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + .current-expiration { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--theme-card-bg); + margin-left: 5px; + border-radius: 30px; + padding: 4px 15px; + gap: 5px; + width: 100px; + + &:hover { + cursor: pointer; + + @include dark { + background: $modal-foreground-color-light; + } + + @include light { + background: $modal-foreground-color-dark; + } + } + } + } + + .expiration-select { + @include popup; + display: flex; + flex-direction: column; + width: 100px; + + margin: 0; + z-index: 2; + right: 0; + bottom: 100%; + position: absolute; + max-width: 150px; + overflow-x: auto; + background-color: var(--theme-bg); + + .expiration-option { + padding: 8px 15px; + + &:hover { + cursor: pointer; + background-color: var(--theme-card-bg); + } + } + } + } + } + } + + .mobile.invitation-modal-content { + .email-input-block { + flex-direction: column; + align-items: center; + .email-input { + max-width: 100%; + margin-bottom: 6px; + .email { + max-width: 100%; + } + } + } + } +} + +.intercept { + position: fixed; + z-index: 1; + top: 0; + bottom: 0; + left: 0; + right: 0; + cursor: default; +} + +.checkbox-and-label { + display: flex; + flex-direction: row; + align-items: center; + + &:hover { + cursor: pointer; + } + + .share-schedule-checkbox { + width: 12px; + height: 12px; + border: 2px solid; + border-radius: 2px; + border-color: var(--theme-fg); + margin-right: 8px; + padding: 1px; + color: var(--theme-bg); + } + + .schedule-checked { + background-color: var(--theme-fg); + } + + .checkbox-label { + font-size: 14px; + margin: 0px; + } +} + +.other-checkbox-and-label { + padding: 10px 15px; +} + +.modal-positioner { + .remove-invitation-modal { + overflow-y: hidden; + + .modal__content { + padding: 0px; + padding-top: 20px !important; + padding-bottom: 20px !important; + text-align: center; + + .remove-invitation-modal-content { + display: flex; + flex-direction: column; + align-items: center; + flex-wrap: wrap; + text-align: center; + padding-top: 15px; + padding-left: 50px; + padding-right: 50px; + height: 200px; + } + + .remove-close-button { + width: 16px; + height: 16px; + position: absolute; + right: 12px; + top: 12px; + padding: 15px; + color: $color-neutral; + border-radius: 50px; + } + } + + .modal__button--cancel { + background-color: #c56e5b !important; + + &:hover { + background-color: #e2944b !important; + } + } + } +} diff --git a/src/components/InviteBackLink/index.tsx b/src/components/InviteBackLink/index.tsx new file mode 100644 index 00000000..c5ab4af2 --- /dev/null +++ b/src/components/InviteBackLink/index.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import axios, { AxiosError, AxiosResponse } from 'axios'; + +import useFirebaseAuth from '../../data/hooks/useFirebaseAuth'; +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; +import { SignedIn } from '../../contexts'; +import Spinner from '../Spinner'; + +import './stylesheet.scss'; + +// eslint-disable-next-line no-shadow +enum LoadingState { + LOADING, + SUCCESS, + ERROR, +} + +type HandleInvitationResponse = { + email: string; + term: string; +}; + +interface ServerError extends AxiosError { + response: ServerErrorResponse; +} + +interface ServerErrorResponse extends AxiosResponse { + data: { + message: string; + }; +} + +const url = `${CLOUD_FUNCTION_BASE_URL}/handleFriendInvitation`; + +const handleInvite = async ( + inviteId: string | undefined, + token: string | void +): Promise => { + const data = JSON.stringify({ + inviteId, + token, + }); + const res = await axios.post(url, `data=${data}`, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + return res.data; +}; + +export default function InviteBackLink(): React.ReactElement { + const navigate = useNavigate(); + const location = useLocation(); + + const { id } = useParams(); + const [state, setState] = useState(LoadingState.LOADING); + + const redirectURL = useMemo( + () => + location.pathname.includes('/#') + ? location.pathname.split('/#')[0] ?? '/' + : '/', + [location] + ); + + const accountContext = useFirebaseAuth(); + + useEffect(() => { + const handleInviteAsync = async (): Promise< + HandleInvitationResponse | undefined + > => { + if (accountContext.type === 'loaded') { + const token = await (accountContext.result as SignedIn).getToken(); + return handleInvite(id, token); + } + return undefined; + }; + + const { type } = accountContext; + + if ( + type === 'loaded' && + accountContext.result.type === 'signedIn' && + redirectURL !== undefined + ) { + handleInviteAsync() + .then((resp) => { + setState(LoadingState.SUCCESS); + navigate( + `${redirectURL}?email=${resp?.email ?? ''}&term=${ + resp?.term ?? '' + }&status=success&inviteId=${id ?? ''}` + ); + }) + .catch((err: ServerError) => { + setState(LoadingState.ERROR); + navigate( + `${redirectURL}?email=none&status=${ + err.response?.data.message ?? '' + }&inviteId=${id ?? ''}` + ); + }); + } else if ( + type === 'loaded' && + accountContext.result.type !== 'signedIn' && + redirectURL !== undefined + ) { + navigate( + `${redirectURL}?email=none&status=not-logged-in&inviteId=${id ?? ''}` + ); + } + }, [id, navigate, redirectURL, accountContext.type]); // eslint-disable-line react-hooks/exhaustive-deps + + if (state === LoadingState.LOADING) { + return ( +
+ +

Loading

+
friend schedule invite
+
+ ); + } + + return ( +
+ {state === LoadingState.SUCCESS ? ( +

Congratulations on Adding a New Schedule to your View!

+ ) : ( +

We've Encountered an Error, Please Try Again

+ )} +

You are being redirected to our main site, please wait...

+

+ If you have not been redirected in 30 seconds, please click the button + below +

+ + + + Bits of Good Logo + +
+ ); +} diff --git a/src/components/InviteBackLink/stylesheet.scss b/src/components/InviteBackLink/stylesheet.scss new file mode 100644 index 00000000..22368c54 --- /dev/null +++ b/src/components/InviteBackLink/stylesheet.scss @@ -0,0 +1,79 @@ +@import '../../variables'; + +body { + background-color: $theme-dark-background; + color: $theme-dark-foreground; +} + +.Loading { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; + + h4 { + font-size: 1.4rem; + margin-top: 12px; + margin-bottom: 8px; + } +} + +.EmailInviteConfirmation { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 20vh; + padding-left: 32px; + padding-right: 32px; + font-size: 24px; + height: 100%; + text-align: center; + + @media (max-width: 720px) { + padding-top: 60px; + } + + @media (max-width: 450px) { + padding-top: 60px; + font-size: 18px; + } + + @media (max-width: 300px) { + font-size: 16px; + } + + @media (max-height: 600px) { + padding-top: 60px; + } + + h1 { + font-size: 36px; + font-weight: 600; + + @media (max-width: 450px) { + font-size: 30px; + } + + @media (max-width: 300px) { + font-size: 24px; + } + } + + .footer { + position: absolute; + bottom: 38px; + } + + .continue-button { + background-color: #fe7c53; + color: white; + margin-top: 96px; + padding: 8px 24px; + border: none; + border-radius: 16px; + cursor: pointer; + font-size: 24px; + font-weight: 600; + } +} diff --git a/src/components/LoginModal/index.tsx b/src/components/LoginModal/index.tsx index 773755b3..e6fdcd58 100644 --- a/src/components/LoginModal/index.tsx +++ b/src/components/LoginModal/index.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import firebaseui from 'firebaseui'; import FirebaseAuth from 'react-firebaseui/FirebaseAuth'; +import { classes } from '../../utils/misc'; import Modal from '../Modal'; import { firebase, authProviders } from '../../data/firebase'; @@ -17,11 +18,15 @@ const uiConfig: firebaseui.auth.Config = { }, }; +export type LoginModalContentProps = { comparison: boolean }; + /** * Inner content of the login modal. * This utilizes Firebase UI to handle the authentication UI components. */ -export function LoginModalContent(): React.ReactElement { +export function LoginModalContent({ + comparison, +}: LoginModalContentProps): React.ReactElement { // Calculate the min height of the FirebaseUI element // so that it does not cause a large layout shift when initially loading. // The height is determined based on the number of auth providers, @@ -34,11 +39,20 @@ export function LoginModalContent(): React.ReactElement { return (
-

Sign in

-

- Sign in using one of the below identity providers to start syncing your - schedules across devices. -

+ {comparison ? ( +

+ You must sign in to use the Compare + Schedule Feature! +

+ ) : ( +

Sign in

+ )} +
+

+ Sign in using one of the below identity providers to start syncing + your schedules across devices. +

+
void; + comparison?: boolean; }; /** @@ -62,6 +77,7 @@ export type LoginModalProps = { export default function LoginModal({ show, onHide, + comparison = false, }: LoginModalProps): React.ReactElement { // If the modal is open, // attach a listener for the authentication state @@ -87,7 +103,7 @@ export default function LoginModal({ { label: 'Cancel', onClick: (): void => onHide(), cancel: true }, ]} > - + ); } diff --git a/src/components/LoginModal/stylesheet.scss b/src/components/LoginModal/stylesheet.scss index 741366c8..b18a08d9 100644 --- a/src/components/LoginModal/stylesheet.scss +++ b/src/components/LoginModal/stylesheet.scss @@ -62,4 +62,30 @@ text-align: center; margin-bottom: 28px; } + + .underline { + text-decoration: underline; + font-weight: 700; + color: #FFFFFF; + } + + .compare-text { + text-align: center; + font-size: 25px; + font-style: normal; + font-weight: 600; + line-height: normal; + padding: 10px 1px 0px 1px; + color: #FFFFFF; + } + + .compare-subtext { + width: 330px; + margin: 0 auto; + padding-bottom: 15px; + font-size: 15px; + font-weight: 400; + color: #FFFFFF; + + } } diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index b5c4b27f..43f589c2 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -21,6 +21,7 @@ export interface ModalButtonProps { export type ModalProps = { children?: React.ReactNode; + buttonPrompt?: string; buttons?: ModalButtonProps[]; show: boolean; onHide: () => void; @@ -58,6 +59,7 @@ type TransitionProps = { */ export default function Modal({ children, + buttonPrompt, buttons = [], show, onHide, @@ -110,6 +112,7 @@ export default function Modal({ checkboxContent && 'has-checkbox' )} > + {buttonPrompt &&
{buttonPrompt}
} {checkboxContent && (
{checkboxContent}

)} - {buttons.map((props, i) => ( - - ))} +
+ {buttons.map((props, i) => ( + + ))} +
)}
diff --git a/src/components/Modal/stylesheet.scss b/src/components/Modal/stylesheet.scss index 7e18e520..4363a2e2 100644 --- a/src/components/Modal/stylesheet.scss +++ b/src/components/Modal/stylesheet.scss @@ -187,6 +187,7 @@ text-align: center; display: flex; + flex-direction: column; align-items: center; justify-content: center; gap: 16px; @@ -232,6 +233,11 @@ font-size: .95em; } } + + .button_container { + display: flex; + column-gap: 20px; + } } &__button { diff --git a/src/components/RouterComponent/index.tsx b/src/components/RouterComponent/index.tsx new file mode 100644 index 00000000..ca55ed14 --- /dev/null +++ b/src/components/RouterComponent/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Routes, Route, Navigate, HashRouter } from 'react-router-dom'; + +import App from '../App'; +import InviteBackLink from '../InviteBackLink'; + +export default function RouterComponent(): React.ReactElement { + return ( + + + } /> + } /> + + } /> + } /> + + } /> + + + ); +} diff --git a/src/components/Scheduler/index.tsx b/src/components/Scheduler/index.tsx index 86012251..cc20c0bf 100644 --- a/src/components/Scheduler/index.tsx +++ b/src/components/Scheduler/index.tsx @@ -1,9 +1,20 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; import { classes } from '../../utils/misc'; -import { Button, Calendar, CombinationContainer, CourseContainer } from '..'; -import { OverlayCrnsContext, OverlayCrnsContextValue } from '../../contexts'; +import { + Button, + Calendar, + CombinationContainer, + ComparisonPanel, + CourseContainer, +} from '..'; +import { + OverlayCrnsContext, + OverlayCrnsContextValue, + ScheduleContext, +} from '../../contexts'; import { DESKTOP_BREAKPOINT } from '../../constants'; +import useCompareStateFromStorage from '../../data/hooks/useCompareStateFromStorage'; import useScreenWidth from '../../hooks/useScreenWidth'; /** @@ -24,6 +35,28 @@ export default function Scheduler(): React.ReactElement { [overlayCrns, setOverlayCrns] ); + const [{ currentVersion }] = useContext(ScheduleContext); + + const { compare, pinned, pinSelf, expanded, setCompareState } = + useCompareStateFromStorage({ pinDefault: [currentVersion] }); + const [overlaySchedules, setOverlaySchedules] = useState([]); + + const handleCompareSchedules = useCallback( + ( + newCompare?: boolean, + newPinnedSchedules?: string[], + newPinSelf?: boolean, + newExpanded?: boolean, + newOverlaySchedules?: string[] + ) => { + setCompareState(newCompare, newPinnedSchedules, newPinSelf, newExpanded); + if (newOverlaySchedules !== undefined) { + setOverlaySchedules(newOverlaySchedules); + } + }, + [setCompareState, setOverlaySchedules] + ); + return ( <> {mobile && ( @@ -42,12 +75,27 @@ export default function Scheduler(): React.ReactElement {
{(!mobile || tabIndex === 0) && } - {(!mobile || tabIndex === 1) && } + {mobile && tabIndex === 1 && } {(!mobile || tabIndex === 2) && (
- +
)} + {(!mobile || tabIndex === 3) && ( + + )}
diff --git a/src/components/SectionBlocks/index.tsx b/src/components/SectionBlocks/index.tsx index 64ba040d..f4ee956a 100644 --- a/src/components/SectionBlocks/index.tsx +++ b/src/components/SectionBlocks/index.tsx @@ -18,6 +18,7 @@ export interface SectionBlockPosition extends TimeBlockPosition { export type SectionBlocksProps = { className?: string; crn: string; + schedule?: string; overlay?: boolean; capture: boolean; includeDetailsPopover: boolean; @@ -34,6 +35,7 @@ export type SectionBlocksProps = { export default function SectionBlocks({ className, crn, + schedule, overlay = false, capture, sizeInfo, @@ -57,9 +59,9 @@ export default function SectionBlocks({ return ( day !== 'S' && day !== 'U')} @@ -122,6 +124,7 @@ export default function SectionBlocks({ deviceHasHover={deviceHasHover} selectedMeeting={selectedMeeting} onSelectMeeting={onSelectMeeting} + schedule={schedule} /> ); })} diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index 3c16eebb..2fe18240 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -442,19 +442,21 @@ type AutoFocusInputProps = { onChange?: (e: React.ChangeEvent) => void; placeholder?: string; onKeyDown?: (e: React.KeyboardEvent) => void; + onBlur?: (e: React.FocusEvent) => void; }; /** * Simple wrapper around `` * that automatically focuses its contents when it is first mounted */ -function AutoFocusInput({ +export function AutoFocusInput({ className, style, value, onChange, placeholder, onKeyDown, + onBlur, }: AutoFocusInputProps): React.ReactElement { const inputRef = useRef(null); useEffect(() => { @@ -473,6 +475,7 @@ function AutoFocusInput({ onKeyDown={onKeyDown} ref={inputRef} type="text" + onBlur={onBlur} /> ); } diff --git a/src/components/ShareIcon/index.tsx b/src/components/ShareIcon/index.tsx new file mode 100644 index 00000000..04199712 --- /dev/null +++ b/src/components/ShareIcon/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { classes } from '../../utils/misc'; + +import './stylesheet.scss'; + +export interface ShareIconProps { + className: string; +} + +export default function ShareIcon({ className }: ShareIconProps): JSX.Element { + return ( + + + + ); +} diff --git a/src/components/ShareIcon/stylesheet.scss b/src/components/ShareIcon/stylesheet.scss new file mode 100644 index 00000000..1c78ca48 --- /dev/null +++ b/src/components/ShareIcon/stylesheet.scss @@ -0,0 +1,14 @@ +@import "../../variables"; + +.share-icon { + transition-duration: $theme-switch-transition-duration; + transition-property: fill; + + @include light { + fill: $theme-light-foreground; + } + + @include dark { + fill: $theme-dark-foreground; + } +} \ No newline at end of file diff --git a/src/components/SuccesfulInvitationImage/index.tsx b/src/components/SuccesfulInvitationImage/index.tsx new file mode 100644 index 00000000..c4994b53 --- /dev/null +++ b/src/components/SuccesfulInvitationImage/index.tsx @@ -0,0 +1,144 @@ +import React from 'react'; + +type Props = { + className?: string; +}; + +export default function SuccesfulInvitationImage({ + className, +}: Props): React.ReactElement { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/TimeBlocks/index.tsx b/src/components/TimeBlocks/index.tsx index bebc2571..df3509ed 100644 --- a/src/components/TimeBlocks/index.tsx +++ b/src/components/TimeBlocks/index.tsx @@ -43,6 +43,7 @@ export type TimeBlocksProps = { includeContent: boolean; sizeInfo: SizeInfo; canBeTabFocused?: boolean; + schedule?: string; /** * Passing through this prop to skip subscribing to a media query per * TimeBlocks component instance: @@ -86,10 +87,11 @@ export default function TimeBlocks({ deviceHasHover = true, selectedMeeting, onSelectMeeting, + schedule, handleMouseDown, }: TimeBlocksProps): React.ReactElement | null { const [{ colorMap }] = useContext(ScheduleContext); - const color = colorMap[id]; + const color = colorMap[schedule ?? id]; const sizeInfoKey = makeSizeInfoKey(period); return ( @@ -252,17 +254,22 @@ function MeetingDayBlock({ {includeContent && (
- {contentHeader.map((content) => { + {contentHeader.map((content, i) => { return ( - + {content.content}  ); })}
- {contentBody.map((content) => { + {contentBody.map((content, i) => { return ( - {content.content} + + {content.content} + ); })}
@@ -316,9 +323,9 @@ function DetailsPopoverContent({ return ( - {popover.map((popoverInfo) => { + {popover.map((popoverInfo, i) => { return popoverInfo.content ? ( - + diff --git a/src/components/TimeBlocks/stylesheet.scss b/src/components/TimeBlocks/stylesheet.scss index 3b015d23..a89c2a4d 100644 --- a/src/components/TimeBlocks/stylesheet.scss +++ b/src/components/TimeBlocks/stylesheet.scss @@ -39,13 +39,15 @@ border: none; text-align: left; padding: 0; + border-top: 1px solid $color-border; + border-left: 1px solid $color-border; &:focus { // Remove the default focus style, since selecting the meeting // adds its own focus style: outline: none; } - + // Add a box-shadow border to the meeting block when it is highlighted &::after { content: ''; @@ -60,7 +62,7 @@ border-radius: 2px; transition: opacity 0.2s ease-in-out; } - + &--selected { &::after { opacity: 0.5; @@ -100,7 +102,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: .72em; + font-size: 0.72em; } .ids { @@ -109,7 +111,7 @@ overflow: hidden; span { - font-size: .8em; + font-size: 0.8em; } } } diff --git a/src/components/index.ts b/src/components/index.ts index 47d8b7ab..d3eac0c4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,6 +24,13 @@ export { default as Tab } from './Tab'; export { default as TimeBlocks } from './TimeBlocks'; export { default as SectionBlocks } from './SectionBlocks'; export { default as EventBlocks } from './EventBlocks'; +export { default as CompareBlocks } from './CompareBlocks'; export { default as Attribution } from './Attribution'; export { default as Event } from './Event'; export { default as CourseNavMenu } from './CourseNavMenu'; +export { default as ComparisonContainer } from './ComparisonContainer'; +export { default as ComparisonPanel } from './ComparisonPanel'; +export { default as InviteBackLink } from './InviteBackLink'; +export { default as RouterComponent } from './RouterComponent'; +export { default as InvitationModal } from './InvitationModal'; +export { default as ShareIcon } from './ShareIcon'; diff --git a/src/contexts/account.ts b/src/contexts/account.ts index a98beed7..5bccfd99 100644 --- a/src/contexts/account.ts +++ b/src/contexts/account.ts @@ -7,6 +7,7 @@ export type SignedOut = { export type SignedIn = { type: 'signedIn'; signOut: () => void; + getToken: () => Promise; name: string | null; provider: string | null; email: string | null; diff --git a/src/contexts/friend.ts b/src/contexts/friend.ts new file mode 100644 index 00000000..389dbb9b --- /dev/null +++ b/src/contexts/friend.ts @@ -0,0 +1,52 @@ +import { Immutable, Draft } from 'immer'; +import React from 'react'; + +import { + defaultFriendScheduleData, + FriendInfo, + FriendScheduleData, + FriendTermData, +} from '../data/types'; +import { ErrorWithFields } from '../log'; + +export type FriendContextData = Immutable<{ friends: FriendScheduleData }>; +export type FriendContextSetters = { + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + renameFriend: (id: string, newName: string) => void; +}; + +export type FriendContextValue = [FriendContextData, FriendContextSetters]; + +export const FriendContext = React.createContext([ + { + friends: defaultFriendScheduleData, + }, + { + updateFriendTermData: (): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.updateFriendTermData value being used', + }); + }, + updateFriendInfo: (): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.updateFriendInfo value being used', + }); + }, + renameFriend: (id: string, newName: string): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.renameFriend value being used', + fields: { + id, + newName, + }, + }); + }, + }, +]); diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 942ff79f..c9e95a9d 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -1,4 +1,6 @@ +export * from './friend'; export * from './theme'; export * from './terms'; export * from './schedule'; export * from './overlayCrns'; +export * from './account'; diff --git a/src/contexts/schedule.ts b/src/contexts/schedule.ts index 12b0a8d5..40a69827 100644 --- a/src/contexts/schedule.ts +++ b/src/contexts/schedule.ts @@ -3,19 +3,28 @@ import { Draft, Immutable } from 'immer'; import { Oscar } from '../data/beans'; import { EMPTY_OSCAR } from '../data/beans/Oscar'; -import { defaultSchedule, Schedule } from '../data/types'; +import { + defaultSchedule, + FriendShareData, + Schedule, + TermScheduleData, +} from '../data/types'; import { ErrorWithFields } from '../log'; type ExtraData = { term: string; currentVersion: string; + currentFriends: Record; + allFriends: Record>; allVersionNames: { id: string; name: string }[]; // `oscar` is included below as a separate type }; export type ScheduleContextData = Immutable & // `Oscar` can't go into `Immutable`, so we place it separately - Immutable & { readonly oscar: Oscar }; + Immutable & { + readonly oscar: Oscar; + } & Immutable; export type ScheduleContextSetters = { setTerm: (next: string) => void; @@ -28,6 +37,7 @@ export type ScheduleContextSetters = { deleteVersion: (id: string) => void; renameVersion: (id: string, newName: string) => void; cloneVersion: (id: string, newName: string) => void; + deleteFriendRecord: (versionId: string, friendId: string) => void; }; export type ScheduleContextValue = [ ScheduleContextData, @@ -37,9 +47,12 @@ export const ScheduleContext = React.createContext([ { term: '', currentVersion: '', + currentFriends: {}, allVersionNames: [], + allFriends: {}, oscar: EMPTY_OSCAR, ...defaultSchedule, + versions: {}, }, { setTerm: (next: string): void => { @@ -71,6 +84,15 @@ export const ScheduleContext = React.createContext([ }, }); }, + deleteFriendRecord: (versionId: string, friendId: string): void => { + throw new ErrorWithFields({ + message: 'empty ScheduleContext.deleteFriendRecord value being used', + fields: { + versionId, + friendId, + }, + }); + }, addNewVersion: (name: string, select?: boolean): string => { throw new ErrorWithFields({ message: 'empty ScheduleContext.addNewVersion value being used', diff --git a/src/data/beans/Oscar.ts b/src/data/beans/Oscar.ts index b9cc3776..c6e62500 100644 --- a/src/data/beans/Oscar.ts +++ b/src/data/beans/Oscar.ts @@ -175,12 +175,35 @@ export default class Oscar { }); this.sortingOptions = [ - new SortingOption('Most Compact', (combination) => { + new SortingOption('Most Compact', (combination, events) => { const { startMap, endMap } = combination; + + const eventStartMap = new Map(); + const eventEndMap = new Map(); + events.forEach((event) => { + const { start, end } = event.period; + for (const day of event.days) { + if (!eventStartMap.has(day)) { + eventStartMap.set(day, start); + } + eventStartMap.set( + day, + Math.min(start, eventStartMap.get(day) ?? Infinity) + ); + + if (!eventEndMap.has(day)) { + eventEndMap.set(day, end); + } + eventEndMap.set(day, Math.max(end, eventEndMap.get(day) ?? -1)); + } + }); const diffs = Object.keys(startMap).map((day) => { - const end = endMap[day]; - const start = startMap[day]; + let end = endMap[day]; + let start = startMap[day]; if (end == null || start == null) return 0; + end = Math.max(end, eventEndMap.get(day) ?? -1); + start = Math.min(start, eventStartMap.get(day) ?? Infinity); + return end - start; }); const sum = diffs.reduce((tot, min) => tot + min, 0); @@ -314,7 +337,8 @@ export default class Oscar { sortCombinations( combinations: Combination[], - sortingOptionIndex: number + sortingOptionIndex: number, + events: Immutable ): Combination[] { const sortingOption = this.sortingOptions[sortingOptionIndex]; if (sortingOption === undefined) { @@ -331,7 +355,7 @@ export default class Oscar { return combinations .map((combination) => ({ ...combination, - factor: sortingOption.calculateFactor(combination), + factor: sortingOption.calculateFactor(combination, events), })) .sort((a, b) => a.factor - b.factor); } diff --git a/src/data/beans/SortingOption.ts b/src/data/beans/SortingOption.ts index 1ca19371..b61c6b7a 100644 --- a/src/data/beans/SortingOption.ts +++ b/src/data/beans/SortingOption.ts @@ -1,11 +1,16 @@ -import { Combination } from '../../types'; +import { Immutable } from 'immer'; + +import { Combination, Event } from '../../types'; export default class SortingOption { label: string; - calculateFactor: (combo: Combination) => number; + calculateFactor: (combo: Combination, events: Immutable) => number; - constructor(label: string, calculateFactor: (combo: Combination) => number) { + constructor( + label: string, + calculateFactor: (combo: Combination, events: Immutable) => number + ) { this.label = label; this.calculateFactor = calculateFactor; } diff --git a/src/data/firebase.ts b/src/data/firebase.ts index 18fb21ab..f4d3a2ef 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -3,7 +3,7 @@ import 'firebase/auth'; import 'firebase/firestore'; import { ErrorWithFields, softError } from '../log'; -import { AnyScheduleData } from './types'; +import { AnyScheduleData, FriendData } from './types'; // This data is not secret; it is included in the application bundle. // Supply these environment variables when developing locally. @@ -18,6 +18,7 @@ export const firebaseConfig = { }; const SCHEDULE_COLLECTION = 'schedules'; +const FRIEND_COLLECTION = 'friends'; /** * Whether Firebase authentication is enabled in this environment. @@ -32,8 +33,12 @@ let db: firebase.firestore.Firestore = null as unknown as firebase.firestore.Firestore; type SchedulesCollection = firebase.firestore.CollectionReference; +type FriendsCollection = firebase.firestore.CollectionReference; let schedulesCollection: SchedulesCollection = null as unknown as SchedulesCollection; + +let friendsCollection: FriendsCollection = null as unknown as FriendsCollection; + /* eslint-enable import/no-mutable-exports */ if (isAuthEnabled) { const app = firebase.initializeApp(firebaseConfig); @@ -44,6 +49,8 @@ if (isAuthEnabled) { SCHEDULE_COLLECTION ) as SchedulesCollection; + friendsCollection = db.collection(FRIEND_COLLECTION) as FriendsCollection; + auth.setPersistence(firebase.auth.Auth.Persistence.LOCAL).catch((err) => { softError( new ErrorWithFields({ @@ -54,7 +61,7 @@ if (isAuthEnabled) { }); } -export { auth, db, schedulesCollection }; +export { auth, db, schedulesCollection, friendsCollection }; export { firebase }; // Configure the enabled auth providers that firebase UI displays as options diff --git a/src/data/hooks/useCompareStateFromStorage.ts b/src/data/hooks/useCompareStateFromStorage.ts new file mode 100644 index 00000000..9dc6b6b1 --- /dev/null +++ b/src/data/hooks/useCompareStateFromStorage.ts @@ -0,0 +1,99 @@ +import { useCallback } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; + +type HookResult = { + compare: boolean; + pinned: string[]; + pinSelf: boolean; + expanded: boolean; + setCompareState: ( + newCompare: boolean | undefined, + newPinned: string[] | undefined, + newPinSelf: boolean | undefined, + newExpanded: boolean | undefined + ) => void; +}; + +type Props = { + compareDefault?: boolean; + pinDefault?: string[]; + pinSelfDefault?: boolean; + expandedDefault?: boolean; +}; + +/** + * Gets the current UI state from local storage. + * Do not call this function in a non-root component; + * it should only be called once in a root component (i.e. ). + * Moreover, unlike the local storage version of the app data, + * this **does not** sync between tabs. + * This is deliberate, as it allows opening up multiple tabs + * with different schedules if desired, + * but still have the app resume to the last viewed schedule when opened again. + */ +export default function useCompareStateFromStorage({ + compareDefault, + pinDefault, + pinSelfDefault, + expandedDefault, +}: Props): HookResult { + const [compare, setCompare] = useLocalStorageState( + 'compare-panel-state-compareValue', + { + defaultValue: compareDefault ?? false, + storageSync: false, + } + ); + const [pinned, setPinned] = useLocalStorageState( + 'compare-panel-state-pinnedSchedules', + { + defaultValue: pinDefault ?? [], + storageSync: false, + } + ); + const [pinSelf, setPinSelf] = useLocalStorageState( + 'compare-panel-state-pinSelfValue', + { + defaultValue: pinSelfDefault ?? true, + storageSync: false, + } + ); + const [expanded, setExpanded] = useLocalStorageState( + 'compare-panel-state-expandedValue', + { + defaultValue: expandedDefault ?? true, + storageSync: false, + } + ); + + const setCompareState = useCallback( + ( + newCompare?: boolean, + newPinnedSchedules?: string[], + newPinSelf?: boolean, + newExpanded?: boolean + ) => { + if (newCompare !== undefined) { + setCompare(newCompare); + } + if (newPinnedSchedules !== undefined) { + setPinned(newPinnedSchedules); + } + if (newPinSelf !== undefined) { + setPinSelf(newPinSelf); + } + if (newExpanded !== undefined) { + setExpanded(newExpanded); + } + }, + [setCompare, setPinned, setPinSelf, setExpanded] + ); + + return { + compare, + pinned, + pinSelf, + expanded, + setCompareState, + }; +} diff --git a/src/data/hooks/useEnsureValidTerm.ts b/src/data/hooks/useEnsureValidTerm.ts index 74e0502f..54196b65 100644 --- a/src/data/hooks/useEnsureValidTerm.ts +++ b/src/data/hooks/useEnsureValidTerm.ts @@ -22,7 +22,9 @@ export default function useEnsureValidTerm({ }): LoadingState { // Set the term to be the first one if it is unset or no longer valid. useEffect(() => { - const mostRecentTerm = terms[0].term; + const mostRecentTerm = + terms.find((term) => term.finalized === true)?.term ?? terms[0].term; + const correctedTerm = !isValidTerm(currentTermRaw, terms) ? mostRecentTerm : currentTermRaw; diff --git a/src/data/hooks/useExtractFriendInfo.ts b/src/data/hooks/useExtractFriendInfo.ts new file mode 100644 index 00000000..b07f2ee5 --- /dev/null +++ b/src/data/hooks/useExtractFriendInfo.ts @@ -0,0 +1,138 @@ +import produce, { Immutable, Draft, castDraft, castImmutable } from 'immer'; +import { useEffect, useCallback, useMemo } from 'react'; + +import { LoadingState } from '../../types'; +import { + FriendData, + defaultFriendInfo, + FriendInfo, + RawFriendScheduleData, + FriendScheduleData, +} from '../types'; +import { ErrorWithFields, softError } from '../../log'; + +/** + * Gets the current term friend info based on the current term. + * If the term friend info for the current term doesn't exist, + * then this hook also initializes it to an empty value. + */ +export default function useExtractFriendInfo({ + rawFriendScheduleData, + friendInfo, + updateFriendData, +}: { + rawFriendScheduleData: RawFriendScheduleData; + friendInfo: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}): LoadingState<{ + friendScheduleData: Immutable; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}> { + // Ensure that there is a valid term friend info object for the term + useEffect(() => { + if (friendInfo === undefined) { + updateFriendData((draft) => { + draft.info = castDraft({}); + }); + return; + } + + for (const friendId of Object.keys(rawFriendScheduleData)) { + const currentFriendInfo = friendInfo[friendId]; + const correctedFriendInfo = + currentFriendInfo === undefined || + currentFriendInfo.name === undefined || + currentFriendInfo.email === undefined + ? defaultFriendInfo + : currentFriendInfo; + if (correctedFriendInfo !== currentFriendInfo) { + updateFriendData((draft) => { + draft.info[friendId] = castDraft(correctedFriendInfo); + }); + return; + } + } + }, [rawFriendScheduleData, friendInfo, updateFriendData]); + + // Create a nested update callback for just the friend info. + const updateFriendInfo = useCallback( + ( + applyDraft: (draft: Draft) => void | Immutable + ): void => { + updateFriendData((draft) => { + const currentFriendInfoDraft = draft.info ?? null; + if (currentFriendInfoDraft === null) { + softError( + new ErrorWithFields({ + message: + 'updateFriendInfo called with invalid info field; ignoring', + fields: { + currentFriendInfo: null, + }, + }) + ); + return; + } + + draft.info = produce(currentFriendInfoDraft, (subDraft) => + castDraft(applyDraft(subDraft)) + ); + }); + }, + [updateFriendData] + ); + + const friendScheduleData = + useMemo | null>(() => { + const temp: FriendScheduleData = {}; + if (friendInfo === undefined) return null; + + for (const friendId of Object.keys(rawFriendScheduleData)) { + const currentFriendInfo = friendInfo[friendId]; + if ( + currentFriendInfo === undefined || + currentFriendInfo.email === undefined || + currentFriendInfo.name === undefined + ) + return null; + + const rawFriendScheduleDatum = rawFriendScheduleData[friendId]; + if ( + rawFriendScheduleDatum === undefined || + rawFriendScheduleDatum.versions === undefined + ) { + softError( + new ErrorWithFields({ + message: 'an error occurred when accessing friend schedule data', + fields: { + friendId, + }, + }) + ); + } else { + temp[friendId] = { + ...currentFriendInfo, + ...rawFriendScheduleDatum, + }; + } + } + + return castImmutable(temp); + }, [friendInfo, rawFriendScheduleData]); + + if (friendScheduleData === null) { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + friendScheduleData, + updateFriendInfo, + }, + }; +} diff --git a/src/data/hooks/useExtractFriendTermData.ts b/src/data/hooks/useExtractFriendTermData.ts new file mode 100644 index 00000000..087a46f0 --- /dev/null +++ b/src/data/hooks/useExtractFriendTermData.ts @@ -0,0 +1,110 @@ +import produce, { Immutable, Draft, castDraft } from 'immer'; +import { useEffect, useCallback } from 'react'; + +import { LoadingState } from '../../types'; +import { + FriendData, + defaultFriendTermData, + FriendTermData, + FriendIds, +} from '../types'; +import { ErrorWithFields, softError } from '../../log'; + +/** + * Gets the current term friend data based on the current term. + * If the term friend data for the current term doesn't exist, + * then this hook also initializes it to an empty value. + */ +export default function useExtractFriendTermData({ + currentTerm, + rawFriendData, + updateFriendData, +}: { + currentTerm: string; + rawFriendData: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}): LoadingState<{ + termFriendData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; +}> { + // Ensure that there is a valid term friend data object for the term + useEffect(() => { + if (rawFriendData.terms === undefined) { + return updateFriendData((draft) => { + draft.terms = { [currentTerm]: castDraft(defaultFriendTermData) }; + }); + } + const currentFriendTermData = rawFriendData.terms[currentTerm]; + const correctedFriendTermData = + currentFriendTermData === undefined || + currentFriendTermData.accessibleSchedules === undefined + ? defaultFriendTermData + : currentFriendTermData; + + if (correctedFriendTermData !== currentFriendTermData) { + updateFriendData((draft) => { + draft.terms[currentTerm] = castDraft(correctedFriendTermData); + }); + } + }, [currentTerm, rawFriendData.terms, updateFriendData]); + + // Create a nested update callback for just the friend term data. + const updateFriendTermData = useCallback( + ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ): void => { + updateFriendData((draft) => { + const currentFriendTermDataDraft = draft.terms[currentTerm] ?? null; + if ( + currentFriendTermDataDraft === null || + currentFriendTermDataDraft.accessibleSchedules === undefined + ) { + softError( + new ErrorWithFields({ + message: + 'updateFriendTermData called with invalid current term; ignoring', + fields: { + currentTerm, + currentFriendTermData: null, + }, + }) + ); + return; + } + + draft.terms[currentTerm] = produce( + currentFriendTermDataDraft, + (subDraft) => castDraft(applyDraft(subDraft)) + ); + }); + }, + [updateFriendData, currentTerm] + ); + + const currentFriendTermData = rawFriendData.terms + ? rawFriendData.terms[currentTerm] + : undefined; + + if ( + currentFriendTermData === undefined || + currentFriendTermData.accessibleSchedules === undefined + ) { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + termFriendData: currentFriendTermData.accessibleSchedules, + updateFriendTermData, + }, + }; +} diff --git a/src/data/hooks/useExtractScheduleVersion.ts b/src/data/hooks/useExtractScheduleVersion.ts index 434337c9..e34a497a 100644 --- a/src/data/hooks/useExtractScheduleVersion.ts +++ b/src/data/hooks/useExtractScheduleVersion.ts @@ -62,6 +62,7 @@ export default function useExtractScheduleVersion({ const id = generateScheduleVersionId(); draft.versions[id] = { name: 'Primary', + friends: {}, createdAt: new Date().toISOString(), schedule: castDraft(defaultSchedule), }; diff --git a/src/data/hooks/useExtractTermScheduleData.ts b/src/data/hooks/useExtractTermScheduleData.ts index 13d492db..0864798e 100644 --- a/src/data/hooks/useExtractTermScheduleData.ts +++ b/src/data/hooks/useExtractTermScheduleData.ts @@ -73,7 +73,7 @@ export default function useExtractTermScheduleData({ 'updateTermScheduleData called on term that does not exist', fields: { currentTerm, - currentTermScheduleData, + currentTermScheduleData: currentTermScheduleDataDraft, allTermsInData: Object.keys(draft.terms), }, }) @@ -87,7 +87,7 @@ export default function useExtractTermScheduleData({ ); }); }, - [currentTerm, currentTermScheduleData, updateScheduleData] + [currentTerm, updateScheduleData] ); if (currentTermScheduleData === undefined) { diff --git a/src/data/hooks/useFirebaseAuth.ts b/src/data/hooks/useFirebaseAuth.ts index 054e0022..3053b166 100644 --- a/src/data/hooks/useFirebaseAuth.ts +++ b/src/data/hooks/useFirebaseAuth.ts @@ -32,11 +32,26 @@ export default function useFirebaseAuth(): LoadingState { name: user.displayName, email: user.email, id: user.uid, + getToken: (): Promise => { + const { currentUser } = firebase.auth(); + if (!currentUser) { + return Promise.reject( + new ErrorWithFields({ + message: 'firebase.auth().currentUser is null', + }) + ); + } + return currentUser.getIdToken(); + }, provider, signOut: () => { firebase .auth() .signOut() + .then(() => { + // don't want to share localStorage between accounts + localStorage.clear(); + }) .catch((err) => { softError( new ErrorWithFields({ diff --git a/src/data/hooks/useFriendDataProducer.ts b/src/data/hooks/useFriendDataProducer.ts new file mode 100644 index 00000000..0cf255bf --- /dev/null +++ b/src/data/hooks/useFriendDataProducer.ts @@ -0,0 +1,41 @@ +import produce, { Draft, Immutable } from 'immer'; +import { useCallback } from 'react'; + +import { FriendData } from '../types'; + +type HookResult = { + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}; + +/** + * Constructs the Immer producer + * from the raw schedule data state setter. + * Returns a referentially stable callback function + * that can be used to update the schedule data using an immer draft: + * https://immerjs.github.io/immer/produce/ + */ +export default function useFriendDataProducer({ + setFriendData, +}: { + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; +}): HookResult { + const updateFriendData = useCallback( + (applyDraft: (draft: Draft) => void): void => + // Here, we use the callback API for the setter function + // returned by `useState` so that we don't have to re-generate + // the callback when the state changes + setFriendData((current: FriendData | null) => { + // Use `produce` from Immer to combine the current state + // & caller-supplied callback that modifies the current state + // to produce the next state + return produce(current, applyDraft); + }), + [setFriendData] + ); + + return { updateFriendData }; +} diff --git a/src/data/hooks/useMigrateScheduleData.test.ts b/src/data/hooks/useMigrateScheduleData.test.ts index 7cb66cf4..8362ede3 100644 --- a/src/data/hooks/useMigrateScheduleData.test.ts +++ b/src/data/hooks/useMigrateScheduleData.test.ts @@ -83,6 +83,7 @@ describe('useMigrateScheduleData', () => { name: 'Primary', // January 1, 1970 at 0 seconds createdAt: '1970-01-01T00:00:00.000Z', + friends: {}, schedule: { desiredCourses: ['CS 1100', 'CS 1331'], pinnedCrns: [ @@ -140,6 +141,7 @@ describe('useMigrateScheduleData', () => { sv_48RC7kqO7YDiBK66qXOd: { name: 'Primary', createdAt: '2021-09-16T00:00:46.191Z', + friends: {}, schedule: { desiredCourses: ['CS 1100', 'CS 1331'], pinnedCrns: [ @@ -186,6 +188,7 @@ describe('useMigrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, diff --git a/src/data/hooks/useRawFriendDataFromFirebase.ts b/src/data/hooks/useRawFriendDataFromFirebase.ts new file mode 100644 index 00000000..1ccf05fe --- /dev/null +++ b/src/data/hooks/useRawFriendDataFromFirebase.ts @@ -0,0 +1,194 @@ +import { Immutable, castImmutable, castDraft } from 'immer'; +import { useCallback, useEffect, useState } from 'react'; + +import { SignedIn } from '../../contexts/account'; +import { ErrorWithFields, softError } from '../../log'; +import { + LoadingState, + LoadingStateCustom, + LoadingStateError, +} from '../../types'; +import { db, isAuthEnabled, friendsCollection } from '../firebase'; +import { FriendData, defaultFriendData } from '../types'; + +type HookResult = { + rawFriendData: Immutable; + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; +}; + +type FriendDataState = Loading | NonExistant | FriendDataExists; + +type Loading = { + type: 'loading'; +}; +type NonExistant = { + type: 'nonExistant'; +}; + +type FriendDataExists = { + type: 'exists'; + data: FriendData; +}; + +/** + * Gets the current schedule data from Firebase. + * Do not call this function in a non-root component; + * it should only be called once in a root component (i.e. ). + */ +export default function useRawFriendDataFromFirebase( + account: SignedIn +): LoadingState { + const [friendData, setFriendData] = useState({ + type: 'loading', + }); + + const [permanentError, setPermanentError] = useState< + LoadingStateError | LoadingStateCustom | null + >(null); + useEffect(() => { + if (!isAuthEnabled) return undefined; + + const removeFriendsSnapshotListener = friendsCollection + .doc(account.id) + .onSnapshot( + { + // Ignore metadata changes + includeMetadataChanges: false, + }, + (doc) => { + const data = doc.data(); + if (data == null) { + setFriendData({ type: 'nonExistant' }); + } else { + setFriendData({ + type: 'exists', + data: doc.data() as FriendData, + }); + } + } + ); + return (): void => { + removeFriendsSnapshotListener(); + }; + }, [account.id]); + + const setFriendDataPersistent = useCallback( + ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ): void => { + let nextFriendData; + setFriendData((state: FriendDataState) => { + if (typeof next === 'function') { + let currentFriendData; + if (state.type === 'exists') { + currentFriendData = state.data; + } else { + currentFriendData = null; + } + nextFriendData = next(currentFriendData); + } else { + nextFriendData = next; + } + if (nextFriendData === null) return state; + + // Eagerly set the friend data here as well. + // It would be okay to wait until Firebase updates the state for us, + // (which it will do, even before the network calls are made), + // but this allows a window where state can react based on stale state. + return { type: 'exists', data: nextFriendData }; + }); + + if (nextFriendData === undefined || nextFriendData === null) return; + friendsCollection + .doc(account.id) + .set(nextFriendData) + .catch((err) => { + softError( + new ErrorWithFields({ + message: 'error when updating remote document', + source: err, + fields: { + accountId: account.id, + }, + }) + ); + }); + }, + [account.id] + ); + + // Perform a transaction if the type is non-existent, + // trying to pull existing data from local storage + // and storing it in Firebase. + // This serves to provide the initial account data. + useEffect(() => { + if (!isAuthEnabled) return; + + if (friendData.type === 'nonExistant') { + // Imperatively get the latest migrated data + const currentFriendData: Immutable = defaultFriendData; + + // Start the transaction + db.runTransaction(async (transaction) => { + const currentDoc = await transaction.get( + friendsCollection.doc(account.id) + ); + if (currentDoc.exists) return; + transaction.set( + friendsCollection.doc(account.id), + castDraft(currentFriendData) + ); + }).catch((err) => { + // Send the error to Sentry + const error = new ErrorWithFields({ + message: 'an error occurred while initializing account friend data', + source: err, + fields: { + account: account.id, + }, + }); + softError(error); + + // Report the error to the user + setPermanentError({ + type: 'error', + error, + stillLoading: false, + overview: String(err), + }); + }); + } + }, [account.id, friendData.type]); + + // If this hook is running and auth is not enabled, + // then something is wrong with the state. + // Show an error. + if (!isAuthEnabled) { + return { + type: 'error', + error: new ErrorWithFields({ + message: 'cannot obtain data from firebase: authentication is disabled', + }), + stillLoading: false, + overview: 'authentication is not enabled', + }; + } + + if (permanentError !== null) { + return permanentError; + } + + if (friendData.type === 'loading' || friendData.type === 'nonExistant') { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + rawFriendData: castImmutable(friendData.data), + setFriendData: setFriendDataPersistent, + }, + }; +} diff --git a/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts b/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts new file mode 100644 index 00000000..820c1697 --- /dev/null +++ b/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts @@ -0,0 +1,266 @@ +import axios, { AxiosPromise } from 'axios'; +import { useState, useRef } from 'react'; +import { Immutable } from 'immer'; + +import { auth } from '../firebase'; +import useRateLimiter from '../../hooks/useRateLimiter'; +import { ErrorWithFields, softError } from '../../log'; +import { LoadingState } from '../../types'; +import { + exponentialBackoff, + isAxiosNetworkError, + sleep, +} from '../../utils/misc'; +import Cancellable from '../../utils/cancellable'; +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; +import { FriendIds, RawFriendScheduleData } from '../types'; +import useDeepCompareEffect from '../../hooks/useDeepCompareEffect'; + +interface HookResult { + friendScheduleData: RawFriendScheduleData; + term: string; +} + +const url = `${CLOUD_FUNCTION_BASE_URL}/fetchFriendSchedules`; + +export const RATE_LIMITER_BUCKET_STORAGE_KEY = + process.env.NODE_ENV === 'production' && !process.env['REACT_APP_PREVIEW'] + ? 'rate-limiter-bucket' + : 'rate-limiter-bucket-dev'; +const RATE_LIMITER_CAPACITY = 10; +const RATE_LIMITER_INTERVAL_SEC = 10; + +// Number of minutes between re-fetches of the friend schedules +const REFRESH_INTERVAL_MIN = 5; + +/** + * Fetches the schedules of friends that have been shared with the user + * for the given term. + * Repeatedly attempts to load in the case of errors, + * and cancels any in-flight downloads if the parent context is unmounted + * or the term is changed. + * Once loaded, this also attempts to update the data every 5 minutes + * in case the friends' schedules have been updated. + */ +export default function useRawFriendScheduleDataFromFirebaseFunction({ + currentTerm, + termFriendData, +}: { + currentTerm: string; + termFriendData: Immutable; +}): LoadingState { + const [state, setState] = useState>({ + type: 'loading', + }); + + const { hasReachedLimit, refreshBucket, decrementBucketCount } = + useRateLimiter( + RATE_LIMITER_BUCKET_STORAGE_KEY, + RATE_LIMITER_CAPACITY, + RATE_LIMITER_INTERVAL_SEC + ); + + // Keep a ref of the latest loaded schedules + // to check if it is any newer than the current one. + const loadedFriendScheduleRef = useRef(null); + + // Fetch the current term's friend schedules information + useDeepCompareEffect(() => { + if (Object.keys(termFriendData).length === 0) { + const res = { + friendScheduleData: {}, + term: currentTerm, + }; + loadedFriendScheduleRef.current = res; + return setState({ + type: 'loaded', + result: res, + }); + } + const loadOperation = new Cancellable(); + + async function loadAndRefresh(): Promise { + let isFirst = true; + while (!loadOperation.isCancelled) { + // Load the friend schedules, showing errors only if this is the + // first time it is being loaded (otherwise, just log errors + // but don't disrupt the user). This is to prevent + // a background refresh from showing an error screen + // in the middle of a session. + // `load` will return early if it is cancelled + await load({ initialLoad: isFirst }); + if (loadOperation.isCancelled) return; + + // Sleep for the refresh interval, + // exiting early if cancelled + const promise = sleep({ amount_ms: REFRESH_INTERVAL_MIN * 60 * 1000 }); + const result = await loadOperation.perform(promise); + if (result.cancelled) { + return; + } + + isFirst = false; + } + } + + async function load({ + initialLoad, + }: { + initialLoad: boolean; + }): Promise { + if (initialLoad) { + setState({ + type: 'loading', + }); + } + + let attemptNumber = 1; + while (!loadOperation.isCancelled) { + try { + const requestData = JSON.stringify({ + IDToken: await auth.currentUser?.getIdToken(), + friends: termFriendData, + term: currentTerm, + }); + /* eslint-disable max-len */ + // This request should be made with content type is application/x-www-form-urlencoded. + // This is done to prevent a pre-flight CORS request made to the firebase function. + // Refer: https://github.com/gt-scheduler/website/pull/187#issuecomment-1496439246 + /* eslint-enable max-len */ + const promise = axios({ + method: 'POST', + url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: `data=${requestData}`, + }) as AxiosPromise; + const result = await loadOperation.perform(promise); + if (result.cancelled) { + return; + } + + const json = result.value.data; + + // If the data is the same as the currently loaded data, + // skip loading it + if ( + loadedFriendScheduleRef.current !== null && + loadedFriendScheduleRef.current.friendScheduleData === json && + loadedFriendScheduleRef.current.term === currentTerm + ) { + // Skip this update + return; + } + + const res = { + friendScheduleData: json, + term: currentTerm, + }; + + setState({ + type: 'loaded', + result: res, + }); + loadedFriendScheduleRef.current = res; + + return; + } catch (err) { + // Report the error to Sentry if not a network error + if (!isAxiosNetworkError(err)) { + softError( + new ErrorWithFields({ + message: 'error fetching friend schedules', + source: err, + fields: { + url, + term: currentTerm, + termFriendData, + }, + }) + ); + } + + if (initialLoad) { + // Flag that an error has occurred + setState({ + type: 'error', + error: + err instanceof Error + ? err + : new ErrorWithFields({ + message: + 'an error occurred while fetching friend schedules', + source: err, + }), + stillLoading: true, + overview: String(err), + }); + } + } + + // Sleep for an exponential backoff between each retry + await exponentialBackoff(attemptNumber); + attemptNumber += 1; + } + } + + refreshBucket(); + if (hasReachedLimit) { + const err = new ErrorWithFields({ + message: 'error loading and refreshing friend schedules', + source: new Error('Exceeded rate limit'), + fields: { + url, + term: currentTerm, + termFriendData, + hasReachedLimit, + }, + }); + softError(err); + setState({ + type: 'error', + error: err, + stillLoading: false, + overview: String(err), + }); + } else { + decrementBucketCount(); + loadAndRefresh().catch((err) => { + softError( + new ErrorWithFields({ + message: 'error loading and refreshing friend schedules', + source: err, + fields: { + url, + term: currentTerm, + termFriendData, + hasReachedLimit, + }, + }) + ); + }); + } + + // Cancel the background load when this cleans up + return (): void => { + loadOperation.cancel(); + }; + }, [ + currentTerm, + termFriendData, + setState, + hasReachedLimit, + refreshBucket, + decrementBucketCount, + ]); + + // If we are about to start a new background load + // after the term changed, then don't return the already fetched + // friend schedules + if (state.type === 'loaded' && state.result.term !== currentTerm) { + return { type: 'loading' }; + } + + return state; +} diff --git a/src/data/hooks/useRawScheduleDataFromFirebase.ts b/src/data/hooks/useRawScheduleDataFromFirebase.ts index 21838796..49c3620e 100644 --- a/src/data/hooks/useRawScheduleDataFromFirebase.ts +++ b/src/data/hooks/useRawScheduleDataFromFirebase.ts @@ -80,24 +80,28 @@ export default function useRawScheduleDataFromFirebase( | AnyScheduleData ): void => { let nextScheduleData; - if (typeof next === 'function') { - let currentScheduleData; - if (scheduleData.type === 'exists') { - currentScheduleData = scheduleData.data; + setScheduleData((state: ScheduleDataState) => { + if (typeof next === 'function') { + let currentScheduleData; + if (state.type === 'exists') { + currentScheduleData = state.data; + } else { + currentScheduleData = null; + } + nextScheduleData = next(currentScheduleData); } else { - currentScheduleData = null; + nextScheduleData = next; } - nextScheduleData = next(currentScheduleData); - } else { - nextScheduleData = next; - } - if (nextScheduleData === null) return; + if (nextScheduleData === null) return state; + + // Eagerly set the schedule data here as well. + // It would be okay to wait until Firebase updates the state for us, + // (which it will do, even before the network calls are made), + // but this allows a window where state can react based on stale state. + return { type: 'exists', data: nextScheduleData }; + }); - // Eagerly set the schedule data here as well. - // It would be okay to wait until Firebase updates the state for us, - // (which it will do, even before the network calls are made), - // but this allows a window where state can react based on stale state. - setScheduleData({ type: 'exists', data: nextScheduleData }); + if (nextScheduleData === undefined || nextScheduleData === null) return; schedulesCollection .doc(account.id) @@ -114,7 +118,7 @@ export default function useRawScheduleDataFromFirebase( ); }); }, - [account.id, scheduleData] + [account.id] ); // Perform a transaction if the type is non-existent, diff --git a/src/data/hooks/useVersionActions.ts b/src/data/hooks/useVersionActions.ts index 9e5ab32f..81a394b4 100644 --- a/src/data/hooks/useVersionActions.ts +++ b/src/data/hooks/useVersionActions.ts @@ -13,6 +13,7 @@ export type HookResult = { deleteVersion: (id: string) => void; renameVersion: (id: string, newName: string) => void; cloneVersion: (id: string, newName: string) => void; + deleteFriendRecord: (versionId: string, friendId: string) => void; }; /** @@ -40,6 +41,7 @@ export default function useVersionActions({ updateTermScheduleData((draft) => { draft.versions[id] = { name, + friends: {}, schedule: castDraft(defaultSchedule), createdAt: new Date().toISOString(), }; @@ -90,6 +92,7 @@ export default function useVersionActions({ const newId = generateScheduleVersionId(); draft.versions[newId] = { name: 'Primary', + friends: {}, createdAt: new Date().toISOString(), schedule: castDraft(defaultSchedule), }; @@ -174,6 +177,7 @@ export default function useVersionActions({ } draft.versions[newId] = { name: newName, + friends: {}, schedule: castDraft(existingDraft.schedule), createdAt: new Date().toISOString(), }; @@ -183,5 +187,50 @@ export default function useVersionActions({ [updateTermScheduleData, setVersion] ); - return { addNewVersion, deleteVersion, renameVersion, cloneVersion }; + const deleteFriendRecord = useCallback( + (versionId: string, friendId: string): void => { + updateTermScheduleData((draft) => { + const existingDraft = draft.versions[versionId]; + if (existingDraft === undefined) { + softError( + new ErrorWithFields({ + message: + "deleteFriendRecord called with version name that doesn't exist; ignoring", + fields: { + allVersionNames: Object.entries(draft.versions).map( + ([versionId_, { name }]) => ({ id: versionId_, name }) + ), + versionId, + versionCount: Object.keys(draft.versions).length, + }, + }) + ); + return; + } + if (friendId in existingDraft.friends) { + delete existingDraft.friends[friendId]; + } else { + softError( + new ErrorWithFields({ + message: + "deleteFriendRecord called with friend ID that doesn't exist; ignoring", + fields: { + allFriendIds: Object.keys(existingDraft.friends), + friendId, + }, + }) + ); + } + }); + }, + [updateTermScheduleData] + ); + + return { + addNewVersion, + deleteVersion, + renameVersion, + cloneVersion, + deleteFriendRecord, + }; } diff --git a/src/data/migrations/2to3.test.ts b/src/data/migrations/2to3.test.ts index b30fc690..992504c6 100644 --- a/src/data/migrations/2to3.test.ts +++ b/src/data/migrations/2to3.test.ts @@ -127,6 +127,7 @@ describe('migrate2to3', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, sv_00000000000000000001: { name: 'Secondary', @@ -140,6 +141,7 @@ describe('migrate2to3', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, sv_00000000000000000002: { name: 'Tertiary', @@ -153,6 +155,7 @@ describe('migrate2to3', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, diff --git a/src/data/migrations/2to3.ts b/src/data/migrations/2to3.ts index 1e5f5e30..291a8048 100644 --- a/src/data/migrations/2to3.ts +++ b/src/data/migrations/2to3.ts @@ -37,6 +37,7 @@ export default function migrate2To3( const version3ScheduleVersion: Version3ScheduleVersion = { name: version2ScheduleVersion.name, createdAt: version2ScheduleVersion.createdAt, + friends: {}, schedule: { ...version2ScheduleVersion.schedule, ...newFields, diff --git a/src/data/migrations/index.test.ts b/src/data/migrations/index.test.ts index 48c0684d..6336bcde 100644 --- a/src/data/migrations/index.test.ts +++ b/src/data/migrations/index.test.ts @@ -62,6 +62,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -131,6 +132,7 @@ describe('migrateScheduleData', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -155,6 +157,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -222,6 +225,7 @@ describe('migrateScheduleData', () => { colorMap: {}, sortingOptionIndex: 0, }, + friends: {}, }, }, }, @@ -245,6 +249,7 @@ describe('migrateScheduleData', () => { colorMap: { 'CS 1100': '#0062B1', 'CS 1331': '#194D33' }, sortingOptionIndex: 0, }, + friends: {}, }, }, }, diff --git a/src/data/types.ts b/src/data/types.ts index 6f955933..d3ab1272 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,4 +1,4 @@ -import { Immutable } from 'immer'; +import { castImmutable, Immutable } from 'immer'; import { Event } from '../types'; import { generateRandomId } from '../utils/misc'; @@ -39,6 +39,27 @@ export const defaultScheduleData: Immutable = { version: 3, }; +export const defaultFriendData: Immutable = { + terms: {}, + info: {}, +}; + +export const defaultFriendInfo: Immutable<{ + name: string; + email: string; +}> = castImmutable({ + name: '', + email: '', +}); + +export const defaultFriendTermData: Immutable = { + accessibleSchedules: {}, +}; + +export const defaultFriendScheduleData: Immutable = { + // +}; + export const defaultTermScheduleData: Immutable = { versions: {}, }; @@ -130,10 +151,16 @@ export interface Version3TermScheduleData { export interface Version3ScheduleVersion { name: string; + friends: Record; createdAt: string; schedule: Version3Schedule; } +export interface FriendShareData { + status: 'Pending' | 'Accepted'; + email: string; +} + export interface Version3Schedule { desiredCourses: string[]; pinnedCrns: string[]; @@ -142,3 +169,54 @@ export interface Version3Schedule { colorMap: Record; sortingOptionIndex: number; } + +export type FriendIds = Record; + +export interface FriendTermData { + accessibleSchedules: FriendIds; +} + +export type ApiErrorResponse = { + message: string; +}; + +export type FriendInfo = Record< + string, + { + name: string; + email: string; + } +>; + +export interface FriendData { + terms: Record; + info: FriendInfo; +} + +export type RawFriendScheduleData = Record< + string, + { + versions: Record< + string, + { + name: string; + schedule: Schedule; + } + >; + } +>; + +export type FriendScheduleData = Record< + string, + { + name: string; + email: string; + versions: Record< + string, + { + name: string; + schedule: Schedule; + } + >; + } +>; diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts new file mode 100644 index 00000000..f1730456 --- /dev/null +++ b/src/hooks/useDeepCompareEffect.ts @@ -0,0 +1,31 @@ +import { useEffect, EffectCallback, DependencyList, useRef } from 'react'; +import lodash from 'lodash'; + +/** + * Inspired by https://github.com/kentcdodds/use-deep-compare-effect + * React useEffect performs a reference equality check for its non-primitive + * dependencies. + * + * If obj is an object that is a state, and useEffect has a dependency + * on obj.field, where obj.field is non-primitive, issues can arise. + * The Effect will re-run on every state change to obj even if the value of + * obj.field remains the same, since the state change changed the reference + * of obj. + * + * This hook keeps track of the previous dependency list and performs a deep + * comparison with the new dependency list. The Effect is re-run only if the + * deep comparison is false. + */ +export default function useDeepCompareEffect( + callback: EffectCallback, + dependencies: DependencyList +): void { + const ref = useRef([]); + + if (!lodash.isEqual(dependencies, ref.current)) { + ref.current = lodash.cloneDeep(dependencies); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useEffect(callback, ref.current); +} diff --git a/src/hooks/useRateLimiter.ts b/src/hooks/useRateLimiter.ts new file mode 100644 index 00000000..a7ca76af --- /dev/null +++ b/src/hooks/useRateLimiter.ts @@ -0,0 +1,80 @@ +import { useMemo, useCallback } from 'react'; +import useLocalStorageState from 'use-local-storage-state'; + +interface RateLimiterBucket { + remainingCount: number; + lastRefreshTime: string | Date; +} + +export default function useRateLimiter( + bucketName: string, + capacity: number, + interval: number +): { + hasReachedLimit: boolean; + refreshBucket: () => void; + decrementBucketCount: () => void; +} { + const [bucket, setBucket] = useLocalStorageState( + bucketName, + { + defaultValue: { + remainingCount: capacity, + lastRefreshTime: new Date(), + }, + storageSync: true, + } + ); + + const intervalMs = useMemo(() => interval * 1000, [interval]); + + const hasReachedLimit = useMemo(() => { + return bucket.remainingCount < 0; + }, [bucket.remainingCount]); + + const refreshBucket = useCallback(() => { + setBucket((currBucket) => { + const oldDate = new Date(currBucket.lastRefreshTime); + const newDate = new Date(); + const isOldDateInvalid = Number.isNaN(oldDate.valueOf()); + if (!isOldDateInvalid) { + const bucketCountAdded = Math.floor( + ((newDate.valueOf() - oldDate.valueOf()) / intervalMs) * capacity + ); + if (bucketCountAdded > 0) { + return { + remainingCount: Math.min( + capacity, + currBucket.remainingCount + + bucketCountAdded + + (currBucket.remainingCount < 0 ? 1 : 0) + ), + lastRefreshTime: newDate, + }; + } + } + + return { + remainingCount: Math.min(capacity, currBucket.remainingCount), + lastRefreshTime: isOldDateInvalid + ? new Date() + : currBucket.lastRefreshTime, + }; + }); + }, [capacity, intervalMs, setBucket]); + + const decrementBucketCount = useCallback(() => { + setBucket((currBucket) => { + return { + ...currBucket, + remainingCount: currBucket.remainingCount - 1, + }; + }); + }, [setBucket]); + + return { + hasReachedLimit, + refreshBucket, + decrementBucketCount, + }; +} diff --git a/src/index.tsx b/src/index.tsx index eee92b7f..003c1795 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; import { Integrations } from '@sentry/tracing'; -import App from './components/App'; +import RouterComponent from './components/RouterComponent'; import { ErrorWithFields } from './log'; import 'normalize.css'; @@ -31,4 +31,4 @@ if (container === null) { }); } const root = createRoot(container); -root.render(); +root.render(); diff --git a/src/types.ts b/src/types.ts index 2302a8bb..f496416c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,3 +354,27 @@ export interface CrawlerTermData { */ version: number; } + +export type ScheduleDeletionRequest = { + /** + * token of account that requested the schedule deletion + */ + IDToken: string | void; + /** + * ID of the INVITEE if the deletion requester is the INVITER + * ID of the INVITER if the deletion requester is the INVITEE + */ + peerUserId: string; + /** + * term that schedule version(s) belong to + */ + term: string; + /** + * shared schedule version(s) for deletion + */ + versions: string[] | string; + /** + * whether the schedule version belongs to the requester + */ + owner: boolean; +}; diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx index 52b921f3..ef684930 100644 --- a/src/utils/misc.tsx +++ b/src/utils/misc.tsx @@ -279,6 +279,9 @@ const getDateRange = (term: string): DateRange => { return { from, to }; }; +// Difference between UTC and EST timezones in minutes +export const EST_TIMEZONE_OFFSET = 240; + /** * Exports the current schedule to a `.ics` file, * which allows for importing into a third-party calendar application. @@ -302,6 +305,8 @@ export function exportCoursesToCalendar( return; } + const timezoneDiff = EST_TIMEZONE_OFFSET - new Date().getTimezoneOffset(); + const addEventsToCalendar = ( period: Period, days: string[], @@ -317,9 +322,11 @@ export function exportCoursesToCalendar( ) { begin.setDate(begin.getDate() + 1); } - begin.setHours(period.start / 60, period.start % 60); + const startWithOffset = period.start + timezoneDiff; + const endWithOffset = period.end + timezoneDiff; + begin.setHours(startWithOffset / 60, startWithOffset % 60); const end = new Date(begin.getTime()); - end.setHours(period.end / 60, period.end % 60); + end.setHours(endWithOffset / 60, endWithOffset % 60); const rrule = { freq: 'WEEKLY', until: to, diff --git a/yarn.lock b/yarn.lock index 1ba506cf..e6df0392 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2071,6 +2071,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@remix-run/router@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.10.0.tgz#e2170dc2049b06e65bbe883adad0e8ddf8291278" + integrity sha512-Lm+fYpMfZoEucJ7cMxgt4dYt8jLfbpwRCzAjm9UgSLOkmlqo9gupxt6YX3DY0Fk155NT9l17d/ydi+964uS9Lw== + "@restart/hooks@^0.4.7": version "0.4.7" resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.7.tgz#d79ca6472c01ce04389fc73d4a79af1b5e33cd39" @@ -2566,6 +2571,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash@^4.14.192": + version "4.14.192" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.192.tgz#5790406361a2852d332d41635d927f1600811285" + integrity sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A== + "@types/long@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" @@ -9744,6 +9754,21 @@ react-resize-panel@^0.3.5: lodash.debounce "^4.0.8" react-draggable "^4.0.3" +react-router-dom@^6.17.0: + version "6.17.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.17.0.tgz#ea73f89186546c1cf72b10fcb7356d874321b2ad" + integrity sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ== + dependencies: + "@remix-run/router" "1.10.0" + react-router "6.17.0" + +react-router@6.17.0: + version "6.17.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.17.0.tgz#7b680c4cefbc425b57537eb9c73bedecbdc67c1e" + integrity sha512-YJR3OTJzi3zhqeJYADHANCGPUu9J+6fT5GLv82UWRGSxu6oJYCKVmxUcaBQuGm9udpWmPsvpme/CdHumqgsoaA== + dependencies: + "@remix-run/router" "1.10.0" + react-scripts@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003"
{popoverInfo.name}