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 (
<>
-
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}
+ />
+
+ {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 && (
+ <>
+
+
+ >
+ )}
+ {(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'}
+
+
+ {compare && (
+
+ )}
+
+
Schedule Combinations
+
+
+ {/*
{
+ setHoverCompare(true);
+ setTooltipYCompare(e.clientY);
+ }}
+ />
+
+
+ Turn off Compare Schedule
+
+ to access courses and events
+
+ */}
+
+
+
+ );
+}
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 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
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 (
+
+
-
-
-
+
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 */}
-