├── .gitignore ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── manifest.json ├── src ├── index.html ├── index.tsx ├── sidebar │ ├── Sidebar.tsx │ ├── SidebarButton.tsx │ ├── SidebarDrawer.tsx │ ├── drawers │ │ ├── calendar │ │ │ ├── CalendarDrawer.tsx │ │ │ ├── CalendarEventDisplay.tsx │ │ │ ├── DayPicker.tsx │ │ │ ├── DayView.tsx │ │ │ └── MonthView.tsx │ │ ├── mail │ │ │ ├── ConversationPreview.tsx │ │ │ ├── ConversationView.tsx │ │ │ └── MailDrawer.tsx │ │ ├── search │ │ │ ├── SearchDrawer.tsx │ │ │ ├── SearchItemDisplay.tsx │ │ │ ├── SearchItemGroup.tsx │ │ │ └── SearchResults.tsx │ │ └── todo │ │ │ ├── Planner.tsx │ │ │ ├── PlannerHeader.tsx │ │ │ ├── TasksDrawer.tsx │ │ │ ├── TodoCategories.tsx │ │ │ └── TodoHeader.tsx │ └── menus │ │ └── navigator │ │ └── Navigator.tsx ├── style.css └── util │ ├── BinaryCarousel.tsx │ ├── Button.tsx │ ├── SegmentedControl.tsx │ ├── TransitionStyles.ts │ ├── coreResources.ts │ ├── getAllPages.ts │ └── search.ts ├── tailwind.config.js ├── tsconfig.json ├── vite.config.ts └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | dist/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.37", 11 | "@types/react": "^18.2.14", 12 | "@types/react-dom": "^18.2.6", 13 | "@types/react-transition-group": "^4.4.6", 14 | "@vitejs/plugin-react": "^4.0.1", 15 | "clean-html": "^2.0.1", 16 | "color": "^4.2.3", 17 | "damerau-levenshtein": "^1.0.8", 18 | "i": "^0.3.7", 19 | "npm": "^9.7.2", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-icons": "^4.10.1", 23 | "react-scripts": "5.0.1", 24 | "react-transition-group": "^4.4.5", 25 | "redaxios": "^0.5.1", 26 | "style-inject": "^0.3.0", 27 | "trigram-similarity": "^1.0.7", 28 | "typescript": "^4.9.5", 29 | "vite": "^4.3.9", 30 | "vite-plugin-css-injected-by-js": "^3.1.2", 31 | "web-vitals": "^2.1.4" 32 | }, 33 | "scripts": { 34 | "start": "react-scripts start", 35 | "build": "vite build", 36 | "test": "react-scripts test", 37 | "eject": "react-scripts eject" 38 | }, 39 | "eslintConfig": { 40 | "extends": [ 41 | "react-app", 42 | "react-app/jest" 43 | ] 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.2%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "devDependencies": { 58 | "@types/color": "^3.0.3", 59 | "@types/damerau-levenshtein": "^1.0.2", 60 | "@types/sanitize-html": "^2.9.0", 61 | "@types/style-inject": "^0.3.0", 62 | "autoprefixer": "^10.4.14", 63 | "copy-webpack-plugin": "^11.0.0", 64 | "postcss": "^8.4.24", 65 | "rollup-plugin-embed-css": "^1.0.26", 66 | "rollup-plugin-inline-postcss": "^3.0.1", 67 | "tailwindcss": "^3.3.2", 68 | "ts-loader": "^9.4.3", 69 | "webpack": "^5.88.0", 70 | "webpack-cli": "^5.1.4" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Canvas+ With TS", 3 | "version": "1.0.0", 4 | "manifest_version": 3, 5 | "content_scripts": [ 6 | { 7 | "matches": [""], 8 | "js": ["src/index.js"], 9 | "run_at": "document_end" 10 | } 11 | ], 12 | "permissions": ["storage"] 13 | } 14 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import Sidebar from "./sidebar/Sidebar"; 4 | import "./style.css"; 5 | async function start() { 6 | if (document.querySelector(".ic-app#application")) { 7 | console.log("CanvasPlus: Initializing..."); 8 | 9 | const header = 10 | document.querySelector("#header") ?? document.createElement("div"); 11 | 12 | header.innerHTML = ""; 13 | header.className = "canvasplus-header"; 14 | 15 | document.body.appendChild(header); 16 | 17 | const root = ReactDOM.createRoot(header); 18 | 19 | root.render( 20 | 21 | 22 | 23 | ); 24 | } 25 | } 26 | 27 | start(); 28 | -------------------------------------------------------------------------------- /src/sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SidebarButton from "./SidebarButton"; 3 | import { 4 | FiBook, 5 | FiCalendar, 6 | FiCheckSquare, 7 | FiCompass, 8 | FiMail, 9 | FiMoreHorizontal, 10 | FiNavigation, 11 | FiSearch, 12 | } from "react-icons/fi"; 13 | import SidebarDrawer from "./SidebarDrawer"; 14 | import Navigator from "./menus/navigator/Navigator"; 15 | 16 | export default function Sidebar() { 17 | const [show, __setShow] = useState(false); 18 | 19 | const [screen, __setScreen] = useState(undefined); 20 | 21 | function setScreen(newScreen?: string) { 22 | if (newScreen == null) { 23 | __setShow(false); 24 | 25 | setTimeout(() => { 26 | __setScreen(undefined); 27 | }, 150); 28 | } else if (screen === newScreen) { 29 | __setShow(false); 30 | 31 | setTimeout(() => { 32 | __setScreen(undefined); 33 | }, 150); 34 | } else if (screen == null) { 35 | __setShow(true); 36 | __setScreen(newScreen); 37 | } else { 38 | __setShow(false); 39 | setTimeout(() => { 40 | __setShow(true); 41 | __setScreen(newScreen); 42 | }, 150); 43 | } 44 | } 45 | 46 | return ( 47 |
48 |
49 | 50 | } 53 | childrenType="menu" 54 | > 55 | 56 | 57 | 58 | } 61 | onClick={() => setScreen("book")} 62 | > 63 | Courses 64 | 65 | } 68 | onClick={() => setScreen("search")} 69 | > 70 | Search 71 | 72 | } 75 | onClick={() => setScreen("tasks")} 76 | > 77 | Tasks 78 | 79 |
80 |
81 |
82 | } 85 | onClick={() => setScreen("mail")} 86 | > 87 | Mail 88 | 89 | } 92 | onClick={() => setScreen("calendar")} 93 | > 94 | Calendar 95 | 96 | } 99 | onClick={() => setScreen("more")} 100 | childrenType="menu" 101 | > 102 |
103 |

More

104 | 107 |
108 |
109 |
110 | setScreen(undefined)} 114 | /> 115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/sidebar/SidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Transition } from "react-transition-group"; 3 | import { TransitionStyles } from "../util/TransitionStyles"; 4 | 5 | export default function SidebarButton(props: { 6 | active: boolean; 7 | icon: React.ReactElement; 8 | onClick?: () => void; 9 | children?: React.ReactNode; 10 | childrenType?: "tooltip" | "menu"; 11 | }) { 12 | const [showPopout, setShowPopout] = useState(false); 13 | const [hover, setHover] = useState(false); 14 | 15 | useEffect(() => { 16 | if (props.childrenType === "menu") { 17 | if (hover) { 18 | setShowPopout(true); 19 | } else { 20 | const timeout = setTimeout(() => { 21 | setShowPopout(false); 22 | }, 400); 23 | 24 | return () => { 25 | clearTimeout(timeout); 26 | }; 27 | } 28 | } else { 29 | setShowPopout(hover); 30 | } 31 | }, [hover]); 32 | 33 | const tooltipRef = React.useRef(null); 34 | 35 | const tooltipStyles: TransitionStyles = { 36 | entering: { 37 | opacity: 0, 38 | "--tw-scale-x": 0.5, 39 | "--tw-scale-y": 0.5, 40 | } as React.CSSProperties, 41 | entered: { 42 | transitionProperty: "transform, opacity", 43 | }, 44 | exiting: { 45 | transitionProperty: "transform, opacity", 46 | "--tw-scale-x": 0.5, 47 | "--tw-scale-y": 0.5, 48 | opacity: 0, 49 | } as React.CSSProperties, 50 | exited: {}, 51 | unmounted: {}, 52 | }; 53 | return ( 54 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/sidebar/SidebarDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { Transition } from "react-transition-group"; 3 | import { TransitionStyles } from "../util/TransitionStyles"; 4 | import MailDrawer from "./drawers/mail/MailDrawer"; 5 | import CalendarDrawer from "./drawers/calendar/CalendarDrawer"; 6 | import TodoDrawer from "./drawers/todo/TasksDrawer"; 7 | import SearchDrawer from "./drawers/search/SearchDrawer"; 8 | 9 | export default function SidebarDrawer(props: { 10 | show: boolean; 11 | close: () => void; 12 | drawer?: string; 13 | }) { 14 | const nodeRef = useRef(null); 15 | 16 | const transitionStyles: TransitionStyles = { 17 | entering: { 18 | opacity: 0.5, 19 | "--tw-translate-x": "-100%", 20 | transitionProperty: "none", 21 | } as React.CSSProperties, 22 | entered: { 23 | "--tw-translate-x": "0%", 24 | transitionProperty: "all", 25 | } as React.CSSProperties, 26 | exiting: { 27 | "--tw-translate-x": "-100%", 28 | transitionProperty: "all", 29 | opacity: 0.5, 30 | } as React.CSSProperties, 31 | exited: { 32 | display: "none", 33 | }, 34 | unmounted: {}, 35 | }; 36 | 37 | return ( 38 | 46 | {(state) => ( 47 |
54 | {props.drawer === "tasks" && } 55 | {props.drawer === "mail" && } 56 | {props.drawer === "calendar" && ( 57 | 58 | )} 59 | {props.drawer === "search" && } 60 |
61 | )} 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/sidebar/drawers/calendar/CalendarDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import BinaryCarousel from "../../../util/BinaryCarousel"; 3 | import Button from "../../../util/Button"; 4 | import DayPicker from "./DayPicker"; 5 | import axios from "redaxios"; 6 | import { useEffect } from "react"; 7 | import getAllPages from "../../../util/getAllPages"; 8 | import MonthView from "./MonthView"; 9 | import DayView from "./DayView"; 10 | 11 | export type CalendarEvent = { 12 | id: number; 13 | title: string; 14 | startAt?: Date; 15 | endAt?: Date; 16 | allDay: boolean; 17 | description?: string; 18 | locationName?: string; 19 | locationAddress?: string; 20 | workflowState?: "active" | "deleted" | "locked"; 21 | hidden?: boolean; 22 | htmlUrl?: string; 23 | color?: string; 24 | __firstThisHour?: boolean; 25 | __lastThisHour?: boolean; 26 | }; 27 | 28 | export default function CalendarDrawer(props: { close: () => void }) { 29 | const [date, setDate] = useState(new Date()); 30 | 31 | const [calendarDates, setCalendarDates] = useState<{ 32 | [date: string]: CalendarEvent[]; 33 | }>({}); 34 | 35 | const [startDate, setStartDate] = useState( 36 | new Date(new Date().getFullYear(), new Date().getMonth(), 1) 37 | ); 38 | 39 | const [endDate, setEndDate] = useState( 40 | new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0) 41 | ); 42 | 43 | useEffect(() => { 44 | axios 45 | .get("/api/v1/courses?enrollment_state=active&state=available") 46 | .then((res) => { 47 | const courseIds = res.data.map( 48 | (course: any) => "&context_codes[]=course_" + course.id 49 | ); 50 | 51 | axios.get("/api/v1/users/self").then((res) => { 52 | const userId = res.data.id; 53 | 54 | axios.get("/api/v1/users/self/colors").then((res) => { 55 | const colors = res.data.custom_colors; 56 | 57 | const baseUrl = `/api/v1/calendar_events?context_codes[]=user_${userId}${ 58 | courseIds.length > 0 ? courseIds.join("") : "" 59 | }&start_date=${startDate.toISOString()}&end_date=${endDate.toISOString()}&type=event`; 60 | 61 | const baseAssignmentsUrl = `/api/v1/calendar_events?context_codes[]=user_${userId}${ 62 | courseIds.length > 0 ? courseIds.join("") : "" 63 | }&start_date=${startDate.toISOString()}&end_date=${endDate.toISOString()}&type=assignment`; 64 | 65 | const events = getAllPages(baseUrl); 66 | 67 | const assignents = getAllPages(baseAssignmentsUrl); 68 | 69 | Promise.all([events, assignents]).then((res) => { 70 | const allEvents = res[0] 71 | .concat(res[1]) 72 | .map((e: any): CalendarEvent => { 73 | return { 74 | id: e.id, 75 | title: e.title, 76 | description: e.description, 77 | endAt: new Date(e.end_at), 78 | startAt: new Date(e.start_at), 79 | locationName: e.location_name, 80 | locationAddress: e.location_address, 81 | workflowState: e.workflow_state, 82 | hidden: e.hidden, 83 | htmlUrl: e.html_url, 84 | color: colors[e.context_code] || "#5A92DE", 85 | allDay: e.all_day || false, 86 | }; 87 | }); 88 | 89 | const dates: { [date: string]: CalendarEvent[] } = { 90 | ...calendarDates, 91 | }; 92 | 93 | const dontFillDates = Object.keys(dates); 94 | 95 | allEvents.forEach((event: CalendarEvent) => { 96 | const date = new Date(event.startAt!); 97 | const dateString = `${date.getFullYear()}-${ 98 | date.getMonth() + 1 99 | }-${date.getDate()}`; 100 | 101 | if (dontFillDates.includes(dateString)) return; 102 | 103 | if (!dates[dateString]) dates[dateString] = []; 104 | dates[dateString].push(event); 105 | }); 106 | 107 | setCalendarDates(dates); 108 | }); 109 | }); 110 | }); 111 | }); 112 | }, [startDate, endDate]); 113 | 114 | const [monthViewMonth, setMonthViewMonth] = useState(new Date().getMonth()); 115 | 116 | const [monthViewYear, setMonthViewYear] = useState(new Date().getFullYear()); 117 | 118 | useEffect(() => { 119 | setStartDate(new Date(monthViewYear, monthViewMonth, 1, 0, 0, 0, 0)); 120 | 121 | setEndDate(new Date(monthViewYear, monthViewMonth + 1, 0, 0, 0, 0, 0)); 122 | }, [monthViewMonth, monthViewYear]); 123 | 124 | return ( 125 |
126 |
127 |
128 |
129 |

Calendar

130 |
131 | 134 | 137 |
138 |
139 |
140 |
141 | { 144 | setDate(new Date(date.getTime() - 86400000)); 145 | }} 146 | onClickRight={() => { 147 | setDate(new Date(date.getTime() + 86400000)); 148 | }} 149 | /> 150 | 151 | 159 | 160 | { 162 | if (monthViewMonth === 0) { 163 | setMonthViewYear(monthViewYear - 1); 164 | setMonthViewMonth(11); 165 | } else { 166 | setMonthViewMonth(monthViewMonth - 1); 167 | } 168 | }} 169 | onClickRight={() => { 170 | if (monthViewMonth === 11) { 171 | setMonthViewYear(monthViewYear + 1); 172 | setMonthViewMonth(0); 173 | } else { 174 | setMonthViewMonth(monthViewMonth + 1); 175 | } 176 | }} 177 | label={ 178 | monthViewYear === new Date().getFullYear() 179 | ? new Date(monthViewYear, monthViewMonth).toLocaleString( 180 | "default", 181 | { month: "long" } 182 | ) 183 | : new Date(monthViewYear, monthViewMonth).toLocaleString( 184 | "default", 185 | { month: "long", year: "numeric" } 186 | ) 187 | } 188 | /> 189 | 190 | 198 |
199 |
200 | ); 201 | } 202 | -------------------------------------------------------------------------------- /src/sidebar/drawers/calendar/CalendarEventDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CalendarEvent } from "./CalendarDrawer"; 3 | import Color from "color"; 4 | 5 | export default function CalendarEventDisplay(props: { 6 | events: (CalendarEvent | null)[]; 7 | child: boolean; 8 | }) { 9 | if (props.events.length === 0) return null; 10 | 11 | const event = props.events[0]; 12 | 13 | if (event == null) { 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 | ); 21 | } 22 | return ( 23 |
30 |
41 | {event && event.__firstThisHour && ( 42 |
48 |

49 | {event.startAt?.toLocaleTimeString([], { 50 | hour: "numeric", 51 | minute: "numeric", 52 | })}{" "} 53 |

54 |

{event.title}

55 |
56 | )} 57 |
58 | 59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/sidebar/drawers/calendar/DayPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FiChevronLeft, FiChevronRight } from "react-icons/fi"; 3 | 4 | export default function DayPicker(props: { 5 | onClickLeft: () => void; 6 | onClickRight: () => void; 7 | label: string; 8 | }) { 9 | return ( 10 |
11 |
{ 14 | props.onClickLeft(); 15 | e.preventDefault(); 16 | }} 17 | > 18 | 19 |
20 | 21 |
22 |

{props.label}

23 |
24 | 25 |
{ 28 | e.preventDefault(); 29 | props.onClickRight(); 30 | }} 31 | > 32 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/sidebar/drawers/calendar/DayView.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useEffect, useState } from "react"; 2 | import { CalendarEvent } from "./CalendarDrawer"; 3 | import CalendarEventDisplay from "./CalendarEventDisplay"; 4 | 5 | export default function DayView(props: { 6 | date: Date; 7 | events: CalendarEvent[]; 8 | }) { 9 | const { date, events } = props; 10 | const numEvents = events.length; 11 | 12 | const [earliestTime, setEarliestTime] = useState( 13 | new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59) 14 | ); 15 | 16 | const [latestTime, setLatestTime] = useState( 17 | new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0) 18 | ); 19 | 20 | const firstHour = earliestTime.getHours(); 21 | const lastHour = latestTime.getHours(); 22 | 23 | const [allDay, setAllDay] = useState([]); 24 | 25 | useEffect(() => { 26 | const EARLIEST = new Date( 27 | date.getFullYear(), 28 | date.getMonth(), 29 | date.getDate(), 30 | 23, 31 | 59, 32 | 59 33 | ); 34 | const LATEST = new Date( 35 | date.getFullYear(), 36 | date.getMonth(), 37 | date.getDate(), 38 | 0, 39 | 0, 40 | 0 41 | ); 42 | 43 | events.forEach((event) => { 44 | if (event.allDay) return; 45 | if (event.endAt && EARLIEST.getTime() > event.endAt.getTime()) { 46 | EARLIEST.setTime(event.endAt.getTime()); 47 | } 48 | if (event.startAt && EARLIEST.getTime() > event.startAt.getTime()) { 49 | EARLIEST.setTime(event.startAt.getTime()); 50 | } 51 | 52 | if (event.startAt && LATEST.getTime() < event.startAt.getTime()) { 53 | LATEST.setTime(event.startAt.getTime()); 54 | } 55 | if (event.endAt && LATEST.getTime() < event.endAt.getTime()) { 56 | LATEST.setTime(event.endAt.getTime()); 57 | } 58 | }); 59 | 60 | setEarliestTime(EARLIEST); 61 | setLatestTime(LATEST); 62 | 63 | const allDayEvents = events.filter((event) => event.allDay); 64 | 65 | setAllDay(allDayEvents); 66 | }, [events, date]); 67 | 68 | return ( 69 |
70 | {numEvents > 0 ? ( 71 |
72 |
73 | {(() => { 74 | const stack: number[] = []; 75 | 76 | return new Array(Math.max(lastHour - firstHour + 1, 0)) 77 | .fill(0) 78 | .map((_, i) => { 79 | const hour = firstHour + i; 80 | 81 | const eventsInHour = events.filter((event) => { 82 | if (event.allDay) return false; 83 | if ( 84 | event.startAt && 85 | event.startAt.getHours() <= hour && 86 | event.endAt && 87 | event.endAt.getHours() >= hour 88 | ) 89 | return true; 90 | return false; 91 | }); 92 | 93 | const toAdd: number[] = []; 94 | 95 | eventsInHour.forEach((event) => { 96 | if (stack.includes(event.id)) return; 97 | toAdd.push(event.id); 98 | }); 99 | 100 | stack.push(...toAdd); 101 | 102 | const tempStack = [...stack]; 103 | 104 | const eventsStartingThisHour = eventsInHour 105 | .filter( 106 | (event) => 107 | event.startAt && event.startAt.getHours() === hour 108 | ) 109 | .map((event) => event.id); 110 | 111 | const eventsEndingThisHour = eventsInHour 112 | .filter( 113 | (event) => event.endAt && event.endAt.getHours() === hour 114 | ) 115 | .map((event) => event.id); 116 | 117 | const allEventsFirst = 118 | eventsStartingThisHour.length === eventsInHour.length; 119 | const allEventsLast = 120 | eventsEndingThisHour.length === eventsInHour.length; 121 | 122 | eventsEndingThisHour.forEach((eventId) => { 123 | const index = stack.indexOf(eventId); 124 | 125 | if (index !== -1) stack[index] = -1; 126 | }); 127 | 128 | while (stack[stack.length - 1] === -1) { 129 | stack.pop(); 130 | } 131 | 132 | if (eventsInHour.length === 0) return <>; 133 | 134 | return ( 135 |
140 | {/*

141 | {"" + 142 | (((((hour - 1) % 12) + 12) % 12) + 1) + 143 | (hour >= 12 ? "p" : "a")} 144 |

*/} 145 | { 148 | const event = events.find((event) => event.id === id); 149 | 150 | if (!event) return null; 151 | 152 | return { 153 | ...event, 154 | __firstThisHour: 155 | eventsStartingThisHour.includes(id), 156 | __lastThisHour: eventsEndingThisHour.includes(id), 157 | }; 158 | })} 159 | /> 160 | {/* {tempStack.map((id) => { 161 | return ( 162 |
163 |

164 | {events.find((event) => event.id === id)?.title} 165 |

166 |
167 | ); 168 | })} */} 169 |
170 | ); 171 | }); 172 | })()} 173 |
174 |
175 | {allDay.map((event) => { 176 | return ( 177 | 187 | ); 188 | })} 189 |
190 |
191 |
192 |
193 | ) : ( 194 |
195 |

No events

196 |
197 | )} 198 |
199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /src/sidebar/drawers/calendar/MonthView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from "react"; 2 | import { FiChevronLeft, FiChevronRight } from "react-icons/fi"; 3 | import { CalendarEvent } from "./CalendarDrawer"; 4 | import { TransitionStyles } from "../../../util/TransitionStyles"; 5 | import { Transition } from "react-transition-group"; 6 | import color from "color"; 7 | 8 | function numDaysInPreviousMonth(month: number, year: number) { 9 | if (month === 0) return numDaysInMonth(11, year - 1); 10 | return numDaysInMonth(month - 1, year); 11 | } 12 | 13 | function numDaysInMonth(month: number, year: number) { 14 | if (month === 0) return 31; 15 | if (month === 1) { 16 | if (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) return 29; 17 | else return 28; 18 | } 19 | if (month === 2) return 31; 20 | if (month === 3) return 30; 21 | if (month === 4) return 31; 22 | if (month === 5) return 30; 23 | if (month === 6) return 31; 24 | if (month === 7) return 31; 25 | if (month === 8) return 30; 26 | if (month === 9) return 31; 27 | if (month === 10) return 30; 28 | if (month === 11) return 31; 29 | 30 | return 0; 31 | } 32 | 33 | function getDay( 34 | YEAR: number, 35 | MONTH: number, 36 | x: number, 37 | y: number 38 | ): { 39 | day: number; 40 | inMonth: boolean; 41 | month: number; 42 | year: number; 43 | } { 44 | // x day of week in yth week of month, year 45 | const firstDay = new Date(YEAR, MONTH, 1); 46 | 47 | const firstDayWeek = firstDay.getDay(); 48 | 49 | const day = y * 7 + x - firstDayWeek + 1; 50 | 51 | if (day <= 0) 52 | return { 53 | day: numDaysInPreviousMonth(MONTH, YEAR) + day, 54 | inMonth: false, 55 | month: (MONTH - 1) % 12, 56 | year: MONTH - 1 < 0 ? YEAR - 1 : YEAR, 57 | }; 58 | if (numDaysInMonth(MONTH, YEAR) < day) { 59 | return { 60 | day: day - numDaysInMonth(MONTH, YEAR), 61 | inMonth: false, 62 | month: (MONTH + 1) % 12, 63 | year: MONTH + 1 > 11 ? YEAR + 1 : YEAR, 64 | }; 65 | } 66 | 67 | return { 68 | day, 69 | inMonth: true, 70 | month: MONTH, 71 | year: YEAR, 72 | }; 73 | } 74 | 75 | function getAverageColor(calendarDates: CalendarEvent[]) { 76 | let r = 0; 77 | let g = 0; 78 | let b = 0; 79 | 80 | for (const event of calendarDates) { 81 | const eventColor = event.color; 82 | 83 | r += color(eventColor).red() ** 2; 84 | g += color(eventColor).green() ** 2; 85 | b += color(eventColor).blue() ** 2; 86 | } 87 | 88 | r = Math.sqrt(r / calendarDates.length); 89 | 90 | g = Math.sqrt(g / calendarDates.length); 91 | 92 | b = Math.sqrt(b / calendarDates.length); 93 | 94 | return `rgb(${r}, ${g}, ${b})`; 95 | } 96 | 97 | export default function MonthView(props: { 98 | month: number; 99 | year: number; 100 | calendarDates: Record; 101 | selectedDate: string | undefined; 102 | }) { 103 | const numberWeekLines = useMemo(() => { 104 | const firstDay = new Date(props.year, props.month, 1); 105 | const lastDay = new Date(props.year, props.month + 1, 0); 106 | const firstDayWeek = firstDay.getDay(); 107 | const days = lastDay.getDate(); 108 | const weeks = Math.ceil((days + firstDayWeek) / 7); 109 | return weeks; 110 | }, [props.month, props.year]); 111 | 112 | const [hoveredDate, setHoveredDate] = useState(undefined); 113 | const [hoveredDateDisplay, setHoveredDateDisplay] = useState< 114 | string | undefined 115 | >(undefined); 116 | 117 | const [showTooltip, setShowTooltip] = useState(false); 118 | 119 | useEffect(() => { 120 | if (hoveredDate !== undefined) { 121 | setShowTooltip(true); 122 | setHoveredDateDisplay(hoveredDate); 123 | } else { 124 | const timeout = setTimeout(() => { 125 | setShowTooltip(false); 126 | }, 400); 127 | 128 | const timeout2 = setTimeout(() => { 129 | setHoveredDateDisplay(undefined); 130 | }, 500); 131 | 132 | return () => { 133 | clearTimeout(timeout); 134 | clearTimeout(timeout2); 135 | }; 136 | } 137 | }, [hoveredDate]); 138 | 139 | const tooltipRef = React.useRef(null); 140 | 141 | const tooltipStyles: TransitionStyles = { 142 | entering: { 143 | opacity: 0, 144 | "--tw-scale-x": 0.5, 145 | "--tw-scale-y": 0.5, 146 | } as React.CSSProperties, 147 | entered: { 148 | transitionProperty: "transform, opacity", 149 | }, 150 | exiting: { 151 | transitionProperty: "transform, opacity", 152 | "--tw-scale-x": 0.5, 153 | "--tw-scale-y": 0.5, 154 | opacity: 0, 155 | } as React.CSSProperties, 156 | exited: { 157 | opacity: 0, 158 | }, 159 | unmounted: {}, 160 | }; 161 | 162 | const today = new Date(); 163 | 164 | return ( 165 |
166 | 172 | {(state) => ( 173 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |

185 | {hoveredDateDisplay && 186 | new Date(hoveredDateDisplay).toLocaleDateString("en-US", { 187 | weekday: "long", 188 | month: "long", 189 | day: "numeric", 190 | })} 191 |

192 |
193 |
194 | {hoveredDateDisplay && 195 | props.calendarDates?.[hoveredDateDisplay]?.length > 0 ? ( 196 |
197 | {props.calendarDates?.[hoveredDateDisplay]?.map( 198 | (event) => { 199 | return ( 200 |
201 |
207 |

208 | {event.title} 209 |

210 |
211 | ); 212 | } 213 | )} 214 |
215 | ) : ( 216 |
217 |

No events

218 |
219 | )} 220 |
221 | 222 | {hoveredDateDisplay && 223 | props.calendarDates?.[hoveredDateDisplay]?.length > 0 && ( 224 |
225 |

Click for more details

226 |
227 | )} 228 |
229 |
230 |
231 | )} 232 | 233 | {new Array(numberWeekLines).fill(0).map((_, y) => { 234 | return ( 235 |
236 | {new Array(7).fill(0).map((_, x) => { 237 | const dayResponse = getDay(props.year, props.month, x, y); 238 | 239 | const day = dayResponse.day; 240 | const inMonth = dayResponse.inMonth; 241 | const month = dayResponse.month; 242 | const year = dayResponse.year; 243 | 244 | const dateAsString = `${year}-${month + 1}-${day}`; 245 | 246 | const events = props.calendarDates?.[dateAsString]; 247 | 248 | const numEvents = events?.length ?? 0; 249 | 250 | const today = new Date(); 251 | const todayAsString = `${today.getFullYear()}-${ 252 | today.getMonth() + 1 253 | }-${today.getDate()}`; 254 | 255 | const isToday = dateAsString === todayAsString; 256 | const isSelected = props.selectedDate === dateAsString; 257 | 258 | return ( 259 |
{ 262 | setHoveredDate(dateAsString); 263 | }} 264 | onMouseLeave={() => { 265 | setHoveredDate(undefined); 266 | }} 267 | > 268 | {isSelected && ( 269 | 274 | )} 275 | 289 | 290 | 301 | {day} 302 | 303 |
304 | ); 305 | })} 306 |
307 | ); 308 | })} 309 |
310 | ); 311 | } 312 | -------------------------------------------------------------------------------- /src/sidebar/drawers/mail/ConversationPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | 3 | export type Conversation = { 4 | id: number; 5 | subject: string; 6 | state: "unread" | "read" | "archived"; 7 | lastMessage?: string; 8 | lastMessageAt?: string; 9 | messageCount: number; 10 | participants: { 11 | id: number; 12 | name: string; 13 | }[]; 14 | messages: { 15 | id: number; 16 | authorId: number; 17 | createdAt: string; 18 | body: string; 19 | }[]; 20 | }; 21 | export default function ConversationPreview(props: { 22 | conversation: Conversation; 23 | onClick: () => void; 24 | }) { 25 | const lastUpdated = useMemo(() => { 26 | if (props.conversation.lastMessageAt == null) { 27 | return "No updates"; 28 | } else { 29 | const date = new Date(props.conversation.lastMessageAt); 30 | 31 | // if today, return time 32 | if (date.toDateString() === new Date().toDateString()) { 33 | return date.toLocaleTimeString(); 34 | } 35 | 36 | // if yesterday, return yesterday 37 | if ( 38 | date.toDateString() === 39 | new Date(new Date().getTime() - 86400000).toDateString() 40 | ) { 41 | return "Yesterday"; 42 | } 43 | 44 | if (date.getFullYear() === new Date().getFullYear()) { 45 | return date.toLocaleDateString(undefined, { 46 | month: "long", 47 | day: "numeric", 48 | }); 49 | } 50 | 51 | return date.toLocaleDateString(undefined, { 52 | year: "numeric", 53 | month: "short", 54 | day: "numeric", 55 | }); 56 | } 57 | }, [props.conversation.lastMessageAt]); 58 | 59 | return ( 60 |
64 |
65 |

66 | {props.conversation.participants[0]?.name ?? "No participants"} 67 |

68 |

{lastUpdated}

69 |
70 |

{props.conversation.subject}

71 |

72 | {props.conversation.lastMessage} 73 |

74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/sidebar/drawers/mail/ConversationView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Conversation } from "./ConversationPreview"; 3 | import axios from "redaxios"; 4 | import { FiChevronLeft, FiCornerDownRight } from "react-icons/fi"; 5 | import Button from "../../../util/Button"; 6 | 7 | function ConversationViewSection(props: { 8 | title: string; 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 |
13 |

{props.title}

14 | {props.children} 15 |
16 | ); 17 | } 18 | export default function ConversationView(props: { 19 | conversation: Conversation; 20 | onReturn: () => void; 21 | }) { 22 | const [conversation, setConversation] = useState( 23 | undefined 24 | ); 25 | 26 | useEffect(() => { 27 | if (props.conversation == null) return; 28 | 29 | setConversation(undefined); 30 | 31 | axios.get(`/api/v1/conversations/${props.conversation.id}`).then((res) => { 32 | const data = res.data; 33 | 34 | setConversation({ 35 | id: data.id, 36 | subject: data.subject, 37 | messageCount: data.message_count, 38 | lastMessage: data.last_message, 39 | lastMessageAt: data.last_message_at, 40 | state: data.workflow_state, 41 | participants: data.participants.map((participant: any) => { 42 | return { 43 | id: participant.id, 44 | name: participant.name, 45 | }; 46 | }), 47 | messages: data.messages.map( 48 | (message: any): Conversation["messages"][0] => { 49 | return { 50 | body: message.body, 51 | authorId: message.author_id, 52 | createdAt: message.created_at, 53 | id: message.id, 54 | }; 55 | } 56 | ), 57 | }); 58 | }); 59 | }, [props.conversation.id]); 60 | 61 | const [showExpandedParticipants, setShowExpandedParticipants] = 62 | useState(false); 63 | 64 | return ( 65 |
66 | {conversation != null ? ( 67 | <> 68 | 69 | {showExpandedParticipants ? ( 70 |
71 |

72 | {conversation.participants 73 | .map((participant) => participant.name) 74 | .join(", ")} 75 |

76 |

setShowExpandedParticipants(false)} 79 | > 80 | Show less 81 |

82 |
83 | ) : ( 84 |
85 | {conversation.participants 86 | .slice(0, 2) 87 | .map((participant) => participant.name) 88 | .join(", ")} 89 | {conversation.participants.length > 2 && ( 90 |

setShowExpandedParticipants(true)} 92 | className="ml-2 text-rose-500 cursor-pointer inline-block" 93 | > 94 | + {conversation.participants.length - 2} 95 |

96 | )} 97 |
98 | )} 99 |
100 | 107 |

{conversation.subject}

108 |
109 |
110 | {conversation.messages.map((message) => ( 111 | participant.id === message.authorId 115 | )?.name ?? "Unknown" 116 | } 117 | > 118 |

119 | {new Date(message.createdAt).toLocaleString(undefined, { 120 | year: "numeric", 121 | month: "long", 122 | day: "numeric", 123 | hour: "numeric", 124 | minute: "numeric", 125 | })} 126 |

127 |
128 |
129 | {message.body.split("\n").map((line) => ( 130 |
131 | {line.trim().length >= 1 ?

{line}

:
} 132 |
133 | ))} 134 |
135 |
136 | ))} 137 |
138 | 139 | ) : ( 140 |

Loading...

141 | )} 142 |
143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /src/sidebar/drawers/mail/MailDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import axios from "redaxios"; 3 | import ConversationPreview, { Conversation } from "./ConversationPreview"; 4 | import { Transition } from "react-transition-group"; 5 | import { TransitionStyles } from "../../../util/TransitionStyles"; 6 | import BinaryCarousel from "../../../util/BinaryCarousel"; 7 | import ConversationView from "./ConversationView"; 8 | import Button from "../../../util/Button"; 9 | 10 | export default function MailDrawer(props: { close: () => void }) { 11 | const [conversations, setConversations] = useState([]); 12 | 13 | useEffect(() => { 14 | axios.get("/api/v1/conversations").then((res) => { 15 | const data = res.data; 16 | 17 | setConversations( 18 | data.map((conversation: any): Conversation => { 19 | return { 20 | id: conversation.id, 21 | subject: conversation.subject, 22 | state: conversation.state, 23 | lastMessage: conversation.last_message, 24 | lastMessageAt: conversation.last_message_at, 25 | messageCount: conversation.message_count, 26 | participants: conversation.participants.map((participant: any) => { 27 | return { 28 | id: participant.id, 29 | name: participant.name, 30 | }; 31 | }), 32 | messages: [], 33 | }; 34 | }) 35 | ); 36 | }); 37 | }, []); 38 | 39 | const [currentConversation, setCurrentConversation] = useState< 40 | Conversation | undefined 41 | >(undefined); 42 | 43 | return ( 44 |
45 |
46 |
47 | 48 |
49 |

Inbox

50 |
51 | 54 | 57 |
58 |
59 |
60 |

Conversation

61 |
62 | 69 | 76 | 83 |
84 |
85 |
86 |
87 | 88 |
89 | {conversations.map((conversation) => ( 90 | setCurrentConversation(conversation)} 93 | /> 94 | ))} 95 |
96 |
97 | {currentConversation != null && ( 98 | setCurrentConversation(undefined)} 101 | /> 102 | )} 103 |
104 |
105 | 106 | {/* 114 | {(state) => ( 115 |
120 | {conversations.map((conversation) => ( 121 | 122 | ))} 123 |
124 | )} 125 |
126 | 127 | 135 | {(state) => ( 136 |
141 | {conversations.map((conversation) => ( 142 | 143 | ))} 144 |
145 | )} 146 |
*/} 147 |
148 |
149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/sidebar/drawers/search/SearchDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import Button from "../../../util/Button"; 3 | import SegmentedControl from "../../../util/SegmentedControl"; 4 | import BinaryCarousel from "../../../util/BinaryCarousel"; 5 | import Finder from "./SearchView"; 6 | import { indexSearch, search } from "../../../util/search"; 7 | import { SearchItem } from "../../../util/coreResources"; 8 | import SearchResultGroup from "./SearchItemGroup"; 9 | import SearchResultDisplay from "./SearchItemDisplay"; 10 | import SearchResults from "./SearchResults"; 11 | 12 | export default function SearchDrawer(props: { close: () => void }) { 13 | const [searchQuery, setSearchQuery] = useState(""); 14 | 15 | const [searchResults, setSearchResults] = useState([]); 16 | 17 | useEffect(() => { 18 | search(searchQuery, 5).then((res) => { 19 | setSearchResults(res); 20 | }); 21 | }, [searchQuery]); 22 | 23 | return ( 24 |
25 |
26 |
27 |
28 |
29 |

Search

30 |
31 | 34 |
35 |
36 | { 42 | setSearchQuery((e.target as HTMLInputElement).value); 43 | }} 44 | /> 45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/sidebar/drawers/search/SearchItemDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function SearchItemDisplay(props: { 4 | title: string; 5 | description: string; 6 | url: string; 7 | small: boolean; 8 | highlightIndex: number; 9 | index: number; 10 | }) { 11 | return ( 12 |
19 |

24 | {props.title} 25 |

26 |

33 | {props.description} 34 |

35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/sidebar/drawers/search/SearchItemGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function SearchItemGroup(props: { 4 | children: React.ReactNode; 5 | label: string; 6 | }) { 7 | return ( 8 |
9 |

{props.label}

10 | 11 |
12 | {props.children} 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/sidebar/drawers/search/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { SearchItem } from "../../../util/coreResources"; 3 | import SearchItemGroup from "./SearchItemGroup"; 4 | import SearchItemDisplay from "./SearchItemDisplay"; 5 | 6 | export default function SearchResults(props: { 7 | results: SearchItem[]; 8 | searchQuery: string; 9 | }) { 10 | const [highlightIndex, setHighlightIndex] = useState(0); 11 | 12 | useEffect(() => { 13 | setHighlightIndex(0); 14 | }, [props.searchQuery]); 15 | 16 | useEffect(() => { 17 | const length = props.results.length; 18 | 19 | const handleKeyDown = (e: KeyboardEvent) => { 20 | if (e.key === "ArrowDown") { 21 | setHighlightIndex((prev) => (prev + 1) % length); 22 | e.preventDefault(); 23 | } else if (e.key === "ArrowUp") { 24 | setHighlightIndex((prev) => (prev - 1 + length) % length); 25 | e.preventDefault(); 26 | } 27 | }; 28 | 29 | window.addEventListener("keydown", handleKeyDown); 30 | return () => { 31 | window.removeEventListener("keydown", handleKeyDown); 32 | }; 33 | }, [props.results.length]); 34 | 35 | return ( 36 |
37 | {props.results.length > 0 && ( 38 |
39 | 40 | {props.results.map((item, idx) => { 41 | return ( 42 | 50 | ); 51 | })} 52 | 53 |
54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/sidebar/drawers/todo/Planner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { PlannerItem, TodoItem } from "./TasksDrawer"; 3 | 4 | export default function Planner(props: { planned: PlannerItem[] }) { 5 | const days = useMemo(() => { 6 | const days: { [key: string]: PlannerItem[] } = {}; 7 | 8 | props.planned.forEach((item) => { 9 | const date = item.date.toISOString().split("T")[0]; 10 | 11 | if (!days[date]) { 12 | days[date] = []; 13 | } 14 | 15 | days[date].push(item); 16 | }); 17 | 18 | return days; 19 | }, [props.planned]); 20 | 21 | return ( 22 |
23 | {Object.keys(days).map((dateString) => { 24 | const date = new Date(dateString); 25 | const planned = days[dateString]; 26 | 27 | const contexts: { [key: string]: PlannerItem[] } = {}; 28 | 29 | planned.forEach((item) => { 30 | if (!contexts[item.context.code]) { 31 | contexts[item.context.code] = []; 32 | } 33 | 34 | contexts[item.context.code].push(item); 35 | }); 36 | 37 | return ( 38 |
39 |
40 | 41 |
42 |
43 |

44 | {new Date().getMonth() === date.getMonth() && 45 | new Date().getFullYear() === date.getFullYear() 46 | ? date.toLocaleDateString(undefined, { 47 | weekday: "short", 48 | }) 49 | : date.toLocaleDateString(undefined, { 50 | month: "short", 51 | })} 52 |

53 |

54 | {date.toLocaleDateString(undefined, { 55 | day: "numeric", 56 | })} 57 |

58 |
59 |
60 | 61 |
62 | {Object.values(contexts).map((items) => { 63 | if (items.length === 0) return null; 64 | 65 | return ( 66 |
67 |
73 |
74 |
75 |

81 | {items[0].context.displayName} 82 |

83 |
84 | {items.map((item) => { 85 | return ( 86 |
87 |
88 |

89 | {item.name} 90 |

91 |
92 | ); 93 | })} 94 |
95 |
96 | ); 97 | })} 98 |
99 |
100 | ); 101 | })} 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/sidebar/drawers/todo/PlannerHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from "react"; 2 | import TodoChart from "./TodoHeader"; 3 | import TodoCategories from "./TodoCategories"; 4 | import { 5 | getColors, 6 | getCourses, 7 | getGroups, 8 | getTodoItems, 9 | } from "../../../util/coreResources"; 10 | import { PlannerItem } from "./TasksDrawer"; 11 | 12 | export default function PlannerHeader(props: { items: PlannerItem[] }) { 13 | const itemCount = useMemo(() => { 14 | return props.items.length; 15 | }, [props.items]); 16 | 17 | const contextMap = useMemo(() => { 18 | interface ContextData { 19 | color: string; 20 | count: number; 21 | upcomingCount: number; 22 | } 23 | 24 | const map = new Map(); 25 | 26 | props.items.forEach((item) => { 27 | const DAYS_7 = 7 * 24 * 60 * 60 * 1000; 28 | 29 | const isUpcoming = 30 | new Date(item.date).getTime() - new Date().getTime() < DAYS_7; 31 | 32 | if (map.has(item.context.code)) { 33 | const prevContext = map.get(item.context.code)!; 34 | 35 | map.set(item.context.code, { 36 | ...prevContext, 37 | count: prevContext.count + 1, 38 | upcomingCount: isUpcoming 39 | ? prevContext.upcomingCount + 1 40 | : prevContext.upcomingCount, 41 | }); 42 | } else { 43 | map.set(item.context.code, { 44 | color: item.context.color, 45 | count: 1, 46 | upcomingCount: isUpcoming ? 1 : 0, 47 | }); 48 | } 49 | }); 50 | 51 | return map; 52 | }, [props.items]); 53 | 54 | const totalUpcoming = useMemo(() => { 55 | return Array.from(contextMap.values()).reduce((acc, curr) => { 56 | return acc + curr.upcomingCount; 57 | }, 0); 58 | }, [contextMap]); 59 | 60 | const linearGradient = useMemo(() => { 61 | const gradient: any[] = []; 62 | 63 | contextMap.forEach((contextInfo, contextName) => { 64 | const percent = 65 | totalUpcoming > 0 ? contextInfo.upcomingCount / totalUpcoming : 0; 66 | gradient.push({ 67 | color: contextInfo.color, 68 | percent, 69 | name: contextName, 70 | }); 71 | }); 72 | 73 | return gradient.sort((a, b) => b.percent - a.percent); 74 | }, [contextMap, itemCount]); 75 | 76 | return ( 77 |
78 |
79 |

80 | {totalUpcoming} 81 | {totalUpcoming > 99 ? "+" : ""} 82 |

83 |

Upcoming This Week

84 |
85 |
86 | {linearGradient.map((context) => { 87 | return ( 88 |
95 | ); 96 | })} 97 |
98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/sidebar/drawers/todo/TasksDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import BinaryCarousel from "../../../util/BinaryCarousel"; 3 | import Button from "../../../util/Button"; 4 | import TodoCategories from "./TodoCategories"; 5 | import { 6 | getColors, 7 | getCourses, 8 | getGroups, 9 | getTodoItems, 10 | getPlannedItems, 11 | } from "../../../util/coreResources"; 12 | import SegmentedControl from "../../../util/SegmentedControl"; 13 | import TodoHeader from "./TodoHeader"; 14 | import PlannerHeader from "./PlannerHeader"; 15 | import Planner from "./Planner"; 16 | 17 | export type TodoItem = { 18 | type: "GRADING" | "SUBMITTING" | "OTHER"; 19 | assignment: any; 20 | ignoreUrl: string; 21 | ignorePermanentlyUrl: string; 22 | htmlUrl: string; 23 | context: { 24 | type: "COURSE" | "GROUP"; 25 | id: number; 26 | color: string; 27 | code: string; 28 | displayName: string; 29 | }; 30 | }; 31 | 32 | export type PlannerItem = { 33 | context: { 34 | type: "COURSE" | "GROUP"; 35 | id: number; 36 | color: string; 37 | code: string; 38 | displayName: string; 39 | }; 40 | name: string; 41 | type: "DISCUSSION" | "ASSIGNMENT" | "TODO"; 42 | date: Date; 43 | id: number; 44 | completed: boolean; 45 | }; 46 | 47 | export default function TasksDrawer(props: { close: () => void }) { 48 | const [view, setView] = useState("todo"); 49 | const [todo, setTodo] = useState([]); 50 | const [planned, setPlanned] = useState([]); 51 | 52 | useEffect(() => { 53 | const todoPromise = getTodoItems(); 54 | const plannedItemsPromise = getPlannedItems(); 55 | const colorsPromise = getColors(); 56 | const coursesPromise = getCourses(); 57 | const groupsPromise = getGroups(); 58 | 59 | Promise.all([ 60 | todoPromise, 61 | colorsPromise, 62 | coursesPromise, 63 | groupsPromise, 64 | plannedItemsPromise, 65 | ]).then((values) => { 66 | const todoRaw = values[0]; 67 | const colorsRaw = values[1].custom_colors; 68 | const coursesRaw = values[2]; 69 | const groupsRaw = values[3]; 70 | const plannedItemsRaw = values[4]; 71 | 72 | setPlanned( 73 | plannedItemsRaw.map((event: any): PlannerItem => { 74 | const contextId = 75 | event.context_type === "Course" ? event.course_id : event.group_id; 76 | 77 | const contextCode = 78 | (event.context_type as string).toLowerCase() + "_" + contextId; 79 | 80 | const contextDisplayName = 81 | event.context_type === "Course" 82 | ? coursesRaw.find((course: any) => course.id === contextId)?.name 83 | : groupsRaw.find((group: any) => group.id === contextId)?.name; 84 | 85 | return { 86 | date: new Date(event.plannable_date), 87 | name: event.plannable.title, 88 | context: { 89 | code: contextCode, 90 | id: contextId, 91 | displayName: contextDisplayName, 92 | color: colorsRaw[contextCode] || "#5A92DE", 93 | type: (event.context_type as string).toUpperCase() as 94 | | "COURSE" 95 | | "GROUP", 96 | }, 97 | type: 98 | event.plannable_type === "discussion_topic" 99 | ? "DISCUSSION" 100 | : event.plannable_type === "assignment" 101 | ? "ASSIGNMENT" 102 | : "TODO", 103 | id: event.plannable_id, 104 | completed: event?.plannable_override?.marked_complete || false, 105 | }; 106 | }) 107 | ); 108 | 109 | setTodo( 110 | todoRaw.map((todo: any): TodoItem => { 111 | const contextId = 112 | todo.context_type === "Course" ? todo.course_id : todo.group_id; 113 | const contextCode = 114 | (todo.context_type as string).toLowerCase() + "_" + contextId; 115 | 116 | const contextDisplayName = 117 | todo.context_type === "Course" 118 | ? coursesRaw.find((course: any) => course.id === contextId)?.name 119 | : groupsRaw.find((group: any) => group.id === contextId)?.name; 120 | 121 | return { 122 | type: 123 | todo.type === "grading" 124 | ? "GRADING" 125 | : todo.type === "submitting" 126 | ? "SUBMITTING" 127 | : "OTHER", 128 | assignment: todo.assignment, 129 | ignoreUrl: todo.ignore_url, 130 | ignorePermanentlyUrl: todo.ignore_permanently_url, 131 | htmlUrl: todo.html_url, 132 | context: { 133 | type: (todo.context_type as string).toUpperCase() as 134 | | "COURSE" 135 | | "GROUP", 136 | id: contextId, 137 | color: colorsRaw[contextCode], 138 | code: contextCode, 139 | displayName: contextDisplayName, 140 | }, 141 | }; 142 | }) 143 | ); 144 | }); 145 | }, []); 146 | 147 | return ( 148 |
149 |
150 |
151 |
152 |

Tasks

153 |
154 | 157 | 171 |
172 |
173 |
174 |
175 | {view === "todo" ? ( 176 | 177 | ) : ( 178 | 179 | )} 180 | 181 | 182 | 183 | 184 | 185 |
186 |
187 |
188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /src/sidebar/drawers/todo/TodoCategories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { TodoItem } from "./TasksDrawer"; 3 | 4 | export default function TodoCategories(props: { todo: TodoItem[] }) { 5 | const taskCount = useMemo(() => { 6 | return props.todo.length; 7 | }, [props.todo]); 8 | 9 | const categories = useMemo(() => { 10 | const map: { 11 | [key: string]: TodoItem[]; 12 | } = {}; 13 | 14 | props.todo.forEach((item) => { 15 | if (map[item.context.code] == null) { 16 | map[item.context.code] = [item]; 17 | } 18 | 19 | map[item.context.code].push(item); 20 | }); 21 | 22 | const entriesSorted = Object.entries(map).sort( 23 | (a, b) => b[1].length - a[1].length 24 | ); 25 | 26 | const newMap: { 27 | [key: string]: TodoItem[]; 28 | } = {}; 29 | 30 | entriesSorted.forEach((entry) => { 31 | newMap[entry[0]] = entry[1]; 32 | }); 33 | return newMap; 34 | }, [props.todo]); 35 | 36 | return ( 37 |
38 | {Object.keys(categories).map((categoryContextName) => { 39 | return ( 40 |
41 |
42 |
49 | {categories[categoryContextName].length} 50 |
51 | 52 |

53 | {categories[categoryContextName][0].context.displayName} 54 |

55 |
56 |
57 | {categories[categoryContextName].map((item) => { 58 | return ( 59 |
60 |
61 |

62 | {item.assignment.name} 63 |

64 |
65 | ); 66 | })} 67 |
68 |
69 | ); 70 | })} 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/sidebar/drawers/todo/TodoHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { TodoItem } from "./TasksDrawer"; 3 | 4 | export default function TodoHeader(props: { todo: TodoItem[] }) { 5 | const taskCount = useMemo(() => { 6 | return props.todo.length; 7 | }, [props.todo]); 8 | 9 | const contextMap = useMemo(() => { 10 | interface ContextData { 11 | color: string; 12 | count: number; 13 | } 14 | 15 | const map = new Map(); 16 | 17 | props.todo.forEach((item) => { 18 | const context = item.context.type + item.context.id; 19 | 20 | if (map.has(context)) { 21 | const prevContext = map.get(context)!; 22 | 23 | map.set(context, { 24 | ...prevContext, 25 | count: prevContext.count + 1, 26 | }); 27 | } else { 28 | map.set(context, { 29 | color: item.context.color, 30 | count: 1, 31 | }); 32 | } 33 | }); 34 | 35 | return map; 36 | }, [props.todo]); 37 | 38 | const linearGradient = useMemo(() => { 39 | const gradient: any[] = []; 40 | 41 | contextMap.forEach((contextInfo, contextName) => { 42 | const percent = contextInfo.count / taskCount; 43 | gradient.push({ 44 | color: contextInfo.color, 45 | percent, 46 | name: contextName, 47 | }); 48 | }); 49 | 50 | return gradient.sort((a, b) => b.percent - a.percent); 51 | }, [contextMap, taskCount]); 52 | 53 | return ( 54 |
55 |
56 |

57 | {taskCount} 58 | {taskCount > 99 ? "+" : ""} 59 |

60 |

61 | Task{taskCount !== 1 ? "s" : ""} Remaining 62 |

63 |
64 |
65 | {linearGradient.map((context) => { 66 | return ( 67 |
74 | ); 75 | })} 76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/sidebar/menus/navigator/Navigator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Navigator() { 4 | const LINKS = [ 5 | ["Dashboard", "/"], 6 | ["Courses", "/courses"], 7 | ["Calendar", "/calendar"], 8 | ]; 9 | 10 | return ( 11 |
12 |
13 |

Navigator

14 |
15 |
16 | {LINKS.map(([name, link]) => ( 17 | 21 | {name} 22 | 23 | ))} 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .border-inner { 6 | box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.05); 7 | } 8 | -------------------------------------------------------------------------------- /src/util/BinaryCarousel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { Transition } from "react-transition-group"; 3 | import { TransitionStyles } from "./TransitionStyles"; 4 | 5 | export default function BinaryCarousel(props: { 6 | children: React.ReactElement[]; 7 | index: 0 | 1; 8 | }) { 9 | const leftRef = useRef(null); 10 | 11 | const leftStyles: TransitionStyles = { 12 | entering: { 13 | opacity: 0.5, 14 | "--tw-translate-x": "-100%", 15 | transitionProperty: "none", 16 | } as React.CSSProperties, 17 | entered: { 18 | transitionProperty: "all", 19 | opacity: 1, 20 | }, 21 | exiting: { 22 | opacity: 0.5, 23 | "--tw-translate-x": "-100%", 24 | transitionProperty: "all", 25 | } as React.CSSProperties, 26 | exited: { 27 | display: "none", 28 | } as React.CSSProperties, 29 | unmounted: {}, 30 | }; 31 | 32 | const rightRef = useRef(null); 33 | 34 | const rightStyles: TransitionStyles = { 35 | entering: { 36 | opacity: 1, 37 | "--tw-translate-x": "-100%", 38 | transitionProperty: "all", 39 | } as React.CSSProperties, 40 | entered: { 41 | "--tw-translate-x": "0%", 42 | transitionProperty: "opacity", 43 | opacity: 1, 44 | } as React.CSSProperties, 45 | exiting: { 46 | "--tw-translate-x": "-100%", 47 | transitionProperty: "none", 48 | } as React.CSSProperties, 49 | exited: { 50 | opacity: 0.5, 51 | "--tw-translate-x": "0%", 52 | transitionProperty: "all", 53 | } as React.CSSProperties, 54 | unmounted: {}, 55 | }; 56 | 57 | return ( 58 |
59 | 67 | {(state) => ( 68 |
73 | {props.children[0]} 74 |
75 | )} 76 |
77 | 78 | 86 | {(state) => ( 87 |
92 | {props.children[1]} 93 |
94 | )} 95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/util/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Button(props: { 4 | children: React.ReactNode[] | React.ReactNode; 5 | onClick?: () => void; 6 | type: "primary" | "secondary"; 7 | size?: "xs" | "sm" | "md" | "lg"; 8 | href?: string; 9 | }) { 10 | const styleMap = { 11 | primary: "bg-rose-500 text-white border-rose-600", 12 | secondary: "bg-white text-gray-400 border-gray-200", 13 | }; 14 | 15 | const sizeMap = { 16 | xs: "px-1 py-0.5 text-xs", 17 | sm: "px-2 py-1 text-sm", 18 | md: "px-4 py-2 text-md", 19 | lg: "px-6 py-3 text-lg", 20 | }; 21 | 22 | if (props.href) 23 | return ( 24 | 25 | 28 | 29 | ); 30 | return ( 31 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/util/SegmentedControl.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function SegmentedControl(props: { 4 | current: string; 5 | items: { 6 | name: string; 7 | value: string; 8 | }[]; 9 | setItem: (itemValue: string) => void; 10 | }) { 11 | return ( 12 |
15 | {props.items.map((item) => { 16 | return ( 17 | 29 | ); 30 | })} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/util/TransitionStyles.ts: -------------------------------------------------------------------------------- 1 | // default export 2 | type TransitionStyles = Record< 3 | "entering" | "entered" | "exiting" | "exited" | "unmounted", 4 | React.HTMLAttributes["style"] 5 | >; 6 | 7 | export type { TransitionStyles }; 8 | -------------------------------------------------------------------------------- /src/util/coreResources.ts: -------------------------------------------------------------------------------- 1 | // keep a cache of the api resources and store the last time they were updated 2 | 3 | import getAllPages from "./getAllPages"; 4 | import axios from "redaxios"; 5 | 6 | // so we can refresh them if they are stale 7 | export interface CacheItem { 8 | status: CacheStatus; 9 | data?: any; 10 | } 11 | export interface CacheStatus { 12 | lastUpdated: number; 13 | stale: boolean; 14 | } 15 | 16 | export interface Course { 17 | id: number; 18 | name: string; 19 | tabs: CourseTab[]; 20 | } 21 | 22 | export interface CourseTab { 23 | id: string; 24 | type: "INTERNAL" | "EXTERNAL"; 25 | url: string; 26 | label: string; 27 | } 28 | export interface ModuleItem { 29 | courseId: number; 30 | id: number; 31 | moduleId: number; 32 | title: string; 33 | type: 34 | | "PAGE" 35 | | "DISCUSSION" 36 | | "ASSIGNMENT" 37 | | "QUIZ" 38 | | "FILE" 39 | | "EXTERNAL_TOOL" 40 | | "EXTERNAL_URL"; 41 | contentId: number; 42 | pureUrl: string; 43 | htmlUrl: string; 44 | } 45 | 46 | export interface Module { 47 | id: number; 48 | courseId: number; 49 | name: string; 50 | itemCount: number; 51 | items: ModuleItem[]; 52 | } 53 | 54 | export interface Page { 55 | courseId: number; 56 | pageId: number; 57 | pageLocator: string; 58 | title: string; 59 | body?: string; 60 | htmlUrl: string; 61 | pureUrl: string; 62 | } 63 | 64 | export interface DiscussionTopic { 65 | courseId: number; 66 | id: number; 67 | title: string; 68 | message: string; 69 | htmlUrl: string; 70 | pureUrl: string; 71 | } 72 | 73 | export interface Assignment { 74 | id: number; 75 | name: string; 76 | description: string; 77 | dueAt: string; 78 | courseId: number; 79 | htmlUrl: string; 80 | pureUrl: string; 81 | } 82 | 83 | export interface Quiz { 84 | id: number; 85 | title: string; 86 | htmlUrl: string; 87 | pureUrl: string; 88 | } 89 | 90 | export interface File { 91 | id: number; 92 | displayName: string; 93 | url: string; 94 | pureUrl: string; 95 | } 96 | 97 | export interface SearchItem { 98 | url: string; 99 | courseId: number; 100 | title: string; 101 | moduleId: number[]; 102 | isCourse?: boolean; 103 | } 104 | 105 | export type ToleranceLevel = "5MIN" | "1HOUR" | "1DAY" | "1WEEK" | "1MONTH"; 106 | 107 | export const TOLERANCE_LEVELS = { 108 | "5MIN": 5 * 60 * 1000, 109 | "1HOUR": 60 * 60 * 1000, 110 | "1DAY": 24 * 60 * 60 * 1000, 111 | "1WEEK": 7 * 24 * 60 * 60 * 1000, 112 | "1MONTH": 30 * 24 * 60 * 60 * 1000, 113 | }; 114 | 115 | export function getCanvasContext(): Promise { 116 | return new Promise((resolve) => { 117 | if (document.readyState === "complete") { 118 | const domain = window.location.hostname; 119 | resolve(domain); 120 | } else { 121 | window.addEventListener("load", () => { 122 | const domain = window.location.hostname; 123 | resolve(domain); 124 | }); 125 | } 126 | }); 127 | // return new Promise((resolve) => { 128 | // if (document.readyState === "complete") { 129 | // const domain = window.location.hostname; 130 | // // @ts-ignore 131 | // resolve(domain + "_" + window["ENV"]["current_user"]["id"]); 132 | // } else { 133 | // window.addEventListener("load", () => { 134 | // const domain = window.location.hostname; 135 | // // @ts-ignore 136 | // resolve(domain + "_" + window["ENV"]["current_user"]["id"]); 137 | // }); 138 | // } 139 | // }); 140 | } 141 | 142 | export async function getCacheItem( 143 | key: string, 144 | tolerance: ToleranceLevel 145 | ): Promise { 146 | const CONTEXT = await getCanvasContext(); 147 | const fullKey = "cache." + CONTEXT + "." + key; 148 | 149 | const storageGet = new Promise((resolve) => { 150 | chrome.storage.local.get([fullKey], (result) => { 151 | if (result[fullKey]) { 152 | const lastUpdated = result[fullKey].lastUpdated; 153 | const now = Date.now(); 154 | const toleranceLevel = TOLERANCE_LEVELS[tolerance]; 155 | 156 | if (now - lastUpdated < toleranceLevel) { 157 | resolve({ 158 | data: result[fullKey].data, 159 | status: { 160 | lastUpdated: lastUpdated, 161 | stale: false, 162 | }, 163 | }); 164 | } else { 165 | resolve({ 166 | status: { 167 | lastUpdated: lastUpdated, 168 | stale: true, 169 | }, 170 | }); 171 | } 172 | } else { 173 | resolve({ status: { lastUpdated: 0, stale: true } }); 174 | } 175 | }); 176 | }); 177 | 178 | const storageGetResult: CacheItem = await storageGet; 179 | 180 | return storageGetResult; 181 | } 182 | 183 | export async function setCacheItem(key: string, data: any): Promise { 184 | const CONTEXT = await getCanvasContext(); 185 | const fullKey = "cache." + CONTEXT + "." + key; 186 | 187 | const storageSet = new Promise((resolve) => { 188 | chrome.storage.local.set( 189 | { 190 | [fullKey]: { 191 | data: data, 192 | lastUpdated: Date.now(), 193 | }, 194 | }, 195 | () => { 196 | resolve(); 197 | } 198 | ); 199 | }); 200 | 201 | await storageSet; 202 | } 203 | 204 | export async function getCourses() { 205 | const cacheItem = await getCacheItem("courses", "1HOUR"); 206 | 207 | if (cacheItem.status.stale) { 208 | const courses = await getAllPages( 209 | "/api/v1/users/self/favorites/courses?include[]=tabs" 210 | ); 211 | await setCacheItem("courses", courses); 212 | return courses; 213 | } else { 214 | return cacheItem.data; 215 | } 216 | } 217 | 218 | export async function getModules(courseId: number, items: boolean) { 219 | const cacheItem = await getCacheItem("modules." + courseId, "1HOUR"); 220 | 221 | if (cacheItem.status.stale) { 222 | const modules = await getAllPages( 223 | `/api/v1/courses/${courseId}/modules${items ? "?include[]=items" : ""}` 224 | ); 225 | await setCacheItem("modules." + courseId, modules); 226 | return modules; 227 | } else { 228 | return cacheItem.data; 229 | } 230 | } 231 | 232 | export async function getPages(courseId: number) { 233 | const cacheItem = await getCacheItem("pages." + courseId, "1HOUR"); 234 | 235 | if (cacheItem.status.stale) { 236 | const pages = await getAllPages( 237 | `/api/v1/courses/${courseId}/pages?per_page=100` 238 | ); 239 | await setCacheItem("pages." + courseId, pages); 240 | return pages; 241 | } else { 242 | return cacheItem.data; 243 | } 244 | } 245 | 246 | export async function getDiscussions(courseId: number) { 247 | const cacheItem = await getCacheItem("discussions." + courseId, "1HOUR"); 248 | 249 | if (cacheItem.status.stale) { 250 | const discussions = await getAllPages( 251 | `/api/v1/courses/${courseId}/discussion_topics?per_page=100` 252 | ); 253 | await setCacheItem("discussions." + courseId, discussions); 254 | return discussions; 255 | } else { 256 | return cacheItem.data; 257 | } 258 | } 259 | 260 | export async function getAssignments(courseId: number) { 261 | const cacheItem = await getCacheItem("assignments." + courseId, "1HOUR"); 262 | 263 | if (cacheItem.status.stale) { 264 | const assignments = await getAllPages( 265 | `/api/v1/courses/${courseId}/assignments?per_page=100` 266 | ); 267 | await setCacheItem("assignments." + courseId, assignments); 268 | return assignments; 269 | } else { 270 | return cacheItem.data; 271 | } 272 | } 273 | 274 | export async function getQuizzes(courseId: number) { 275 | const cacheItem = await getCacheItem("quizzes." + courseId, "1HOUR"); 276 | 277 | if (cacheItem.status.stale) { 278 | const quizzes = await getAllPages( 279 | `/api/v1/courses/${courseId}/quizzes?per_page=100` 280 | ); 281 | await setCacheItem("quizzes." + courseId, quizzes); 282 | return quizzes; 283 | } else { 284 | return cacheItem.data; 285 | } 286 | } 287 | 288 | export async function getFiles(courseId: number) { 289 | const cacheItem = await getCacheItem("files." + courseId, "1HOUR"); 290 | 291 | if (cacheItem.status.stale) { 292 | const files = await getAllPages( 293 | `/api/v1/courses/${courseId}/files?per_page=100` 294 | ); 295 | await setCacheItem("files." + courseId, files); 296 | return files; 297 | } else { 298 | return cacheItem.data; 299 | } 300 | } 301 | 302 | export async function getGroups() { 303 | const cacheItem = await getCacheItem("groups", "1HOUR"); 304 | 305 | if (cacheItem.status.stale) { 306 | const courses = await getAllPages("/api/v1/users/self/groups"); 307 | await setCacheItem("groups", courses); 308 | return courses; 309 | } else { 310 | return cacheItem.data; 311 | } 312 | } 313 | 314 | export async function getColors() { 315 | const cacheItem = await getCacheItem("colors", "1WEEK"); 316 | 317 | if (cacheItem.status.stale) { 318 | const colors = (await axios.get("/api/v1/users/self/colors")).data; 319 | await setCacheItem("colors", colors); 320 | return colors; 321 | } else { 322 | return cacheItem.data; 323 | } 324 | } 325 | 326 | export async function getTodoItems() { 327 | const cacheItem = await getCacheItem("todoItems", "5MIN"); 328 | 329 | if (cacheItem.status.stale) { 330 | const todoItems = await getAllPages("/api/v1/users/self/todo"); 331 | await setCacheItem("todoItems", todoItems); 332 | console.log(todoItems); 333 | 334 | return todoItems; 335 | } else { 336 | return cacheItem.data; 337 | } 338 | } 339 | 340 | export async function getPlannedItems() { 341 | const cacheItem = await getCacheItem("plannerItems", "5MIN"); 342 | 343 | const startingDate = new Date(); 344 | startingDate.setDate(startingDate.getDate() - 7); 345 | 346 | if (cacheItem.status.stale) { 347 | const plannedItems = await getAllPages( 348 | // YYYY-MM-DD 349 | `/api/v1/planner/items?start_date=${ 350 | startingDate.toISOString().split("T")[0] 351 | }` 352 | ); 353 | await setCacheItem("plannerItems", plannedItems); 354 | return plannedItems; 355 | } else { 356 | return cacheItem.data; 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/util/getAllPages.ts: -------------------------------------------------------------------------------- 1 | import axios from "redaxios"; 2 | 3 | const PER_PAGE = 100; 4 | 5 | async function requestPage(url: string, page: number): Promise { 6 | try { 7 | const hasURLParams = url.includes("?"); 8 | 9 | const { data } = await axios.get( 10 | url + `${hasURLParams ? "&" : "?"}page=${page}&per_page=${PER_PAGE}` 11 | ); 12 | 13 | if (data.length < PER_PAGE) { 14 | return data; 15 | } 16 | 17 | const nextData = await requestPage(url, page + 1); 18 | 19 | return [...data, ...nextData]; 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | } 24 | export default function getAllPages(url: string): Promise { 25 | return new Promise((resolve, reject) => { 26 | requestPage(url, 1) 27 | .then((data) => { 28 | resolve(data); 29 | }) 30 | .catch((error) => { 31 | reject(error); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/util/search.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Course, 3 | CourseTab, 4 | ModuleItem, 5 | SearchItem, 6 | getCacheItem, 7 | getCourses, 8 | getModules, 9 | setCacheItem, 10 | } from "./coreResources"; 11 | 12 | // @ts-ignore 13 | import trigramSimilarity from "trigram-similarity"; 14 | 15 | export async function searchCourseModules( 16 | course: Course 17 | ): Promise { 18 | const modules = await getModules(course.id, true); 19 | 20 | const returnable: SearchItem[] = []; 21 | 22 | modules.forEach((module: any) => { 23 | const moduleId: number = module.id; 24 | 25 | const moduleItems = module.items.map((item: any): ModuleItem => { 26 | const typeIsInternal = 27 | item.type === "Assignment" || 28 | item.type === "Page" || 29 | item.type === "Quiz" || 30 | item.type === "Discussion"; 31 | 32 | let pureUrl: string = typeIsInternal ? "" : item.external_url; 33 | 34 | if (item.type === "Assignment") { 35 | pureUrl = `/courses/${course.id}/assignments/${item.content_id}`; 36 | } else if (item.type === "Page") { 37 | pureUrl = `/courses/${course.id}/pages/${item.page_url}`; 38 | } else if (item.type === "Quiz") { 39 | pureUrl = `/courses/${course.id}/quizzes/${item.content_id}`; 40 | } else if (item.type === "Discussion") { 41 | pureUrl = `/courses/${course.id}/discussion_topics/${item.content_id}`; 42 | } 43 | 44 | return { 45 | id: item.id, 46 | title: item.title, 47 | type: 48 | item.type === "Assignment" 49 | ? "ASSIGNMENT" 50 | : item.type === "Page" 51 | ? "PAGE" 52 | : item.type === "Quiz" 53 | ? "QUIZ" 54 | : item.type === "Discussion" 55 | ? "DISCUSSION" 56 | : item.type === "ExternalTool" 57 | ? "EXTERNAL_TOOL" 58 | : "EXTERNAL_URL", 59 | contentId: item.content_id, 60 | courseId: course.id, 61 | htmlUrl: item.html_url, 62 | pureUrl, 63 | moduleId: moduleId, 64 | }; 65 | }); 66 | 67 | returnable.push( 68 | ...moduleItems.map((item: ModuleItem): SearchItem => { 69 | return { 70 | title: item.title, 71 | url: item.htmlUrl, 72 | courseId: item.courseId, 73 | moduleId: [item.moduleId], 74 | }; 75 | }) 76 | ); 77 | }); 78 | 79 | return returnable; 80 | } 81 | 82 | export async function searchCoursePages(course: Course): Promise { 83 | return []; 84 | } 85 | 86 | export async function searchCourseAssignments( 87 | course: Course 88 | ): Promise { 89 | return []; 90 | } 91 | 92 | export async function searchCourseQuizzes( 93 | course: Course 94 | ): Promise { 95 | return []; 96 | } 97 | 98 | export async function searchCourseFiles(course: Course): Promise { 99 | return []; 100 | } 101 | 102 | export async function searchCourseDiscussions( 103 | course: Course 104 | ): Promise { 105 | return []; 106 | } 107 | 108 | export async function searchCourse(course: Course): Promise { 109 | const returnable: SearchItem[] = []; 110 | 111 | let searchModules = false; 112 | let searchPages = false; 113 | let searchQuizzes = false; 114 | let searchFiles = false; 115 | let searchDiscussions = false; 116 | 117 | for (let tab of course.tabs) { 118 | if (tab.id === "modules") { 119 | searchModules = true; 120 | } else if (tab.id === "pages") { 121 | searchPages = true; 122 | } else if (tab.id === "quizzes") { 123 | searchQuizzes = true; 124 | } else if (tab.id === "files") { 125 | searchFiles = true; 126 | } else if (tab.id === "discussions") { 127 | searchDiscussions = true; 128 | } 129 | } 130 | 131 | const modulesPromise = async () => { 132 | if (searchModules) { 133 | returnable.push(...(await searchCourseModules(course))); 134 | } 135 | }; 136 | 137 | const pagesPromise = async () => { 138 | if (searchPages) { 139 | returnable.push(...(await searchCoursePages(course))); 140 | } 141 | }; 142 | 143 | const quizzesPromise = async () => { 144 | if (searchQuizzes) { 145 | returnable.push(...(await searchCourseQuizzes(course))); 146 | } 147 | }; 148 | 149 | const filesPromise = async () => { 150 | if (searchFiles) { 151 | returnable.push(...(await searchCourseFiles(course))); 152 | } 153 | }; 154 | 155 | const discussionsPromise = async () => { 156 | if (searchDiscussions) { 157 | returnable.push(...(await searchCourseDiscussions(course))); 158 | } 159 | }; 160 | 161 | const assignmentsPromise = async () => { 162 | returnable.push(...(await searchCourseAssignments(course))); 163 | }; 164 | 165 | await Promise.all([ 166 | modulesPromise(), 167 | pagesPromise(), 168 | quizzesPromise(), 169 | filesPromise(), 170 | discussionsPromise(), 171 | assignmentsPromise(), 172 | ]); 173 | 174 | return returnable; 175 | } 176 | 177 | export async function indexSearch() { 178 | const coursesRaw = await getCourses(); 179 | 180 | let searchIndex: SearchItem[] = []; 181 | 182 | for (let courseRaw of coursesRaw) { 183 | const course: Course = { 184 | id: courseRaw.id, 185 | name: courseRaw.name, 186 | tabs: courseRaw.tabs.map((tab: any): CourseTab => { 187 | return { 188 | id: tab.id, 189 | label: tab.label, 190 | type: tab.type.toUpperCase(), 191 | url: tab.html_url, 192 | }; 193 | }), 194 | }; 195 | 196 | searchIndex.push(...(await searchCourse(course))); 197 | } 198 | 199 | return searchIndex; 200 | } 201 | 202 | export async function getSearchIndex(): Promise { 203 | const cacheItem = await getCacheItem("searchIndex", "1HOUR"); 204 | 205 | if (cacheItem.status.stale) { 206 | const searchIndex = await indexSearch(); 207 | 208 | await setCacheItem("searchIndex", searchIndex); 209 | return searchIndex; 210 | } else { 211 | return cacheItem.data; 212 | } 213 | } 214 | 215 | export async function getSearchIndexByWord(): Promise<{ 216 | [key: string]: SearchItem[]; 217 | }> { 218 | const cacheItem = await getCacheItem("searchIndexByWord", "1HOUR"); 219 | 220 | if (cacheItem.status.stale || true) { 221 | const searchIndex = await getSearchIndex(); 222 | 223 | const searchIndexByWord: { [key: string]: SearchItem[] } = {}; 224 | 225 | for (let item of searchIndex) { 226 | if (item.title == null) continue; 227 | 228 | const words = keywordSplit(item.title); 229 | 230 | for (let word of words) { 231 | if (searchIndexByWord[word] === undefined) { 232 | searchIndexByWord[word] = []; 233 | } 234 | searchIndexByWord[word].push(item); 235 | } 236 | } 237 | 238 | return searchIndexByWord; 239 | } else { 240 | } 241 | } 242 | 243 | export function keywordSplit(query: string) { 244 | const blocks = []; 245 | let currentBlock = ""; 246 | 247 | for (let char of query) { 248 | if (char.match(/^[a-zA-Z0-9]*$/)) { 249 | currentBlock += char; 250 | } else { 251 | if (currentBlock !== "") { 252 | blocks.push(currentBlock.toLowerCase()); 253 | currentBlock = ""; 254 | } 255 | } 256 | } 257 | if (currentBlock !== "") { 258 | blocks.push(currentBlock.toLowerCase()); 259 | currentBlock = ""; 260 | } 261 | return blocks; 262 | } 263 | 264 | const filterAlphanumeric = (toFilter: string) => { 265 | return toFilter.replace(/[^0-9a-z]/gi, ""); 266 | }; 267 | 268 | const scoreWordMatch = (ref: string, sub: string) => { 269 | const refAna: { [x: string]: number } = {}; 270 | 271 | ref.split("").forEach((ref) => { 272 | // sort words into object with quantities of letters 273 | refAna[ref] = (refAna[ref] || 0) + 1; 274 | }); 275 | 276 | const subAna: { [x: string]: number } = {}; 277 | 278 | sub.split("").forEach((sub) => { 279 | // sort words into object with quantities of letters 280 | subAna[sub] = (subAna[sub] || 0) + 1; 281 | }); 282 | 283 | let missing: string[] = []; // chars in ref not in sub 284 | let stray: string[] = []; // chars in sub not in ref 285 | 286 | Object.keys(refAna).forEach((ref) => { 287 | // add missing chars to missing 288 | const quantity = refAna[ref] || 0; 289 | const subQuantity = subAna[ref] || 0; 290 | if (quantity > subQuantity) { 291 | missing = missing.concat(Array(quantity - subQuantity).fill(ref)); 292 | } 293 | }); 294 | 295 | Object.keys(subAna).forEach((sub) => { 296 | // add stray chars to stray 297 | const quantity = refAna[sub] || 0; 298 | const subQuantity = subAna[sub] || 0; 299 | if (subQuantity > quantity) { 300 | stray = stray.concat(Array(subQuantity - quantity).fill(sub)); 301 | } 302 | }); 303 | 304 | // calculate score 305 | let score = 306 | Math.min( 307 | (ref.length - 308 | (missing.length + stray.length * 0.5) + 309 | (ref.includes(sub) || sub.includes(ref) ? 2 : 0)) / 310 | ref.length, 311 | 1 312 | ) * /*Math.min(ref.length, sub.length)*/ ref.length; 313 | 314 | return score; 315 | }; 316 | 317 | export async function search( 318 | query: string, 319 | items: number 320 | ): Promise { 321 | if (query.length < 2) { 322 | const courses: Course[] = await getCourses(); 323 | 324 | return courses 325 | .filter((course: Course) => { 326 | return course.name.toLowerCase().includes(query.toLowerCase()); 327 | }) 328 | .sort((a: Course, b: Course) => { 329 | return ( 330 | a.name.toLowerCase().indexOf(query.toLowerCase()) - 331 | b.name.toLowerCase().indexOf(query.toLowerCase()) 332 | ); 333 | }) 334 | .slice(0, items) 335 | .map((course: Course): SearchItem => { 336 | return { 337 | title: course.name, 338 | url: `/courses/${course.id}`, 339 | courseId: course.id, 340 | moduleId: [], 341 | isCourse: true, 342 | }; 343 | }); 344 | } 345 | 346 | const results: SearchItem[] = []; 347 | const scoredResults: { 348 | [url: string]: { 349 | score: number; 350 | item: SearchItem; 351 | }; 352 | } = {}; 353 | 354 | const searchIndex = await getSearchIndex(); 355 | 356 | for (let searchItem of searchIndex) { 357 | const score = trigramSimilarity(query, searchItem.title); 358 | 359 | scoredResults[searchItem.url] = { 360 | score: score, 361 | item: searchItem, 362 | }; 363 | } 364 | 365 | const courses = await getCourses(); 366 | 367 | courses.forEach((course: Course) => { 368 | const courseScore = trigramSimilarity(query, course.name); 369 | 370 | scoredResults[course.name] = { 371 | score: courseScore, 372 | item: { 373 | title: course.name, 374 | url: `/courses/${course.id}`, 375 | courseId: course.id, 376 | moduleId: [], 377 | }, 378 | }; 379 | }); 380 | 381 | const scoredResultsArray = Object.values(scoredResults); 382 | 383 | scoredResultsArray.sort((a, b) => { 384 | return b.score - a.score; 385 | }); 386 | 387 | for (let scoredResult of scoredResultsArray) { 388 | results.push(scoredResult.item); 389 | } 390 | 391 | results.splice(items); 392 | 393 | return results; 394 | } 395 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/index.html"], 4 | theme: {}, 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", "vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { resolve } from "path"; 4 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 5 | 6 | export default defineConfig({ 7 | build: { 8 | outDir: "dist", 9 | rollupOptions: { 10 | input: { 11 | index: resolve(__dirname, "./src/index.html"), 12 | }, 13 | output: { 14 | entryFileNames: "src/[name].js", 15 | chunkFileNames: "src/[name].js", 16 | }, 17 | external: ["sanitize-html"], 18 | }, 19 | }, 20 | plugins: [react(), cssInjectedByJsPlugin({ topExecutionPriority: false })], 21 | }); 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyWebPackPlugin = require("copy-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: "./src/index.tsx", 6 | mode: "production", 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | use: [ 12 | { 13 | loader: "ts-loader", 14 | options: { 15 | compilerOptions: { noEmit: false }, 16 | }, 17 | }, 18 | ], 19 | exclude: /node_modules/, 20 | }, 21 | { 22 | test: /\.css$/i, 23 | include: path.resolve(__dirname, "src"), 24 | use: ["style-loader", "css-loader", "postcss-loader"], 25 | }, 26 | ], 27 | }, 28 | plugins: [ 29 | new CopyWebPackPlugin({ 30 | patterns: [{ from: "public", to: "." }], 31 | }), 32 | ], 33 | resolve: { 34 | extensions: [".tsx", ".ts", ".js", ".json"], 35 | }, 36 | output: { 37 | filename: "content.js", 38 | path: path.resolve(__dirname, "dist"), 39 | }, 40 | }; 41 | --------------------------------------------------------------------------------