Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion client/src/api/times/times.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const COURSES_CLASS_TIMES_QUERY: CoursesClassTimesQueryType = gql`
classes(where: { course_id: { _in: $courseIds }, year: { _eq: $year }, term: { _eq: $term } }) {
times {
day
start_time
time
}
}
}
Expand All @@ -182,6 +182,61 @@ export const useCoursesClassTimesQuery = (courseIds: string[], year: number, ter
return data.classes;
};

type CourseClassTimesDetailedQueryType = TypedDocumentNode<
{
classes: {
times: {
day: string;
time: string;
location: string;
}[];
section: string;
class_id: string;
activity: string;
}[];
},
{ courseId: string; year: number; term: string }
>;

export const COURSE_CLASS_TIMES_DETAILED_QUERY: CourseClassTimesDetailedQueryType = gql`
query GetCoursesClassTimesDetailed($courseId: String!, $year: Int!, $term: String!) {
classes(where: { course_id: { _eq: $courseId }, year: { _eq: $year }, term: { _eq: $term } }) {
times {
day
time
location
}
section
class_id
activity
}
}
`;

export const useCourseClassTimesDetailedQuery = (courseId: string, year: number, term: string) => {
const skip = courseId.length === 0;

const { data } = useSuspenseQuery(COURSE_CLASS_TIMES_DETAILED_QUERY, {
variables: { courseId, year, term },
skip,
});

// Data should only be undefined if skipped
if (skip || data === undefined)
return [] as {
times: {
day: string;
time: string;
location: string;
}[];
class_id: string;
section: string;
activity: string;
}[];

return data.classes;
};

const COURSE_LIST_QUERY: TypedDocumentNode<
{
courses: {
Expand Down
10 changes: 10 additions & 0 deletions client/src/api/timetable/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { QueryClient, useMutation } from '@tanstack/react-query';

import {
addEvent,
AddEventParams,
addTimetableCourse,
createTimetable,
deleteTimetable,
Expand Down Expand Up @@ -89,3 +91,11 @@ export const useDuplicateTimetable = (queryClient: QueryClient) =>
variables.onSuccess(data);
},
});

export const useAddTimetableEvent = (queryClient: QueryClient) =>
useMutation({
mutationFn: ({ event }: { event: AddEventParams }) => addEvent(event),
onSuccess: async (_data, variables, _context) => {
await queryClient.invalidateQueries({ queryKey: ['timetable', variables.event.timetableId] });
},
});
11 changes: 11 additions & 0 deletions client/src/api/timetable/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,14 @@ export const useTimetableInfoQuery = (timetableId: string) =>
queryKey: ['timetable', timetableId, 'info'],
queryFn: () => getTimetableInfo(timetableId),
}).data;

// export const useTimetableEventQuery = (eventIds: string[]) => {
// const queries = useSuspenseQueries({
// queries: eventIds.map((id) => ({
// queryKey: ['timetable', 'event', id],
// queryFn: () => getEventInfo(id),
// })),
// });

// return queries.map((query) => query.data);
// };
54 changes: 54 additions & 0 deletions client/src/api/timetable/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,57 @@ export const makePrimaryTimetable = async (timetableId: string): Promise<void> =
export const duplicateTimetable = async (timetableId: string): Promise<string> => {
return (await apiClient.post<string>(`/user/timetables/${timetableId}/duplicate`)).data;
};

interface TimetableEvent {
id: string;
timetableId: string;
colour: string;
title: string;
location: string | null;
description: string | null;
dayOfWeek: number;
start: number;
end: number;
type: 'CUSTOM' | 'TUTORING';
}

export const getEventInfo = async (eventId: string) => {
return (await apiClient.get<TimetableEvent>(`/user/timetables/event/${eventId}`)).data;
};

export interface AddEventParams {
timetableId: string;
colour: string;
dayOfWeek: number; // 0 = Monday, 6 = Sunday
start: number; // Mins since midnight
end: number; // Mins since midnight
type: 'CUSTOM' | 'TUTORING';
title: string;
description?: string;
location?: string;
}
export const addEvent = async ({
timetableId,
colour,
dayOfWeek,
start,
end,
type,
title,
description,
location,
}: AddEventParams): Promise<void> => {
await apiClient.post(`/user/timetables/event/${timetableId}`, {
event: {
timetableId,
colour,
dayOfWeek,
start,
end,
type,
title,
description,
location,
},
});
};
87 changes: 87 additions & 0 deletions client/src/components/planner/controls/ColorOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import { IconButton, List, ListItem, useTheme } from '@mui/material';
import { useMemo } from 'react';

import { useGetUserSettingsQuery } from '../../../api/user/queries';
import { decodeColor } from '../../../utils/colors';

const COLORS = ['default-1', 'default-2', 'default-3', 'default-4', 'default-5', 'default-6', 'default-7', 'default-8'];

const StyledColorIconButton = styled(IconButton, {
shouldForwardProp: (prop) => prop !== 'border' && prop !== 'bgColor',
})<{ border: string; bgColor: string }>(({ border, bgColor }) => ({
backgroundColor: bgColor,
width: 40,
height: 40,
'&:hover': {
backgroundColor: bgColor,
border: `2px solid ${border}`,
},
}));

interface ColorOptionsProps {
maxDefaultColors?: number;
showCustomColorPicker: boolean;
onSelectColor: (color: string) => void;
onCustomColorSelect: () => void;
}

const ColorOptions = ({
maxDefaultColors = 4, // Default to 4 color options
showCustomColorPicker,
onSelectColor,
onCustomColorSelect,
}: ColorOptionsProps) => {
const theme = useTheme();
const { preferredTheme } = useGetUserSettingsQuery();
const decodedColors = useMemo(() => COLORS.map((color) => decodeColor(color, preferredTheme)), [preferredTheme]);

const selectedThemeColorDisplay = useMemo(() => {
const colorItems = [];
for (let i = 0; i < COLORS.length; i += maxDefaultColors) {
const isLastChunk = i === COLORS.length - maxDefaultColors;
colorItems.push(
COLORS.slice(i, isLastChunk ? i + maxDefaultColors - 1 : i + maxDefaultColors).map((color, j) => (
<ListItem component="div" disablePadding key={color}>
<StyledColorIconButton
border={theme.palette.secondary.main}
bgColor={decodedColors[i + j]}
onClick={() => {
onSelectColor(color);
}}
/>
</ListItem>
)),
);
}
colorItems[colorItems.length - 1].push(
<StyledColorIconButton
border={theme.palette.secondary.main}
bgColor={theme.palette.secondary.dark}
onClick={onCustomColorSelect}
>
{showCustomColorPicker ? <CloseIcon /> : <AddIcon />}
</StyledColorIconButton>,
);

return colorItems.map((item, index) => (
<ListItem key={index} sx={{ display: 'flex', flexDirection: 'row', gap: 1.2 }} disablePadding>
{item}
</ListItem>
));
}, [
maxDefaultColors,
theme.palette.secondary.main,
theme.palette.secondary.dark,
decodedColors,
onCustomColorSelect,
showCustomColorPicker,
onSelectColor,
]);

return <List sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>{selectedThemeColorDisplay}</List>;
};

export default ColorOptions;
114 changes: 114 additions & 0 deletions client/src/components/planner/controls/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Box, Button, ButtonGroup, ListItem, Popover, TextField } from '@mui/material';
import { Colorful } from '@uiw/react-color';
import { useMemo, useState } from 'react';

import { useGetUserSettingsQuery } from '../../../api/user/queries';
import { ColorIndicatorBox, StyledButtonContainer } from '../../../styles/ControlStyles';
import { decodeColor, oklchToHex } from '../../../utils/colors';
import ColorOptions from './ColorOptions';

interface ColorPickerProps {
color: string;
setColor: (color: string) => void;
colorPickerAnchorEl: HTMLElement | null;
handleOpenColorPicker: (event: React.MouseEvent<HTMLElement>) => void;
handleCloseColorPicker: () => void;
handleSaveNewColor?: () => void;
}

const ColorPicker: React.FC<ColorPickerProps> = ({
color,
setColor,
colorPickerAnchorEl,
handleOpenColorPicker,
handleCloseColorPicker,
handleSaveNewColor,
}) => {
// Whether the colour picker popover is shown
const openColorPickerPopover = Boolean(colorPickerAnchorEl);
const colorPickerPopoverId = openColorPickerPopover ? 'simple-popover' : undefined;

const [showCustomColorPicker, setShowCustomColorPicker] = useState(false);
const { preferredTheme } = useGetUserSettingsQuery();

const decodedColor = decodeColor(color, preferredTheme);
const textFieldValue = useMemo(() => oklchToHex(decodedColor), [decodedColor]);

return (
<Box m={1} display="flex" justifyContent="center" alignItems="center">
<ColorIndicatorBox backgroundColor={decodeColor(color, preferredTheme)} onClick={handleOpenColorPicker} />
<StyledButtonContainer>
<ButtonGroup>
<Button
disableElevation
variant="outlined"
size="small"
aria-describedby={colorPickerPopoverId}
onClick={handleOpenColorPicker}
>
Choose Colour
</Button>
{handleSaveNewColor && (
<Button variant="contained" size="small" onClick={handleSaveNewColor} disableElevation>
Save
</Button>
)}
</ButtonGroup>
</StyledButtonContainer>
<Popover
id={colorPickerPopoverId}
open={openColorPickerPopover}
anchorEl={colorPickerAnchorEl}
onClose={handleCloseColorPicker}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<ListItem alignItems="flex-start">
<ColorOptions
showCustomColorPicker={showCustomColorPicker}
onSelectColor={(selectedColor) => {
setColor(selectedColor);
}}
onCustomColorSelect={() => {
setShowCustomColorPicker(!showCustomColorPicker);
}}
/>
</ListItem>
{showCustomColorPicker && (
<ListItem alignItems="flex-start">
<Colorful
onChange={(e) => {
setColor(e.hex);
}}
color={color}
disableAlpha
/>
</ListItem>
)}
<ListItem alignItems="flex-start">
<TextField
id="outlined-required"
label="Hex"
variant="outlined"
value={textFieldValue}
onChange={(e) => {
let newColor = e.target.value;
if (newColor !== '' && !newColor.startsWith('#')) {
newColor = `#${newColor}`;
}
setColor(newColor);
}}
/>
</ListItem>
</Popover>
</Box>
);
};

export default ColorPicker;
2 changes: 1 addition & 1 deletion client/src/components/planner/controls/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const Controls: React.FC<{ term: Term; setTerm: (term: Term) => void; timetableI
}}
>
<CustomEventsWrapper>
<CustomEvents />
<CustomEvents term={term} timetableId={timetableId} />
</CustomEventsWrapper>
<AutotimetablerWrapper>
<Autotimetabler />
Expand Down
Loading