├── extension ├── .nvmrc ├── public │ ├── contentStyle.css │ ├── icon-128.png │ ├── icon-32.png │ ├── dev-icon-128.png │ └── dev-icon-32.png ├── src │ ├── pages │ │ ├── options │ │ │ ├── index.css │ │ │ ├── Options.css │ │ │ ├── Options.tsx │ │ │ ├── index.html │ │ │ └── index.tsx │ │ ├── content │ │ │ ├── style.css │ │ │ ├── scripts │ │ │ │ ├── fillin.ts │ │ │ │ └── scrape.ts │ │ │ ├── api.ts │ │ │ └── index.ts │ │ └── popup │ │ │ ├── index.html │ │ │ ├── components │ │ │ ├── Tile.tsx │ │ │ ├── Badge.tsx │ │ │ ├── CopyButton.tsx │ │ │ ├── Input.tsx │ │ │ └── Button.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ └── Popup.tsx │ ├── vite-env.d.ts │ ├── global.d.ts │ ├── types.ts │ └── assets │ │ ├── styles │ │ └── tailwind.css │ │ └── img │ │ └── logo.svg ├── bun.lockb ├── postcss.config.cjs ├── .prettierrc ├── nodemon.json ├── manifest.dev.json ├── .eslintrc ├── tsconfig.json ├── manifest.json ├── manifest.qa.json ├── LICENSE ├── tailwind.config.cjs ├── package.json ├── vite.config.ts └── README.md ├── backend ├── .dockerignore ├── backend.http ├── appsettings.Development.json ├── appsettings.json ├── Makefile ├── docker-compose.yml ├── Repositories │ ├── Interfaces │ │ └── ICacheRepository.cs │ └── CacheRepository.cs ├── Services │ ├── Interfaces │ │ ├── IFacultyService.cs │ │ ├── IAssignmentService.cs │ │ ├── ICourseService.cs │ │ └── IRecordService.cs │ ├── FacultyService.cs │ ├── AssignmentService.cs │ ├── CourseService.cs │ └── RecordService.cs ├── Dockerfile.ci ├── Models │ ├── Faculty.cs │ ├── Assignment.cs │ ├── Course.cs │ └── Record.cs ├── DTO │ ├── FacultyDTO.cs │ ├── AssignmentDTO.cs │ ├── CourseDTO.cs │ └── RecordDTO.cs ├── .env.template ├── Parsers │ ├── FacultyParser.cs │ ├── AssignmentParser.cs │ ├── CourseParser.cs │ └── RecordParser.cs ├── Data │ ├── Firestore.cs │ └── Seed │ │ ├── FirestoreSeeder.cs │ │ └── faculties.json ├── backend.csproj ├── Exceptions │ └── ServiceException.cs ├── custom │ ├── GlobalRoutePrefix.cs │ └── MyConfigServiceCollectionExtensions.cs ├── Config │ └── Config.cs ├── Properties │ └── launchSettings.json ├── docker-compose.qa.yml ├── Middlewares │ └── ApiKeyMiddleware.cs ├── Controllers │ ├── AssignmentController.cs │ ├── FacultyController.cs │ ├── CourseController.cs │ └── RecordController.cs └── Program.cs ├── frontend ├── .eslintrc.json ├── bun.lockb ├── public │ ├── logo.png │ ├── course-test.png │ ├── favicon │ │ ├── favicon.ico │ │ ├── favicon-48x48.png │ │ ├── apple-touch-icon.png │ │ ├── web-app-manifest-192x192.png │ │ ├── web-app-manifest-512x512.png │ │ └── site.webmanifest │ └── ourcourseville-chrome-extension.zip ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── providers.tsx │ │ ├── faculty │ │ │ ├── [facultycode] │ │ │ │ └── course │ │ │ │ │ ├── [coursecode] │ │ │ │ │ └── assignment │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── [assignmentcode] │ │ │ │ │ │ ├── RecordTabs.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── record │ │ │ │ │ │ │ └── [id] │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── LoadState.tsx │ │ ├── HowTo.tsx │ │ ├── page.tsx │ │ ├── Intro.tsx │ │ ├── RecentCourses.tsx │ │ ├── globals.css │ │ └── layout.tsx │ ├── utils │ │ ├── getPathname.ts │ │ ├── formatTime.ts │ │ └── isUrl.ts │ ├── components │ │ ├── Header.tsx │ │ ├── Tile │ │ │ ├── index.tsx │ │ │ └── AssignmentTile.tsx │ │ ├── NavBar │ │ │ ├── Logo.tsx │ │ │ ├── NavItem.tsx │ │ │ └── index.tsx │ │ ├── Badge.tsx │ │ ├── IconButton │ │ │ ├── ExtensionButton.tsx │ │ │ └── CopyButton.tsx │ │ ├── Card │ │ │ ├── index.tsx │ │ │ ├── RecordCard.tsx │ │ │ ├── FacultyCard.tsx │ │ │ └── CourseCard.tsx │ │ ├── Button │ │ │ └── index.tsx │ │ ├── Tab │ │ │ ├── Tab.tsx │ │ │ └── TabButton.tsx │ │ ├── AttachmentFile.tsx │ │ ├── ui │ │ │ ├── toaster.tsx │ │ │ ├── popover.tsx │ │ │ └── toast.tsx │ │ └── SideBar │ │ │ ├── SideBarItem.tsx │ │ │ └── index.tsx │ ├── api │ │ ├── axios.ts │ │ ├── dto │ │ │ ├── faculty.dto.ts │ │ │ ├── record.dto.ts │ │ │ ├── assignment.dto.ts │ │ │ └── course.dto.ts │ │ ├── faculty.ts │ │ ├── record.ts │ │ ├── course.ts │ │ └── assignment.ts │ ├── config │ │ └── config.ts │ ├── middleware.ts │ ├── hooks │ │ ├── useIsUnderLargeViewport.ts │ │ └── useGetRecentCourses.ts │ ├── types.ts │ ├── store │ │ ├── store.ts │ │ ├── recordSlice.ts │ │ ├── facultySlice.ts │ │ ├── assignmentSlice.ts │ │ └── courseSlice.ts │ └── cache │ │ └── localStorage.ts ├── .env.template ├── postcss.config.mjs ├── lib │ └── utils.ts ├── next.config.mjs ├── .prettierrc ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md ├── tailwind.config.ts └── hooks │ └── use-toast.ts ├── .github ├── pull_request_template.md └── workflows │ ├── build-deploy.yml │ └── ext-ci.yml ├── ourcourseville.sln ├── README.md └── .gitignore /extension/.nvmrc: -------------------------------------------------------------------------------- 1 | 18.17.1 2 | -------------------------------------------------------------------------------- /extension/public/contentStyle.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /extension/src/pages/options/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | firebase-adminsdk.json 2 | .env -------------------------------------------------------------------------------- /extension/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /extension/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/extension/bun.lockb -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/bun.lockb -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/logo.png -------------------------------------------------------------------------------- /extension/public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/extension/public/icon-128.png -------------------------------------------------------------------------------- /extension/public/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/extension/public/icon-32.png -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/public/course-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/course-test.png -------------------------------------------------------------------------------- /extension/public/dev-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/extension/public/dev-icon-128.png -------------------------------------------------------------------------------- /extension/public/dev-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/extension/public/dev-icon-32.png -------------------------------------------------------------------------------- /frontend/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/favicon/favicon.ico -------------------------------------------------------------------------------- /extension/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/favicon/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/favicon/favicon-48x48.png -------------------------------------------------------------------------------- /extension/src/pages/content/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | .langin{ 5 | color: aliceblue; 6 | } -------------------------------------------------------------------------------- /frontend/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_RECENT_COURSES_TTL=31536000000 # 1 year 2 | NEXT_PUBLIC_EXTENSION_URL= 3 | API_URL=http://localhost:5203 4 | API_KEY=apikey -------------------------------------------------------------------------------- /frontend/public/ourcourseville-chrome-extension.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/ourcourseville-chrome-extension.zip -------------------------------------------------------------------------------- /backend/backend.http: -------------------------------------------------------------------------------- 1 | @backend_HostAddress = http://localhost:5030 2 | 3 | GET {{backend_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | -------------------------------------------------------------------------------- /frontend/public/favicon/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/favicon/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /frontend/public/favicon/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bookpanda/ourcourseville/HEAD/frontend/public/favicon/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /backend/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /extension/src/pages/options/Options.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 50vh; 4 | font-size: 2rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /backend/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /extension/src/pages/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@pages/options/Options.css'; 3 | 4 | export default function Options(): JSX.Element { 5 | return
Options
; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["mycourseville-default.s3.ap-southeast-1.amazonaws.com"], 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | watch: 2 | dotnet watch run 3 | 4 | seed: 5 | dotnet run seed 6 | 7 | pull: 8 | docker pull --platform linux/x86_64 ghcr.io/bookpanda/ourcourseville:latest 9 | 10 | qa: 11 | docker-compose -f docker-compose.qa.yml up --build -------------------------------------------------------------------------------- /frontend/src/utils/getPathname.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | 3 | export const getPathname = () => { 4 | const headerList = headers(); 5 | const pathname = headerList.get("x-current-path"); 6 | 7 | return pathname ?? "/"; 8 | }; 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Change made 2 | 3 | - [ ]  New features 4 | - [ ]  Bug fixes 5 | - [ ]  Breaking changes 6 | - [ ] Refactor 7 | ## Describe what you have done 8 | - 9 | ### New Features 10 | - 11 | ### Fix 12 | - 13 | ### Others 14 | - 15 | -------------------------------------------------------------------------------- /frontend/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface HeaderProps { 4 | title: string; 5 | } 6 | 7 | export const Header: FC = ({ title }) => { 8 | return
{title}
; 9 | }; 10 | -------------------------------------------------------------------------------- /extension/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-organize-imports", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "trailingComma": "es5", 7 | "tabWidth": 2, 8 | "semi": true, 9 | "singleQuote": false, 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-organize-imports", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "trailingComma": "es5", 7 | "tabWidth": 2, 8 | "semi": true, 9 | "singleQuote": false, 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | cache: 5 | image: redis 6 | container_name: cache 7 | restart: unless-stopped 8 | environment: 9 | REDIS_HOST: localhost 10 | REDIS_PASSWORD: "5678" 11 | ports: 12 | - "6379:6379" -------------------------------------------------------------------------------- /backend/Repositories/Interfaces/ICacheRepository.cs: -------------------------------------------------------------------------------- 1 | namespace backend.Repositories.Interfaces; 2 | 3 | public interface ICacheRepository 4 | { 5 | Task SetAsync(string key, T value, TimeSpan expiration); 6 | Task GetAsync(string key); 7 | Task RemoveAsync(string key); 8 | } -------------------------------------------------------------------------------- /frontend/src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { API_KEY, API_URL } from "../config/config"; 3 | 4 | export const apiClient = axios.create({ 5 | baseURL: `${API_URL}/api/v1`, 6 | headers: { 7 | Authorization: `Bearer ${API_KEY}`, 8 | }, 9 | timeout: 10000, 10 | }); 11 | -------------------------------------------------------------------------------- /extension/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "__DEV__": "true" 4 | }, 5 | "watch": [ 6 | "src", "utils", "vite.config.ts", "manifest.json", "manifest.dev.json" 7 | ], 8 | "ext": "tsx,css,html,ts,json", 9 | "ignore": [ 10 | "src/**/*.spec.ts" 11 | ], 12 | "exec": "vite build" 13 | } 14 | -------------------------------------------------------------------------------- /extension/src/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/components/Tile/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | 3 | export const Tile: FC = ({ children }) => { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/config/config.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = process.env.API_URL; 2 | export const API_KEY = process.env.API_KEY; 3 | export const EXTENSION_URL = process.env.NEXT_PUBLIC_EXTENSION_URL ?? ""; 4 | 5 | export const RECENT_COURSES_TTL = parseInt( 6 | process.env.NEXT_PUBLIC_RECENT_COURSES_TTL ?? "", 7 | 10 8 | ); 9 | -------------------------------------------------------------------------------- /extension/src/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /extension/src/pages/popup/components/Tile.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from "react"; 2 | 3 | export const Tile: FC = ({ children }) => { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Provider } from "react-redux"; 4 | import { store } from "../store/store"; 5 | 6 | const Providers = ({ children }: { children: React.ReactNode }) => { 7 | return {children}; 8 | }; 9 | 10 | export default Providers; 11 | -------------------------------------------------------------------------------- /backend/Services/Interfaces/IFacultyService.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Services.Interfaces; 5 | 6 | public interface IFacultyService 7 | { 8 | Task Create(FacultyDTO facultyDTO); 9 | Task> FindAll(); 10 | Task FindByCode(string code); 11 | } -------------------------------------------------------------------------------- /extension/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | export const ReactComponent: React.SFC>; 4 | const src: string; 5 | export default src; 6 | } 7 | 8 | declare module '*.json' { 9 | const content: string; 10 | export default content; 11 | } 12 | -------------------------------------------------------------------------------- /backend/Services/Interfaces/IAssignmentService.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | 3 | namespace backend.Services.Interfaces; 4 | 5 | public interface IAssignmentService 6 | { 7 | Task Create(AssignmentDTO assignmentDTO); 8 | Task> FindByCourseCode(string courseCode); 9 | Task FindByCode(string code); 10 | } -------------------------------------------------------------------------------- /backend/Services/Interfaces/ICourseService.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Services.Interfaces; 5 | 6 | public interface ICourseService 7 | { 8 | Task Create(CourseDTO courseDTO); 9 | Task> FindByFacultyCode(string facultyCode); 10 | Task FindByCode(string code); 11 | } -------------------------------------------------------------------------------- /backend/Services/Interfaces/IRecordService.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Services.Interfaces; 5 | 6 | public interface IRecordService 7 | { 8 | Task Create(CreateRecordDTO recordDTO); 9 | Task FindOne(string id); 10 | Task> FindByAssignmentCode(string asgmCode); 11 | } -------------------------------------------------------------------------------- /extension/src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | height: 260px; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | 11 | position: relative; 12 | } 13 | -------------------------------------------------------------------------------- /extension/manifest.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": { 3 | "default_icon": "public/dev-icon-32.png", 4 | "default_popup": "src/pages/popup/index.html" 5 | }, 6 | "icons": { 7 | "128": "public/dev-icon-128.png" 8 | }, 9 | "web_accessible_resources": [ 10 | { 11 | "resources": ["contentStyle.css", "dev-icon-128.png", "dev-icon-32.png"], 12 | "matches": [] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export const Logo = () => { 5 | return ( 6 | 7 | logo 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /extension/src/pages/popup/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface BadgeProps { 4 | text: string; 5 | } 6 | 7 | export const Badge: FC = ({ text }) => { 8 | return ( 9 |
10 | {text} 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /extension/src/pages/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import Options from '@pages/options/Options'; 4 | import '@pages/options/index.css'; 5 | 6 | function init() { 7 | const rootContainer = document.querySelector("#__root"); 8 | if (!rootContainer) throw new Error("Can't find Options root element"); 9 | const root = createRoot(rootContainer); 10 | root.render(); 11 | } 12 | 13 | init(); 14 | -------------------------------------------------------------------------------- /backend/Dockerfile.ci: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build 2 | WORKDIR /app 3 | 4 | COPY "backend/*.csproj" . 5 | RUN dotnet restore 6 | 7 | COPY ./backend/. . 8 | RUN dotnet publish "backend.csproj" -c Release -o /app/publish 9 | 10 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base 11 | WORKDIR /app 12 | 13 | EXPOSE 80 14 | EXPOSE 443 15 | 16 | FROM base AS final 17 | WORKDIR /app 18 | COPY --from=build /app/publish . 19 | ENTRYPOINT ["dotnet", "backend.dll"] 20 | -------------------------------------------------------------------------------- /extension/src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '@pages/popup/index.css'; 4 | import '@assets/styles/tailwind.css'; 5 | import Popup from '@pages/popup/Popup'; 6 | 7 | function init() { 8 | const rootContainer = document.querySelector("#__root"); 9 | if (!rootContainer) throw new Error("Can't find Popup root element"); 10 | const root = createRoot(rootContainer); 11 | root.render(); 12 | } 13 | 14 | init(); 15 | -------------------------------------------------------------------------------- /extension/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ScrapeRecord { 2 | courseCode: string; 3 | courseID: string; 4 | course: string; 5 | courseIcon: string; 6 | assignmentCode: string; 7 | assignment: string; 8 | problems: { 9 | question: string; 10 | answer: string; 11 | }[]; 12 | } 13 | 14 | export type RecordDTO = { 15 | id: string; 16 | url?: string; 17 | assignment_code: string; 18 | problems: { 19 | question: string; 20 | answer: string; 21 | }[]; 22 | created_at: string; 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | 4 | interface BadgeProps { 5 | text: string; 6 | } 7 | 8 | export const Badge: FC = ({ text }) => { 9 | return ( 10 |
16 | {text} 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /backend/Models/Faculty.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | 3 | namespace backend.Models; 4 | 5 | [FirestoreData] 6 | public class Faculty 7 | { 8 | [FirestoreProperty] 9 | public string? ID { get; set; } 10 | [FirestoreProperty] 11 | public required string Code { get; set; } 12 | [FirestoreProperty] 13 | public required string Name { get; set; } 14 | [FirestoreProperty] 15 | public int Count { get; set; } = 0; 16 | [FirestoreProperty] 17 | public required Timestamp CreatedAt { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/src/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/src/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export function middleware(request: NextRequest) { 5 | const headers = new Headers(request.headers); 6 | headers.set("x-current-path", request.nextUrl.pathname); 7 | 8 | return NextResponse.next({ headers }); 9 | } 10 | 11 | export const config = { 12 | matcher: [ 13 | // match all routes except static files and APIs 14 | "/((?!api|_next/static|_next/image|favicon.ico).*)", 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /backend/DTO/FacultyDTO.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace backend.DTO; 4 | 5 | public record FacultyDTO 6 | { 7 | [JsonPropertyName("id")] 8 | public string? ID { get; init; } 9 | [JsonPropertyName("code")] 10 | public required string Code { get; init; } 11 | [JsonPropertyName("name")] 12 | public required string Name { get; init; } 13 | [JsonPropertyName("count")] 14 | public int Count { get; init; } = 0; 15 | 16 | [JsonPropertyName("created_at")] 17 | public DateTime? CreatedAt { get; init; } 18 | } -------------------------------------------------------------------------------- /frontend/src/api/dto/faculty.dto.ts: -------------------------------------------------------------------------------- 1 | import { Faculty } from "@/src/types"; 2 | 3 | export type FacultyDTO = { 4 | id: string; 5 | code: string; 6 | name: string; 7 | count: number; 8 | created_at: string; 9 | }; 10 | 11 | export const parseFacultyDTO = (data: FacultyDTO): Faculty => ({ 12 | id: data.id, 13 | code: data.code, 14 | name: data.name, 15 | count: data.count, 16 | createdAt: data.created_at, 17 | }); 18 | 19 | export const parseFacultyDTOList = (data: FacultyDTO[]): Faculty[] => { 20 | return data.map(parseFacultyDTO); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIsUnderLargeViewport.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsMobileViewport = () => { 4 | const [isMobile, setIsMobile] = useState(() => window.innerWidth < 1024); 5 | 6 | useEffect(() => { 7 | const handleResize = () => { 8 | setIsMobile(window.innerWidth < 1024); 9 | }; 10 | 11 | window.addEventListener("resize", handleResize); 12 | 13 | return () => { 14 | window.removeEventListener("resize", handleResize); 15 | }; 16 | }, []); 17 | 18 | return { isMobile }; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/utils/formatTime.ts: -------------------------------------------------------------------------------- 1 | export const formatTime = (dateStr: string): string => { 2 | const date = new Date(dateStr); 3 | 4 | const day = date.getUTCDate().toString().padStart(2, "0"); 5 | const month = date.toLocaleString("en-US", { month: "short" }); 6 | const year = date.getUTCFullYear(); 7 | const hours = date.getUTCHours().toString().padStart(2, "0"); 8 | const minutes = date.getUTCMinutes().toString().padStart(2, "0"); 9 | 10 | const formattedDate = `${day} ${month} ${year} at ${hours}:${minutes}`; 11 | return formattedDate; 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /backend/.env.template: -------------------------------------------------------------------------------- 1 | ASPNETCORE_ENVIRONMENT=Development 2 | # ASPNETCORE_ENVIRONMENT=Production 3 | ASPNETCORE_URLS="http://localhost:5203" 4 | 5 | Web__Url=http://localhost:3000 6 | API__Key=apikey 7 | ConnectionStrings__Redis="localhost:6379,password=5678" 8 | TTL__Faculty=86400000 # 1 day 9 | TTL__Course=3600000 # 1 hour 10 | 11 | Firestore__DB=ourcourseville 12 | Firestore__Faculties=faculties_dev 13 | Firestore__Courses=courses_dev 14 | Firestore__Assignments=assignments_dev 15 | Firestore__Records=records_dev 16 | 17 | GOOGLE_APPLICATION_CREDENTIALS=firebase-adminsdk.json -------------------------------------------------------------------------------- /frontend/src/api/dto/record.dto.ts: -------------------------------------------------------------------------------- 1 | import { Problem, Record } from "@/src/types"; 2 | 3 | export type RecordDTO = { 4 | id: string; 5 | assignment_code: string; 6 | code: string; 7 | problems: Problem[]; 8 | created_at: string; 9 | }; 10 | 11 | export const parseRecordDTO = (data: RecordDTO): Record => ({ 12 | id: data.id, 13 | assignmentCode: data.assignment_code, 14 | problems: data.problems, 15 | createdAt: data.created_at, 16 | }); 17 | 18 | export const parseRecordDTOList = (data: RecordDTO[]): Record[] => { 19 | return data.map(parseRecordDTO); 20 | }; 21 | -------------------------------------------------------------------------------- /backend/Models/Assignment.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | 3 | namespace backend.DTO; 4 | 5 | [FirestoreData] 6 | public class Assignment 7 | { 8 | [FirestoreProperty] 9 | public string? ID { get; set; } 10 | [FirestoreProperty] 11 | public required string CourseCode { get; set; } 12 | [FirestoreProperty] 13 | public required string Code { get; set; } 14 | [FirestoreProperty] 15 | public required string Name { get; set; } 16 | [FirestoreProperty] 17 | public int Count { get; set; } = 0; 18 | [FirestoreProperty] 19 | public required Timestamp CreatedAt { get; set; } 20 | } -------------------------------------------------------------------------------- /extension/src/pages/popup/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC } from "react"; 4 | import { FaCopy } from "react-icons/fa"; 5 | 6 | interface CopyButtonProps { 7 | text: string; 8 | } 9 | 10 | export const CopyButton: FC = ({ text }) => { 11 | const handleClick = () => { 12 | navigator.clipboard.writeText(text); 13 | }; 14 | 15 | return ( 16 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/components/IconButton/ExtensionButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FaExternalLinkAlt } from "react-icons/fa"; 3 | 4 | import Link from "next/link"; 5 | import { FC } from "react"; 6 | 7 | interface ExtensionButtonProps { 8 | url: string; 9 | } 10 | 11 | export const ExtensionButton: FC = ({ url }) => { 12 | return ( 13 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/api/dto/assignment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Assignment } from "@/src/types"; 2 | 3 | export type AssignmentDTO = { 4 | id: string; 5 | course_code: string; 6 | code: string; 7 | name: string; 8 | count: number; 9 | created_at: string; 10 | }; 11 | 12 | export const parseAssignmentDTO = (data: AssignmentDTO): Assignment => ({ 13 | id: data.id, 14 | courseCode: data.course_code, 15 | code: data.code, 16 | name: data.name, 17 | count: data.count, 18 | createdAt: data.created_at, 19 | }); 20 | 21 | export const parseAssignmentDTOList = (data: AssignmentDTO[]): Assignment[] => { 22 | return data.map(parseAssignmentDTO); 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/api/dto/course.dto.ts: -------------------------------------------------------------------------------- 1 | import { Course } from "@/src/types"; 2 | 3 | export type CourseDTO = { 4 | id: string; 5 | faculty_code: string; 6 | code: string; 7 | icon: string; 8 | name: string; 9 | count: number; 10 | created_at: string; 11 | }; 12 | 13 | export const parseCourseDTO = (data: CourseDTO): Course => ({ 14 | id: data.id, 15 | facultyCode: data.faculty_code, 16 | code: data.code, 17 | icon: data.icon, 18 | name: data.name, 19 | count: data.count, 20 | createdAt: data.created_at, 21 | }); 22 | 23 | export const parseCourseDTOList = (data: CourseDTO[]): Course[] => { 24 | return data.map(parseCourseDTO); 25 | }; 26 | -------------------------------------------------------------------------------- /backend/DTO/AssignmentDTO.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using backend.Models; 3 | 4 | namespace backend.DTO; 5 | 6 | public record AssignmentDTO 7 | { 8 | [JsonPropertyName("id")] 9 | public string? ID { get; init; } 10 | [JsonPropertyName("course_code")] 11 | public required string CourseCode { get; init; } 12 | [JsonPropertyName("code")] 13 | public required string Code { get; init; } 14 | [JsonPropertyName("name")] 15 | public required string Name { get; init; } 16 | [JsonPropertyName("count")] 17 | public int Count { get; init; } = 0; 18 | [JsonPropertyName("created_at")] 19 | public DateTime? CreatedAt { get; init; } 20 | } -------------------------------------------------------------------------------- /extension/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": "latest", 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["react", "@typescript-eslint"], 21 | "rules": { 22 | "react/react-in-jsx-scope": "off" 23 | }, 24 | "globals": { 25 | "chrome": "readonly" 26 | }, 27 | "ignorePatterns": ["watch.js", "dist/**"] 28 | } 29 | -------------------------------------------------------------------------------- /backend/Models/Course.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | 3 | namespace backend.Models; 4 | 5 | [FirestoreData] 6 | public class Course 7 | { 8 | [FirestoreProperty] 9 | public string? ID { get; set; } 10 | [FirestoreProperty] 11 | public required string FacultyCode { get; set; } 12 | [FirestoreProperty] 13 | public required string Code { get; set; } 14 | [FirestoreProperty] 15 | public required string Icon { get; set; } 16 | [FirestoreProperty] 17 | public required string Name { get; set; } 18 | [FirestoreProperty] 19 | public int Count { get; set; } = 0; 20 | [FirestoreProperty] 21 | public required Timestamp CreatedAt { get; set; } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/useGetRecentCourses.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { cache } from "../cache/localStorage"; 3 | import { selectRecentCourses, setRecentCourses } from "../store/courseSlice"; 4 | import { useAppDispatch, useAppSelector } from "../store/store"; 5 | import { Course } from "../types"; 6 | 7 | export const useGetRecentCourses = () => { 8 | const dispatch = useAppDispatch(); 9 | const recentCourses = useAppSelector(selectRecentCourses); 10 | 11 | useEffect(() => { 12 | const res = cache.getItem("recentCourses"); 13 | if (res) { 14 | dispatch(setRecentCourses(res)); 15 | return; 16 | } 17 | }, []); 18 | 19 | return { recentCourses }; 20 | }; 21 | -------------------------------------------------------------------------------- /extension/src/pages/popup/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface InputProps { 4 | value: string; 5 | onChange: (e: React.ChangeEvent) => void; 6 | placeholder?: string; 7 | } 8 | 9 | export const Input: FC = ({ value, onChange, placeholder }) => { 10 | return ( 11 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FC, PropsWithChildren } from "react"; 3 | 4 | interface CardProps extends PropsWithChildren { 5 | href: string; 6 | onClick?: () => void; 7 | } 8 | 9 | export const Card: FC = ({ onClick, href, children }) => { 10 | return ( 11 | 12 |
16 | {children} 17 |
18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /backend/DTO/CourseDTO.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace backend.DTO; 4 | 5 | public record CourseDTO 6 | { 7 | [JsonPropertyName("id")] 8 | public string? ID { get; init; } 9 | [JsonPropertyName("faculty_code")] 10 | public required string FacultyCode { get; init; } 11 | [JsonPropertyName("code")] 12 | public required string Code { get; init; } 13 | [JsonPropertyName("icon")] 14 | public required string Icon { get; init; } 15 | [JsonPropertyName("name")] 16 | public required string Name { get; init; } 17 | [JsonPropertyName("count")] 18 | public int Count { get; init; } = 0; 19 | 20 | [JsonPropertyName("created_at")] 21 | public DateTime? CreatedAt { get; init; } 22 | } -------------------------------------------------------------------------------- /frontend/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface ButtonProps { 4 | onClick?: () => void; 5 | disabled?: boolean; 6 | text: string; 7 | } 8 | 9 | export const Button: FC = ({ text }) => { 10 | return ( 11 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Faculty = { 2 | id: string; 3 | code: string; 4 | name: string; 5 | count: number; 6 | createdAt: string; 7 | }; 8 | 9 | export type Course = { 10 | id: string; 11 | facultyCode: string; 12 | code: string; 13 | icon: string; 14 | name: string; 15 | count: number; 16 | createdAt: string; 17 | }; 18 | 19 | export type Assignment = { 20 | id: string; 21 | courseCode: string; 22 | code: string; 23 | name: string; 24 | count: number; 25 | createdAt: string; 26 | }; 27 | 28 | export type Record = { 29 | id: string; 30 | assignmentCode: string; 31 | problems: Problem[]; 32 | createdAt: string; 33 | }; 34 | 35 | export type Problem = { 36 | question: string; 37 | answer: string; 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "./src/**/*.ts", 27 | "./src/**/*.tsx", 28 | ".next/types/**/*.ts" 29 | ], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/IconButton/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "@/hooks/use-toast"; 4 | import { FC } from "react"; 5 | import { FaCopy } from "react-icons/fa"; 6 | 7 | interface CopyButtonProps { 8 | text: string; 9 | } 10 | 11 | export const CopyButton: FC = ({ text }) => { 12 | const handleClick = () => { 13 | navigator.clipboard.writeText(text); 14 | 15 | toast({ 16 | title: "Copied", 17 | description: text, 18 | }); 19 | }; 20 | 21 | return ( 22 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /backend/Models/Record.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | 3 | namespace backend.Models; 4 | 5 | [FirestoreData] 6 | public class Record 7 | { 8 | [FirestoreProperty] 9 | public string? ID { get; set; } 10 | [FirestoreProperty] 11 | public required string AssignmentCode { get; set; } 12 | [FirestoreProperty] 13 | public required List Problems { get; set; } 14 | [FirestoreProperty] 15 | public string? ProblemsHash { get; set; } 16 | [FirestoreProperty] 17 | public required Timestamp CreatedAt { get; set; } 18 | } 19 | 20 | [FirestoreData] 21 | public class Problem 22 | { 23 | [FirestoreProperty] 24 | public required string Question { get; set; } 25 | [FirestoreProperty] 26 | public required string Answer { get; set; } 27 | } -------------------------------------------------------------------------------- /extension/src/pages/popup/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface ButtonProps { 4 | onClick?: () => void; 5 | disabled?: boolean; 6 | text: string; 7 | } 8 | 9 | export const Button: FC = ({ onClick, text }) => { 10 | return ( 11 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /backend/Parsers/FacultyParser.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Parsers; 5 | 6 | public class FacultyParser 7 | { 8 | public static FacultyDTO ModelToDTO(Faculty faculty) => 9 | new FacultyDTO 10 | { 11 | ID = faculty.ID ?? string.Empty, 12 | Code = faculty.Code, 13 | Name = faculty.Name, 14 | Count = faculty.Count, 15 | CreatedAt = faculty.CreatedAt.ToDateTime() 16 | }; 17 | 18 | public static List ModelToDTOList(List faculties) 19 | { 20 | List dtos = new List(); 21 | foreach (Faculty faculty in faculties) 22 | { 23 | dtos.Add(ModelToDTO(faculty)); 24 | } 25 | 26 | return dtos; 27 | } 28 | } -------------------------------------------------------------------------------- /extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "types": ["vite/client", "node", "chrome"], 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@src/*": ["src/*"], 21 | "@assets/*": ["src/assets/*"], 22 | "@pages/*": ["src/pages/*"] 23 | } 24 | }, 25 | "include": ["src", 26 | "utils", "vite.config.ts"], 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 3 | import assignmentReducer from "./assignmentSlice"; 4 | import courseReducer from "./courseSlice"; 5 | import facultyReducer from "./facultySlice"; 6 | import recordReducer from "./recordSlice"; 7 | 8 | export const store = configureStore({ 9 | reducer: { 10 | faculty: facultyReducer, 11 | course: courseReducer, 12 | assignment: assignmentReducer, 13 | record: recordReducer, 14 | }, 15 | }); 16 | 17 | export type RootState = ReturnType; 18 | export type AppDispatch = typeof store.dispatch; 19 | 20 | export const useAppDispatch = () => useDispatch(); 21 | export const useAppSelector: TypedUseSelectorHook = useSelector; 22 | -------------------------------------------------------------------------------- /backend/Data/Firestore.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | using backend.Config; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace backend.Data; 6 | 7 | public class Firestore 8 | { 9 | private readonly FirestoreConfig _config; 10 | public FirestoreDb db; 11 | public CollectionReference faculties; 12 | public CollectionReference courses; 13 | public CollectionReference assignments; 14 | public CollectionReference records; 15 | 16 | public Firestore(IOptions config) 17 | { 18 | _config = config.Value; 19 | db = FirestoreDb.Create(_config.DB); 20 | faculties = db.Collection(_config.Faculties); 21 | courses = db.Collection(_config.Courses); 22 | assignments = db.Collection(_config.Assignments); 23 | records = db.Collection(_config.Records); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /extension/src/assets/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --default: #f1f5f9; 7 | --primary-default: #2b9afb; 8 | --primary-medium: #1d63b9; 9 | --primary-bg: #e6faff; 10 | --medium: #64748b; 11 | --dark: #94a3b8; 12 | --high: #0f172b; 13 | --secondary-default: #fba711; 14 | --success-default: #31c55e; 15 | --light: #f8fafc; 16 | } 17 | 18 | @layer utilities { 19 | .h1 { 20 | font-size: 2.25rem; 21 | } 22 | .h2 { 23 | font-size: 32px; 24 | line-height: 44px; 25 | } 26 | .h3 { 27 | font-size: 24px; 28 | line-height: 32px; 29 | } 30 | .h4 { 31 | font-size: 20px; 32 | line-height: 26px; 33 | } 34 | .h5 { 35 | font-size: 16px; 36 | line-height: 24px; 37 | } 38 | .h6 { 39 | font-size: 14px; 40 | line-height: 20px; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCourseByCode } from "@/src/api/course"; 2 | import { SideBar } from "@/src/components/SideBar"; 3 | import { getPathname } from "@/src/utils/getPathname"; 4 | import { FC, PropsWithChildren } from "react"; 5 | 6 | const AssignmentLayout: FC = async ({ children }) => { 7 | const pathname = getPathname(); 8 | const courseCode = pathname.split("/")[4]; 9 | 10 | const currentCourse = await getCourseByCode(courseCode); 11 | if (currentCourse instanceof Error) { 12 | return
Error: {currentCourse.message}
; 13 | } 14 | 15 | return ( 16 |
17 |
18 | 19 | {children} 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default AssignmentLayout; 26 | -------------------------------------------------------------------------------- /backend/Parsers/AssignmentParser.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | namespace backend.Parsers; 3 | 4 | public class AssignmentParser 5 | { 6 | public static AssignmentDTO ModelToDTO(Assignment assignment) => 7 | new AssignmentDTO 8 | { 9 | ID = assignment.ID ?? string.Empty, 10 | CourseCode = assignment.CourseCode, 11 | Code = assignment.Code, 12 | Name = assignment.Name, 13 | Count = assignment.Count, 14 | CreatedAt = assignment.CreatedAt.ToDateTime() 15 | }; 16 | 17 | public static List ModelToDTOList(List faculties) 18 | { 19 | List dtos = new List(); 20 | foreach (Assignment assignment in faculties) 21 | { 22 | dtos.Add(ModelToDTO(assignment)); 23 | } 24 | 25 | return dtos; 26 | } 27 | } -------------------------------------------------------------------------------- /backend/Parsers/CourseParser.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Parsers; 5 | 6 | public class CourseParser 7 | { 8 | public static CourseDTO ModelToDTO(Course course) => 9 | new CourseDTO 10 | { 11 | ID = course.ID ?? string.Empty, 12 | FacultyCode = course.FacultyCode, 13 | Code = course.Code, 14 | Icon = course.Icon, 15 | Name = course.Name, 16 | Count = course.Count, 17 | CreatedAt = course.CreatedAt.ToDateTime() 18 | }; 19 | 20 | public static List ModelToDTOList(List faculties) 21 | { 22 | List dtos = new List(); 23 | foreach (Course course in faculties) 24 | { 25 | dtos.Add(ModelToDTO(course)); 26 | } 27 | 28 | return dtos; 29 | } 30 | } -------------------------------------------------------------------------------- /frontend/src/components/Tab/Tab.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FC } from "react"; 3 | import { TabButton } from "./TabButton"; 4 | 5 | interface TabProps { 6 | currentIndex: number; 7 | items: { 8 | text: string; 9 | href: string; 10 | isEnabled: boolean; 11 | }[]; 12 | } 13 | 14 | export const Tab: FC = ({ currentIndex, items }) => { 15 | return ( 16 | <> 17 |
18 | {items.map((item, index) => ( 19 | 24 | 29 | 30 | ))} 31 |
32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /backend/backend.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ourcourseville", 4 | "description": "Productivity tool for students", 5 | "options_ui": { 6 | "page": "src/pages/options/index.html" 7 | }, 8 | "action": { 9 | "default_popup": "src/pages/popup/index.html", 10 | "default_icon": { 11 | "32": "icon-32.png" 12 | } 13 | }, 14 | "icons": { 15 | "128": "icon-128.png" 16 | }, 17 | "permissions": ["activeTab"], 18 | "content_scripts": [ 19 | { 20 | "matches": ["http://*/*", "https://*/*", ""], 21 | "js": ["src/pages/content/index.ts"], 22 | "css": ["contentStyle.css"] 23 | } 24 | ], 25 | "web_accessible_resources": [ 26 | { 27 | "resources": ["contentStyle.css", "icon-128.png", "icon-32.png"], 28 | "matches": [] 29 | } 30 | ], 31 | "api_url": "http://localhost:5203/api/v1", 32 | "api_key": "apikey" 33 | } 34 | -------------------------------------------------------------------------------- /extension/manifest.qa.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ourcourseville", 4 | "description": "Productivity tool for students", 5 | "options_ui": { 6 | "page": "src/pages/options/index.html" 7 | }, 8 | "action": { 9 | "default_popup": "src/pages/popup/index.html", 10 | "default_icon": { 11 | "32": "icon-32.png" 12 | } 13 | }, 14 | "icons": { 15 | "128": "icon-128.png" 16 | }, 17 | "permissions": ["activeTab"], 18 | "content_scripts": [ 19 | { 20 | "matches": ["http://*/*", "https://*/*", ""], 21 | "js": ["src/pages/content/index.ts"], 22 | "css": ["contentStyle.css"] 23 | } 24 | ], 25 | "web_accessible_resources": [ 26 | { 27 | "resources": ["contentStyle.css", "icon-128.png", "icon-32.png"], 28 | "matches": [] 29 | } 30 | ], 31 | "api_url": "http://localhost:5203/api/v1", 32 | "api_key": "apikey" 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/AttachmentFile.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FaFileArrowDown } from "react-icons/fa6"; 3 | 4 | import { FC } from "react"; 5 | 6 | interface AttachmentFileProps { 7 | url: string; 8 | } 9 | 10 | export const AttachmentFile: FC = ({ url }) => { 11 | const fileName = url.split("/").pop(); 12 | return ( 13 | 14 |
15 |
16 | 17 |
18 |
19 | {fileName} 20 |
21 |
22 |
23 |
24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/hooks/use-toast"; 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/src/components/ui/toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/cache/localStorage.ts: -------------------------------------------------------------------------------- 1 | class LocalStorageManager { 2 | setItem(key: string, value: object, ttl: number) { 3 | const now = new Date(); 4 | 5 | const item = { 6 | value: value, 7 | expiry: now.getTime() + ttl, // Current time + TTL (in milliseconds) 8 | }; 9 | 10 | localStorage.setItem(key, JSON.stringify(item)); 11 | } 12 | 13 | getItem(key: string): T | null { 14 | const itemStr = localStorage.getItem(key); 15 | 16 | if (!itemStr) { 17 | return null; 18 | } 19 | 20 | const item = JSON.parse(itemStr); 21 | const now = new Date(); 22 | 23 | if (now.getTime() > item.expiry) { 24 | localStorage.removeItem(key); 25 | return null; 26 | } 27 | 28 | return item.value; 29 | } 30 | 31 | deleteItem(key: string) { 32 | localStorage.removeItem(key); 33 | } 34 | 35 | clearAll() { 36 | localStorage.clear(); 37 | } 38 | } 39 | 40 | export const cache = new LocalStorageManager(); 41 | -------------------------------------------------------------------------------- /backend/Exceptions/ServiceException.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace backend.Exceptions; 5 | 6 | public class ServiceException : Exception 7 | { 8 | [JsonPropertyName("status_code")] 9 | public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.InternalServerError; 10 | 11 | public ServiceException(string message, HttpStatusCode statusCode) : base(message) 12 | { 13 | StatusCode = statusCode; 14 | } 15 | 16 | public ServiceException(string message, HttpStatusCode statusCode, Exception innerException) : base(message, innerException) 17 | { 18 | StatusCode = statusCode; 19 | } 20 | 21 | public JSONResponse ToJSON() 22 | { 23 | return new JSONResponse(Message); 24 | } 25 | } 26 | 27 | public class JSONResponse 28 | { 29 | [JsonPropertyName("message")] 30 | public string Message { get; set; } 31 | 32 | public JSONResponse(string message) 33 | { 34 | Message = message; 35 | } 36 | } -------------------------------------------------------------------------------- /frontend/src/api/faculty.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import { apiClient } from "./axios"; 3 | import { 4 | FacultyDTO, 5 | parseFacultyDTO, 6 | parseFacultyDTOList, 7 | } from "./dto/faculty.dto"; 8 | 9 | export const getAllFaculty = async () => { 10 | try { 11 | const res: AxiosResponse = await apiClient.get("/faculty"); 12 | res.data.sort((a, b) => a.code.localeCompare(b.code)); 13 | 14 | return parseFacultyDTOList(res.data); 15 | } catch (error) { 16 | console.error("Failed to get all faculty", error); 17 | return Error("Failed to get all faculty"); 18 | } 19 | }; 20 | 21 | export const getFacultyByCode = async (code: string) => { 22 | try { 23 | const res: AxiosResponse = await apiClient.get( 24 | `/faculty/${code}` 25 | ); 26 | 27 | return parseFacultyDTO(res.data); 28 | } catch (error) { 29 | console.error("Failed to get faculty by code", error); 30 | return Error("Failed to get faculty by code"); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/store/recordSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Record } from "../types"; 3 | import { RootState } from "./store"; 4 | 5 | interface RecordState { 6 | records: Record[]; 7 | currentRecord: Record | null; 8 | } 9 | 10 | const initialState: RecordState = { 11 | records: [], 12 | currentRecord: null, 13 | }; 14 | 15 | export const recordSlice = createSlice({ 16 | name: "record", 17 | initialState, 18 | reducers: { 19 | setRecords: (state, action: PayloadAction) => { 20 | state.records = action.payload; 21 | }, 22 | setCurrentRecord: (state, action: PayloadAction) => { 23 | state.currentRecord = action.payload; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { setRecords, setCurrentRecord } = recordSlice.actions; 29 | export const selectRecords = (state: RootState) => state.record.records; 30 | export const selectCurrentRecord = (state: RootState) => 31 | state.record.currentRecord; 32 | 33 | export default recordSlice.reducer; 34 | -------------------------------------------------------------------------------- /frontend/src/store/facultySlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Faculty } from "../types"; 3 | import { RootState } from "./store"; 4 | 5 | interface FacultyState { 6 | faculties: Faculty[]; 7 | currentFaculty: Faculty | null; 8 | } 9 | 10 | const initialState: FacultyState = { 11 | faculties: [], 12 | currentFaculty: null, 13 | }; 14 | 15 | export const facultySlice = createSlice({ 16 | name: "faculty", 17 | initialState, 18 | reducers: { 19 | setFaculties: (state, action: PayloadAction) => { 20 | state.faculties = action.payload; 21 | }, 22 | setCurrentFaculty: (state, action: PayloadAction) => { 23 | state.currentFaculty = action.payload; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { setFaculties, setCurrentFaculty } = facultySlice.actions; 29 | export const selectFaculties = (state: RootState) => state.faculty.faculties; 30 | export const selectCurrentFaculty = (state: RootState) => 31 | state.faculty.currentFaculty; 32 | 33 | export default facultySlice.reducer; 34 | -------------------------------------------------------------------------------- /frontend/src/api/record.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import { apiClient } from "./axios"; 3 | import { 4 | RecordDTO, 5 | parseRecordDTO, 6 | parseRecordDTOList, 7 | } from "./dto/record.dto"; 8 | 9 | export const getRecordByAssignment = async (facultyCode: string) => { 10 | try { 11 | const res: AxiosResponse = await apiClient.get( 12 | `/record/assignment/${facultyCode}` 13 | ); 14 | res.data.sort((a, b) => a.created_at.localeCompare(b.created_at)); 15 | 16 | return parseRecordDTOList(res.data); 17 | } catch (error) { 18 | console.error("Failed to get record by assignment code", error); 19 | return Error("Failed to get record by assignment code"); 20 | } 21 | }; 22 | 23 | export const getRecordByID = async (id: string) => { 24 | try { 25 | const res: AxiosResponse = await apiClient.get(`/record/${id}`); 26 | 27 | return parseRecordDTO(res.data); 28 | } catch (error) { 29 | console.error("Failed to get record by id", error); 30 | return Error("Failed to get record by id"); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /backend/custom/GlobalRoutePrefix.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ApplicationModels; 2 | 3 | public class GlobalRoutePrefixConvention : IApplicationModelConvention 4 | { 5 | private readonly AttributeRouteModel _centralPrefix; 6 | 7 | public GlobalRoutePrefixConvention(string prefix) 8 | { 9 | _centralPrefix = new AttributeRouteModel(new Microsoft.AspNetCore.Mvc.RouteAttribute(prefix)); 10 | } 11 | 12 | public void Apply(ApplicationModel application) 13 | { 14 | foreach (var controller in application.Controllers) 15 | { 16 | foreach (var selector in controller.Selectors) 17 | { 18 | if (selector.AttributeRouteModel == null) 19 | { 20 | selector.AttributeRouteModel = _centralPrefix; 21 | } 22 | else 23 | { 24 | selector.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(_centralPrefix, selector.AttributeRouteModel); 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/api/course.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import { apiClient } from "./axios"; 3 | import { 4 | CourseDTO, 5 | parseCourseDTO, 6 | parseCourseDTOList, 7 | } from "./dto/course.dto"; 8 | 9 | export const getCourseByFaculty = async (facultyCode: string) => { 10 | try { 11 | const res: AxiosResponse = await apiClient.get( 12 | `/course/faculty/${facultyCode}` 13 | ); 14 | res.data.sort((a, b) => a.name.localeCompare(b.name)); 15 | 16 | return parseCourseDTOList(res.data); 17 | } catch (error) { 18 | console.error("Failed to get course by faculty code", error); 19 | return Error("Failed to get course by faculty code"); 20 | } 21 | }; 22 | 23 | export const getCourseByCode = async (code: string) => { 24 | try { 25 | const res: AxiosResponse = await apiClient.get( 26 | `/course/${code}` 27 | ); 28 | 29 | return parseCourseDTO(res.data); 30 | } catch (error) { 31 | console.error("Failed to get course by code", error); 32 | return Error("Failed to get course by code"); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /backend/Repositories/CacheRepository.cs: -------------------------------------------------------------------------------- 1 | using backend.Repositories.Interfaces; 2 | using System.Text.Json; 3 | using StackExchange.Redis; 4 | 5 | namespace backend.Repositories; 6 | 7 | public class CacheRepository : ICacheRepository 8 | { 9 | private readonly IDatabase _cache; 10 | 11 | public CacheRepository(IConnectionMultiplexer connectionMultiplexer) 12 | { 13 | _cache = connectionMultiplexer.GetDatabase(); 14 | } 15 | 16 | public async Task SetAsync(string key, T value, TimeSpan expiration) 17 | { 18 | var jsonValue = JsonSerializer.Serialize(value); 19 | await _cache.StringSetAsync(key, jsonValue, expiration); 20 | } 21 | 22 | public async Task GetAsync(string key) 23 | { 24 | var jsonValue = await _cache.StringGetAsync(key); 25 | if (jsonValue.IsNull) 26 | { 27 | return default; 28 | } 29 | 30 | return JsonSerializer.Deserialize(jsonValue!); 31 | } 32 | 33 | public async Task RemoveAsync(string key) 34 | { 35 | await _cache.KeyDeleteAsync(key); 36 | } 37 | } -------------------------------------------------------------------------------- /frontend/src/app/faculty/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllFaculty } from "@/src/api/faculty"; 2 | import { FacultyCard } from "@/src/components/Card/FacultyCard"; 3 | import { Header } from "@/src/components/Header"; 4 | 5 | const FacultiesPage = async () => { 6 | const faculties = await getAllFaculty(); 7 | if (faculties instanceof Error) { 8 | return
Error: {faculties.message}
; 9 | } 10 | 11 | return ( 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {faculties.map((f) => ( 20 | 21 | ))} 22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default FacultiesPage; 29 | -------------------------------------------------------------------------------- /frontend/src/app/LoadState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC } from "react"; 4 | import { setCurrentAssignment } from "../store/assignmentSlice"; 5 | import { setCurrentCourse } from "../store/courseSlice"; 6 | import { setCurrentFaculty } from "../store/facultySlice"; 7 | import { setCurrentRecord } from "../store/recordSlice"; 8 | import { useAppDispatch } from "../store/store"; 9 | import { Assignment, Course, Faculty, Record } from "../types"; 10 | 11 | interface LoadStateProps { 12 | currentFaculty?: Faculty; 13 | currentCourse?: Course; 14 | currentAssignment?: Assignment; 15 | currentRecord?: Record; 16 | } 17 | 18 | export const LoadState: FC = ({ 19 | currentFaculty, 20 | currentCourse, 21 | currentAssignment, 22 | currentRecord, 23 | }) => { 24 | const dispatch = useAppDispatch(); 25 | 26 | if (currentFaculty) dispatch(setCurrentFaculty(currentFaculty)); 27 | if (currentCourse) dispatch(setCurrentCourse(currentCourse)); 28 | if (currentAssignment) dispatch(setCurrentAssignment(currentAssignment)); 29 | if (currentRecord) dispatch(setCurrentRecord(currentRecord)); 30 | 31 | return <>; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/utils/isUrl.ts: -------------------------------------------------------------------------------- 1 | export const isUrl = (url: string): boolean => { 2 | const urlPattern = new RegExp( 3 | "^(https?:\\/\\/)" + // protocol 4 | "((([a-z\\d]([a-z\\d-]*[a-z\\d])?)\\.)+([a-z]{2,}|[a-z\\d-]{2,})|" + // domain name 5 | "localhost|" + // localhost 6 | "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|" + // IP address (v4) 7 | "\\[([0-9a-f]{1,4}:){7,7}[0-9a-f]{1,4}\\]|" + // IP address (v6) 8 | "([0-9a-f]{1,4}:){1,7}:|([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:((:[0-9a-f]{1,4}){1,6}|:)|:((:[0-9a-f]{1,4}){1,7}|:)|fe80:(:[0-9a-f]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}" + // ipv6 9 | "((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))" + // ip v4 10 | "(:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // path 11 | "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string 12 | "(\\#[-a-z\\d_]*)?$", 13 | "i" // fragment locator 14 | ); 15 | 16 | return urlPattern.test(url); 17 | }; 18 | -------------------------------------------------------------------------------- /extension/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Jonathan Braat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /frontend/src/api/assignment.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import { apiClient } from "./axios"; 3 | import { 4 | AssignmentDTO, 5 | parseAssignmentDTO, 6 | parseAssignmentDTOList, 7 | } from "./dto/assignment.dto"; 8 | 9 | export const getAssignmentByCourse = async (facultyCode: string) => { 10 | try { 11 | const res: AxiosResponse = await apiClient.get( 12 | `/assignment/course/${facultyCode}` 13 | ); 14 | res.data.sort((a, b) => a.name.localeCompare(b.name)); 15 | 16 | return parseAssignmentDTOList(res.data); 17 | } catch (error) { 18 | console.error("Failed to get assignment by course code", error); 19 | return Error("Failed to get assignment by course code"); 20 | } 21 | }; 22 | 23 | export const getAssignmentByCode = async (code: string) => { 24 | try { 25 | const res: AxiosResponse = await apiClient.get( 26 | `/assignment/${code}` 27 | ); 28 | 29 | return parseAssignmentDTO(res.data); 30 | } catch (error) { 31 | console.error("Failed to get assignment by code", error); 32 | return Error("Failed to get assignment by code"); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /extension/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 3 | theme: { 4 | extend: { 5 | animation: { 6 | "spin-slow": "spin 20s linear infinite", 7 | }, 8 | colors: { 9 | default: "var(--default)", 10 | primary: { 11 | default: "var(--primary-default)", 12 | medium: "var(--primary-medium)", 13 | bg: "var(--primary-bg)", 14 | DEFAULT: "hsl(var(--primary))", 15 | foreground: "hsl(var(--primary-foreground))", 16 | }, 17 | secondary: { 18 | default: "var(--secondary-default)", 19 | DEFAULT: "hsl(var(--secondary))", 20 | foreground: "hsl(var(--secondary-foreground))", 21 | }, 22 | success: { 23 | default: "var(--success-default)", 24 | }, 25 | medium: "var(--medium)", 26 | dark: "var(--dark)", 27 | high: "var(--high)", 28 | light: "var(--light)", 29 | background: "hsl(var(--background))", 30 | foreground: "hsl(var(--foreground))", 31 | }, 32 | }, 33 | }, 34 | prefix: "", 35 | plugins: [], 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/store/assignmentSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Assignment } from "../types"; 3 | import { RootState } from "./store"; 4 | 5 | interface AssignmentState { 6 | assignments: Assignment[]; 7 | currentAssignment: Assignment | null; 8 | } 9 | 10 | const initialState: AssignmentState = { 11 | assignments: [], 12 | currentAssignment: null, 13 | }; 14 | 15 | export const assignmentSlice = createSlice({ 16 | name: "assignment", 17 | initialState, 18 | reducers: { 19 | setAssignments: (state, action: PayloadAction) => { 20 | state.assignments = action.payload; 21 | }, 22 | setCurrentAssignment: (state, action: PayloadAction) => { 23 | state.currentAssignment = action.payload; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { setAssignments, setCurrentAssignment } = assignmentSlice.actions; 29 | export const selectAssignments = (state: RootState) => 30 | state.assignment.assignments; 31 | export const selectCurrentAssignment = (state: RootState) => 32 | state.assignment.currentAssignment; 33 | 34 | export default assignmentSlice.reducer; 35 | -------------------------------------------------------------------------------- /frontend/src/components/Card/RecordCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "@/hooks/use-toast"; 4 | import { Record } from "@/src/types"; 5 | import { formatTime } from "@/src/utils/formatTime"; 6 | import { FC } from "react"; 7 | import { Card } from "."; 8 | 9 | interface RecordCardProps { 10 | href: string; 11 | record: Record; 12 | } 13 | 14 | export const RecordCard: FC = ({ href, record }) => { 15 | const { id, createdAt } = record; 16 | const formattedDate = formatTime(createdAt); 17 | 18 | const handleClick = () => { 19 | navigator.clipboard.writeText(record.id); 20 | 21 | toast({ 22 | title: "Copied", 23 | description: record.id, 24 | }); 25 | }; 26 | 27 | return ( 28 | 29 |
30 |
31 |
{id}
32 |
33 |
34 | {formattedDate} 35 |
36 |
37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /ourcourseville.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.002.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "backend", "backend\backend.csproj", "{B5D99B7D-6269-4358-AF16-21C55802BE56}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {B5D99B7D-6269-4358-AF16-21C55802BE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {B5D99B7D-6269-4358-AF16-21C55802BE56}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {B5D99B7D-6269-4358-AF16-21C55802BE56}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {B5D99B7D-6269-4358-AF16-21C55802BE56}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {14CD0BD8-FEC2-462F-9A83-28FAABCDC161} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/[assignmentcode]/RecordTabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Tab } from "@/src/components/Tab/Tab"; 4 | import { selectCurrentRecord } from "@/src/store/recordSlice"; 5 | import { useAppSelector } from "@/src/store/store"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | export const RecordTabs = () => { 9 | const pathname = usePathname(); 10 | const pathParts = pathname.split("/"); 11 | const facultyCode = pathParts[2]; 12 | const courseCode = pathParts[4]; 13 | const assignmentCode = pathParts[6]; 14 | const assignmentsPath = `/faculty/${facultyCode}/course/${courseCode}/assignment`; 15 | 16 | const currentRecord = useAppSelector(selectCurrentRecord); 17 | 18 | const currentTab = pathParts.length > 7 ? 1 : 0; 19 | const tabs = [ 20 | { 21 | text: "Records", 22 | href: `${assignmentsPath}/${assignmentCode}`, 23 | isEnabled: true, 24 | }, 25 | { 26 | text: "Solution", 27 | href: `${assignmentsPath}/${assignmentCode}/record/${currentRecord?.id}`, 28 | isEnabled: currentRecord !== null, 29 | }, 30 | ]; 31 | 32 | return ; 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-icons": "^1.3.0", 13 | "@radix-ui/react-popover": "^1.1.2", 14 | "@radix-ui/react-toast": "^1.2.2", 15 | "@reduxjs/toolkit": "^2.2.7", 16 | "axios": "^1.7.7", 17 | "class-variance-authority": "^0.7.0", 18 | "clsx": "^2.1.1", 19 | "lucide-react": "^0.447.0", 20 | "next": "14.2.14", 21 | "react": "^18", 22 | "react-dom": "^18", 23 | "react-icons": "^5.3.0", 24 | "react-redux": "^9.1.2", 25 | "tailwind-merge": "^2.5.3", 26 | "tailwindcss-animate": "^1.0.7" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^20", 30 | "@types/react": "^18", 31 | "@types/react-dom": "^18", 32 | "eslint": "^8", 33 | "eslint-config-next": "14.2.14", 34 | "postcss": "^8", 35 | "prettier": "^3.3.3", 36 | "prettier-plugin-organize-imports": "^4.1.0", 37 | "prettier-plugin-tailwindcss": "^0.6.8", 38 | "tailwindcss": "^3.4.1", 39 | "typescript": "^5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/components/Tab/TabButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FC } from "react"; 3 | 4 | interface TabButtonProps { 5 | text: string; 6 | isActive: boolean; 7 | isEnabled: boolean; 8 | } 9 | 10 | export const TabButton: FC = ({ 11 | text, 12 | isActive, 13 | isEnabled, 14 | }) => { 15 | return ( 16 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/[assignmentcode]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getRecordByAssignment } from "@/src/api/record"; 2 | import { RecordCard } from "@/src/components/Card/RecordCard"; 3 | import { getPathname } from "@/src/utils/getPathname"; 4 | 5 | const AssignmentPage = async () => { 6 | const pathname = getPathname(); 7 | const assignmentCode = pathname.split("/")[6]; 8 | 9 | const records = await getRecordByAssignment(assignmentCode); 10 | if (records instanceof Error) { 11 | return
Error: {records.message}
; 12 | } 13 | 14 | return ( 15 |
16 |
17 |

Records

18 |

Created At

19 |
20 |
21 | {records.map((r) => ( 22 | 27 | ))} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default AssignmentPage; 34 | -------------------------------------------------------------------------------- /backend/Config/Config.cs: -------------------------------------------------------------------------------- 1 | namespace backend.Config; 2 | 3 | public class FirestoreConfig 4 | { 5 | public const string Firestore = "Firestore"; 6 | public required string DB { get; set; } 7 | public required string Faculties { get; set; } 8 | public required string Courses { get; set; } 9 | public required string Assignments { get; set; } 10 | public required string Records { get; set; } 11 | } 12 | 13 | public class TTLConfig 14 | { 15 | public const string TTL = "TTL"; 16 | 17 | private int? facultyTTL; 18 | private int? courseTTL; 19 | 20 | public required int Faculty 21 | { 22 | get => facultyTTL ?? throw new ArgumentNullException(nameof(facultyTTL)); 23 | set 24 | { 25 | facultyTTL = value; 26 | FacultyTTL = TimeSpan.FromSeconds(value); 27 | } 28 | } 29 | 30 | public required int Course 31 | { 32 | get => courseTTL ?? throw new ArgumentNullException(nameof(courseTTL)); 33 | set 34 | { 35 | courseTTL = value; 36 | CourseTTL = TimeSpan.FromSeconds(value); 37 | } 38 | } 39 | 40 | public TimeSpan FacultyTTL { get; private set; } 41 | public TimeSpan CourseTTL { get; private set; } 42 | } -------------------------------------------------------------------------------- /backend/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:15772", 8 | "sslPort": 44301 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5030", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7154;http://localhost:5030", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/docker-compose.qa.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | ourcourseville: 5 | image: ghcr.io/bookpanda/ourcourseville:latest 6 | container_name: ourcourseville 7 | restart: unless-stopped 8 | environment: 9 | ASPNETCORE_ENVIRONMENT: Production 10 | ASPNETCORE_URLS: "http://+:5203" 11 | API__Key: apikey 12 | ConnectionStrings__Redis: "cache:6379,password=5678" 13 | TTL__Faculty: 86400 # 1 day 14 | TTL__Course: 3600 # 1 hour 15 | Firestore__DB: ourcourseville 16 | Firestore__Faculties: faculties_dev 17 | Firestore__Courses: courses_dev 18 | Firestore__Assignments: assignments_dev 19 | Firestore__Records: records_dev 20 | GOOGLE_APPLICATION_CREDENTIALS: firebase-adminsdk.json 21 | volumes: 22 | - ./firebase-adminsdk.json:/app/firebase-adminsdk.json 23 | networks: 24 | - ourcourseville 25 | ports: 26 | - "5203:5203" 27 | 28 | cache: 29 | image: redis 30 | container_name: cache 31 | restart: unless-stopped 32 | environment: 33 | REDIS_HOST: localhost 34 | REDIS_PASSWORD: "5678" 35 | networks: 36 | - ourcourseville 37 | expose: 38 | - 6379 39 | 40 | networks: 41 | ourcourseville: 42 | name: ourcourseville 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: 7 | - closed 8 | branches: 9 | - main 10 | - dev 11 | push: 12 | branches: 13 | - main 14 | - dev 15 | tags: 16 | - v* 17 | 18 | env: 19 | IMAGE_NAME: ghcr.io/${{ github.repository }} 20 | 21 | jobs: 22 | build: 23 | name: Build 24 | runs-on: ubuntu-latest 25 | 26 | permissions: 27 | contents: write 28 | packages: write 29 | 30 | steps: 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v2 33 | 34 | - name: Log in to the Container Registry 35 | uses: docker/login-action@v2 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.actor }} 39 | password: ${{ github.token }} 40 | 41 | - name: Build and Push Docker Image 42 | uses: docker/build-push-action@v3 43 | with: 44 | file: ./backend/Dockerfile.ci 45 | push: true 46 | tags: ${{ env.IMAGE_NAME }}:${{ github.ref_type == 'tag' && github.ref_name || github.sha }},${{ env.IMAGE_NAME }}:latest 47 | cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache 48 | cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max 49 | -------------------------------------------------------------------------------- /backend/DTO/RecordDTO.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using backend.Models; 3 | 4 | namespace backend.DTO; 5 | 6 | public record CreateRecordDTO 7 | { 8 | [JsonPropertyName("course_code")] 9 | public required string CourseCode { get; init; } 10 | [JsonPropertyName("course_id")] 11 | public required string CourseID { get; init; } 12 | [JsonPropertyName("course")] 13 | public required string Course { get; init; } 14 | [JsonPropertyName("course_icon")] 15 | public string? CourseIcon { get; init; } 16 | [JsonPropertyName("assignment_code")] 17 | public required string AssignmentCode { get; init; } 18 | [JsonPropertyName("assignment")] 19 | public required string Assignment { get; init; } 20 | [JsonPropertyName("problems")] 21 | public required List Problems { get; init; } 22 | } 23 | 24 | public record RecordDTO 25 | { 26 | [JsonPropertyName("id")] 27 | public string? ID { get; init; } 28 | [JsonPropertyName("url")] 29 | public string? url { get; init; } 30 | [JsonPropertyName("assignment_code")] 31 | public required string AssignmentCode { get; init; } 32 | [JsonPropertyName("problems")] 33 | public required List Problems { get; init; } 34 | [JsonPropertyName("created_at")] 35 | public DateTime? CreatedAt { get; init; } 36 | } -------------------------------------------------------------------------------- /backend/custom/MyConfigServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using backend.Services.Interfaces; 2 | using backend.Services; 3 | using FirebaseAdmin; 4 | using backend.Config; 5 | using backend.Data; 6 | using backend.Repositories.Interfaces; 7 | using backend.Repositories; 8 | 9 | namespace Microsoft.Extensions.DependencyInjection; 10 | 11 | public static class MyConfigServiceCollectionExtensions 12 | { 13 | public static IServiceCollection AddConfig(this IServiceCollection services, IConfiguration config) 14 | { 15 | services.Configure(config.GetSection(FirestoreConfig.Firestore)); 16 | services.Configure(config.GetSection(TTLConfig.TTL)); 17 | 18 | return services; 19 | } 20 | public static IServiceCollection AddMyDependencyGroup(this IServiceCollection services) 21 | { 22 | services.AddSingleton(FirebaseApp.Create()); 23 | services.AddSingleton(); 24 | 25 | services.AddSingleton(); 26 | 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | services.AddScoped(); 30 | services.AddScoped(); 31 | 32 | services.AddScoped(); 33 | 34 | return services; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/Parsers/RecordParser.cs: -------------------------------------------------------------------------------- 1 | using backend.DTO; 2 | using backend.Models; 3 | 4 | namespace backend.Parsers; 5 | 6 | public class RecordParser 7 | { 8 | public static RecordDTO ModelToCreatedDTO(Record record, CreateRecordDTO createRecord, string webUrl) 9 | { 10 | string facultyCode = createRecord.CourseCode.Substring(0, 2); 11 | return new RecordDTO 12 | { 13 | ID = record.ID ?? string.Empty, 14 | url = $"{webUrl}/faculty/{facultyCode}/course/{createRecord.CourseCode}/assignment/{createRecord.AssignmentCode}/record/{record.ID}", 15 | AssignmentCode = record.AssignmentCode, 16 | Problems = record.Problems, 17 | CreatedAt = record.CreatedAt.ToDateTime() 18 | }; 19 | } 20 | 21 | public static RecordDTO ModelToDTO(Record record) => 22 | new RecordDTO 23 | { 24 | ID = record.ID ?? string.Empty, 25 | AssignmentCode = record.AssignmentCode, 26 | Problems = record.Problems, 27 | CreatedAt = record.CreatedAt.ToDateTime() 28 | }; 29 | 30 | public static List ModelToDTOList(List faculties) 31 | { 32 | List dtos = new List(); 33 | foreach (Record record in faculties) 34 | { 35 | dtos.Add(ModelToDTO(record)); 36 | } 37 | 38 | return dtos; 39 | } 40 | } -------------------------------------------------------------------------------- /frontend/src/app/HowTo.tsx: -------------------------------------------------------------------------------- 1 | import { FaListCheck } from "react-icons/fa6"; 2 | 3 | export const HowTo = () => { 4 | return ( 5 |
6 |
7 |
8 | 9 |
10 | How to use extension 11 |
12 |
13 |
14 |
19 |
20 |
  • Download and unzip the extension from this web
  • 21 |
  • 22 | Go to{" "} 23 | chrome://extensions/{" "} 24 | (you can copy and paste this link in your browser) 25 |
  • 26 |
  • 27 | Click Load unpacked and 28 | select the unzipped folder 29 |
  • 30 |
  • Now you can share your ideas with your friends
  • 31 |
    32 |
    33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /backend/Data/Seed/FirestoreSeeder.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using backend.DTO; 3 | using backend.Services.Interfaces; 4 | 5 | public class FirestoreSeeder 6 | { 7 | private readonly IFacultyService _facultySvc; 8 | 9 | public FirestoreSeeder(IFacultyService facultySvc) 10 | { 11 | _facultySvc = facultySvc; 12 | } 13 | 14 | public async Task SeedFaculties() 15 | { 16 | var jsonData = await File.ReadAllTextAsync("Data/Seed/faculties.json"); 17 | var faculties = JsonSerializer.Deserialize>(jsonData); 18 | if (faculties == null) return; 19 | 20 | foreach (var faculty in faculties) 21 | { 22 | if (faculty == null) continue; 23 | 24 | try 25 | { 26 | var found = await _facultySvc.FindByCode(faculty.Code); 27 | if (found != null) continue; 28 | } 29 | catch 30 | { 31 | Console.WriteLine($"Faculty with code: {faculty.Code} not found"); 32 | } 33 | 34 | try 35 | { 36 | await _facultySvc.Create(faculty); 37 | Console.WriteLine($"Added faculty with code: {faculty.Code}"); 38 | } 39 | catch 40 | { 41 | Console.WriteLine($"Error creating faculty with code: {faculty.Code}"); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/components/SideBar/SideBarItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import clsx from "clsx"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { FC } from "react"; 7 | 8 | interface SideBarItemProps { 9 | icon: JSX.Element; 10 | text: string; 11 | href: string; 12 | } 13 | 14 | export const SideBarItem: FC = ({ icon, text, href }) => { 15 | const pathname = usePathname(); 16 | const isActive = pathname === href; 17 | 18 | return ( 19 | 20 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCourseByFaculty } from "@/src/api/course"; 2 | import { CourseCard } from "@/src/components/Card/CourseCard"; 3 | import { Header } from "@/src/components/Header"; 4 | import { getPathname } from "@/src/utils/getPathname"; 5 | 6 | const CoursesPage = async () => { 7 | const pathname = getPathname(); 8 | const facultyCode = pathname.split("/")[2]; 9 | 10 | const courses = await getCourseByFaculty(facultyCode); 11 | if (courses instanceof Error) { 12 | return
    Error: {courses.message}
    ; 13 | } 14 | 15 | return ( 16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 | {courses.map((c) => ( 24 | 29 | ))} 30 |
    31 |
    32 |
    33 | ); 34 | }; 35 | 36 | export default CoursesPage; 37 | -------------------------------------------------------------------------------- /frontend/src/components/Card/FacultyCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { setCurrentFaculty } from "@/src/store/facultySlice"; 4 | import { useAppDispatch } from "@/src/store/store"; 5 | import { Faculty } from "@/src/types"; 6 | import { FC } from "react"; 7 | import { Card } from "."; 8 | 9 | interface FacultyCardProps { 10 | faculty: Faculty; 11 | } 12 | 13 | export const FacultyCard: FC = ({ faculty }) => { 14 | const dispatch = useAppDispatch(); 15 | const { code, name, count } = faculty; 16 | const href = `/faculty/${code}/course`; 17 | 18 | const handleClick = () => { 19 | dispatch(setCurrentFaculty(faculty)); 20 | }; 21 | 22 | return ( 23 | 24 |
    25 |
    26 |
    27 |
    28 | {code} 29 |
    30 |
    31 | {name} 32 |
    33 |
    34 |
      35 |
      36 |
      37 |
      38 |
      Courses: {count}
      39 |
      40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /backend/Middlewares/ApiKeyMiddleware.cs: -------------------------------------------------------------------------------- 1 | public class ApiKeyMiddleware 2 | { 3 | private readonly RequestDelegate _next; 4 | private readonly string _apiKey; 5 | private const string ApiKeyPrefix = "Bearer "; 6 | 7 | public ApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) 8 | { 9 | _next = next; 10 | _apiKey = configuration.GetValue("API:Key") ?? throw new ArgumentNullException("API:Key"); 11 | } 12 | 13 | public async Task Invoke(HttpContext context) 14 | { 15 | if (!context.Request.Headers.TryGetValue("Authorization", out var extractedApiKey)) 16 | { 17 | context.Response.StatusCode = 401; 18 | await context.Response.WriteAsync("Authorization header is missing"); 19 | return; 20 | } 21 | 22 | if (!extractedApiKey.ToString().StartsWith(ApiKeyPrefix)) 23 | { 24 | context.Response.StatusCode = 401; 25 | await context.Response.WriteAsync("Invalid Authorization header format"); 26 | return; 27 | } 28 | 29 | var token = extractedApiKey.ToString().Substring(ApiKeyPrefix.Length); // Remove the "Bearer " prefix 30 | if (!string.Equals(token, _apiKey)) 31 | { 32 | context.Response.StatusCode = 401; 33 | await context.Response.WriteAsync("Unauthorized client"); 34 | return; 35 | } 36 | 37 | await _next(context); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { FaHouse } from "react-icons/fa6"; 2 | import { getAllFaculty } from "../api/faculty"; 3 | import { HowTo } from "./HowTo"; 4 | import { Intro } from "./Intro"; 5 | import { RecentCourses } from "./RecentCourses"; 6 | 7 | const Home = async () => { 8 | const faculties = await getAllFaculty(); 9 | if (faculties instanceof Error) { 10 | return
      Error: {faculties.message}
      ; 11 | } 12 | 13 | return ( 14 |
      15 |
      16 |
      17 |
      18 | 19 |
      20 | Home 21 |
      22 |
      23 | 24 |
      25 |
      26 | 27 |
      28 |
      29 | 30 |
      31 |
      32 |
      33 |
      34 |
      35 | ); 36 | }; 37 | 38 | export default Home; 39 | -------------------------------------------------------------------------------- /frontend/src/app/Intro.tsx: -------------------------------------------------------------------------------- 1 | import { FaQuestion } from "react-icons/fa"; 2 | 3 | export const Intro = () => { 4 | return ( 5 |
      6 |
      7 |
      8 | 9 |
      10 | What is OurCourseVille 11 |
      12 |
      13 |
      14 |
      19 |
      20 |
    • Tired of taking screenshots of assignments?
    • 21 |
    • 22 | You can now "Share solution" of your assignment using our 23 | Chrome extension 24 |
    • 25 |
    • Share once, learn anywhere
    • 26 |
    • 27 | We do NOT condone any form of cheating or plagiarism, we only provide 28 | a platform for students to collaborate and share their ideas. 29 |
    • 30 |
    • 31 | We do NOT collect any Personally Identifiable Information data from 32 | you, we only collect solutions to the assignments. 33 |
    • 34 |
      35 |
      36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /extension/src/pages/content/scripts/fillin.ts: -------------------------------------------------------------------------------- 1 | import { RecordDTO } from "@src/types"; 2 | 3 | export const fillin = async (record: RecordDTO) => { 4 | console.log("Success:", record); 5 | const mainElements = document.querySelectorAll("main"); 6 | const innerMain = mainElements[1]; 7 | const form = innerMain.querySelectorAll("form")[0]; 8 | 9 | const problems = Array.from(form.children).filter( 10 | (child) => child.tagName === "DIV" 11 | ); 12 | 13 | const solutions = record.problems; 14 | 15 | problems.forEach((p, i) => { 16 | const questionDiv = p.children[0].children[0]; 17 | const questionP = questionDiv.getElementsByTagName("p"); 18 | const textArray = Array.from(questionP).map((para) => 19 | para.textContent?.trim() 20 | ); 21 | const question = textArray.join(" "); 22 | 23 | const answer = 24 | solutions.find((solution) => solution.question === question)?.answer ?? 25 | "No answer found"; 26 | console.log(`answer for question ${question} (${i + 1}):`, answer); 27 | const inputs = p.querySelectorAll("input"); 28 | 29 | inputs.forEach((input) => { 30 | if (input.type === "text") { 31 | input.value = answer; 32 | } else if (input.type === "radio") { 33 | if (input.value === answer) { 34 | input.click(); 35 | } 36 | } 37 | }); 38 | 39 | const buttons = p.querySelectorAll("button"); 40 | buttons.forEach((button) => { 41 | if (button.value === answer) { 42 | button.click(); 43 | } 44 | }); 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /.github/workflows/ext-ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Zip Extension 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: 7 | - closed 8 | branches: 9 | - main 10 | - dev 11 | push: 12 | branches: 13 | - main 14 | - dev 15 | tags: 16 | - v* 17 | 18 | jobs: 19 | build: 20 | name: Build 21 | timeout-minutes: 15 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out code 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 2 28 | 29 | - name: Change directory to "extension" 30 | run: cd extension 31 | 32 | - name: Setup Node.js environment 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: 18 36 | 37 | - name: Install Bun 38 | uses: oven-sh/setup-bun@v2 39 | with: 40 | bun-version: 1.1.26 41 | 42 | - name: Update manifest.json 43 | working-directory: ./extension 44 | run: | 45 | sed -i 's|"api_url": ".*"|"api_url": "${{ secrets.API_URL }}"|' manifest.json 46 | sed -i 's|"api_key": ".*"|"api_key": "${{ secrets.API_KEY }}"|' manifest.json 47 | 48 | - name: Install dependencies 49 | working-directory: ./extension 50 | run: bun install 51 | 52 | - name: Build 53 | working-directory: ./extension 54 | run: bun run build 55 | 56 | - name: Upload extension artifacts 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: ourcourseville-chrome-extension 60 | path: extension/dist 61 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar/NavItem.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import Link from "next/link"; 3 | import { FC, PropsWithChildren } from "react"; 4 | 5 | interface NavItemProps extends PropsWithChildren { 6 | isSelected: boolean; 7 | isEnabled: boolean; 8 | isMobile: boolean; 9 | href: string; 10 | text: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const NavItem: FC = ({ 15 | isEnabled, 16 | isSelected, 17 | isMobile, 18 | href, 19 | text, 20 | children, 21 | }) => { 22 | if (isMobile) 23 | return ( 24 | 31 | {isEnabled && ( 32 | <> 33 | {children} 34 | {text} 35 | 36 | )} 37 | 38 | ); 39 | 40 | return ( 41 | 50 |
      58 | {children} 59 | {text} 60 |
      61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-web-extension", 3 | "version": "1.2.0", 4 | "description": "A simple chrome extension template with Vite, React, TypeScript and Tailwind CSS.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/JohnBra/web-extension.git" 9 | }, 10 | "scripts": { 11 | "build": "vite build", 12 | "dev": "nodemon" 13 | }, 14 | "type": "module", 15 | "dependencies": { 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-icons": "^5.3.0", 19 | "webextension-polyfill": "^0.11.0" 20 | }, 21 | "devDependencies": { 22 | "@crxjs/vite-plugin": "2.0.0-beta.30", 23 | "@types/chrome": "^0.0.268", 24 | "@types/node": "^20.12.11", 25 | "@types/react": "^18.3.1", 26 | "@types/react-dom": "^18.3.0", 27 | "@types/webextension-polyfill": "^0.10.7", 28 | "@typescript-eslint/eslint-plugin": "^7.8.0", 29 | "@typescript-eslint/parser": "^7.8.0", 30 | "@vitejs/plugin-react": "^4.2.1", 31 | "autoprefixer": "^10.4.19", 32 | "eslint": "^8.57.0", 33 | "eslint-config-prettier": "^9.1.0", 34 | "eslint-plugin-import": "^2.29.1", 35 | "eslint-plugin-jsx-a11y": "^6.8.0", 36 | "eslint-plugin-react": "^7.34.1", 37 | "eslint-plugin-react-hooks": "^4.6.2", 38 | "fs-extra": "^11.2.0", 39 | "nodemon": "^3.1.0", 40 | "postcss": "^8.4.38", 41 | "prettier": "^3.3.3", 42 | "prettier-plugin-organize-imports": "^4.1.0", 43 | "prettier-plugin-tailwindcss": "^0.6.8", 44 | "tailwindcss": "^3.4.3", 45 | "ts-node": "^10.9.2", 46 | "typescript": "^5.4.5", 47 | "vite": "^5.2.11" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/Tile/AssignmentTile.tsx: -------------------------------------------------------------------------------- 1 | import { Assignment } from "@/src/types"; 2 | import { formatTime } from "@/src/utils/formatTime"; 3 | import Link from "next/link"; 4 | import { FC } from "react"; 5 | import { FaUser } from "react-icons/fa6"; 6 | import { Tile } from "."; 7 | import { Badge } from "../Badge"; 8 | import { Button } from "../Button"; 9 | 10 | interface AssignmentTileProps { 11 | href: string; 12 | assignment: Assignment; 13 | } 14 | 15 | export const AssignmentTile: FC = ({ 16 | href, 17 | assignment, 18 | }) => { 19 | const { code, name, count, createdAt } = assignment; 20 | 21 | const formattedDate = formatTime(createdAt); 22 | 23 | return ( 24 | 25 |
      26 |
      27 | 31 |
      {name}
      32 |
      33 |
      34 | {count} 35 |
      36 |
      37 | {formattedDate} 38 |
      39 |
      40 |
      41 |
      42 |
      43 | 44 |
      45 | 46 |
      49 |
      50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /backend/Controllers/AssignmentController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using backend.Services.Interfaces; 3 | using backend.DTO; 4 | using backend.Exceptions; 5 | using backend.Parsers; 6 | 7 | namespace backend.Controllers; 8 | 9 | 10 | [Route("assignment")] 11 | [ApiController] 12 | public class AssignmentController : ControllerBase 13 | { 14 | private readonly IAssignmentService _assignmentSvc; 15 | private readonly ILogger _log; 16 | 17 | public AssignmentController(IAssignmentService assignmentSvc, ILogger log) 18 | { 19 | _assignmentSvc = assignmentSvc; 20 | _log = log; 21 | } 22 | 23 | [HttpGet("course/{code}")] 24 | public async Task FindAssignmentByCourseCode(string code) 25 | { 26 | try 27 | { 28 | var assignments = await _assignmentSvc.FindByCourseCode(code); 29 | 30 | return Ok(AssignmentParser.ModelToDTOList(assignments)); 31 | } 32 | catch (ServiceException ex) 33 | { 34 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 35 | } 36 | } 37 | 38 | [HttpGet("{code}")] 39 | public async Task FindAssignmentByCode(string code) 40 | { 41 | try 42 | { 43 | var assignment = await _assignmentSvc.FindByCode(code); 44 | if (assignment == null) 45 | { 46 | return NotFound(new JSONResponse($"No assignment with code {code} found")); 47 | } 48 | 49 | return Ok(AssignmentParser.ModelToDTO(assignment)); 50 | } 51 | catch (ServiceException ex) 52 | { 53 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /backend/Program.cs: -------------------------------------------------------------------------------- 1 | using DotNetEnv; 2 | using StackExchange.Redis; 3 | 4 | Env.Load(); 5 | string environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; 6 | 7 | WebApplicationOptions options = new WebApplicationOptions 8 | { 9 | EnvironmentName = environment 10 | }; 11 | var builder = WebApplication.CreateBuilder(args); 12 | 13 | builder.Configuration.AddEnvironmentVariables(); 14 | 15 | var redisConnString = builder.Configuration.GetConnectionString("Redis"); 16 | builder.Services.AddSingleton(ConnectionMultiplexer.Connect(redisConnString ?? "")); 17 | 18 | builder.Services.AddControllers(opt => 19 | { 20 | opt.Conventions.Insert(0, new GlobalRoutePrefixConvention("api/v1")); 21 | }); 22 | 23 | builder.Services.AddEndpointsApiExplorer(); 24 | builder.Services.AddHealthChecks(); 25 | builder.Services.AddSwaggerGen(); 26 | 27 | builder.Services 28 | .AddConfig(builder.Configuration) 29 | .AddMyDependencyGroup(); 30 | 31 | builder.Services.AddCors(options => 32 | { 33 | options.AddPolicy("AllowAll", 34 | policy => policy.AllowAnyOrigin() 35 | .AllowAnyMethod() 36 | .AllowAnyHeader()); 37 | }); 38 | 39 | 40 | var app = builder.Build(); 41 | app.UseCors("AllowAll"); 42 | 43 | if (args.Contains("seed")) 44 | { 45 | using var scope = app.Services.CreateScope(); 46 | var seeder = scope.ServiceProvider.GetRequiredService(); 47 | await seeder.SeedFaculties(); 48 | return; 49 | } 50 | 51 | if (app.Environment.IsDevelopment()) 52 | { 53 | app.UseSwagger(); 54 | app.UseSwaggerUI(); 55 | } 56 | 57 | app.UseMiddleware(); 58 | 59 | app.UseHttpsRedirection(); 60 | app.UseRouting(); 61 | 62 | app.MapHealthChecks("/healthz"); 63 | app.MapControllers(); 64 | app.Run(); -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAssignmentByCourse } from "@/src/api/assignment"; 2 | import { AssignmentTile } from "@/src/components/Tile/AssignmentTile"; 3 | import { getPathname } from "@/src/utils/getPathname"; 4 | import { FaFileSignature } from "react-icons/fa6"; 5 | 6 | const AssignmentsPage = async () => { 7 | const pathname = getPathname(); 8 | const courseCode = pathname.split("/")[4]; 9 | 10 | const assignments = await getAssignmentByCourse(courseCode); 11 | if (assignments instanceof Error) { 12 | return
      Error: {assignments.message}
      ; 13 | } 14 | 15 | return ( 16 |
      17 |
      18 |
      19 | 23 |

      Assignments

      24 |
      25 |
      26 |
      27 |

      Assignments

      28 |

      Records Count

      29 |

      Created At

      30 |
      31 |
      32 | {assignments.map((assignment) => ( 33 | 38 | ))} 39 |
      40 |
      41 |
      42 |
      43 | ); 44 | }; 45 | 46 | export default AssignmentsPage; 47 | -------------------------------------------------------------------------------- /frontend/src/components/Card/CourseCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { pushRecentCourses, setCurrentCourse } from "@/src/store/courseSlice"; 4 | import { setCurrentFaculty } from "@/src/store/facultySlice"; 5 | import { useAppDispatch } from "@/src/store/store"; 6 | import { Course, Faculty } from "@/src/types"; 7 | import Image from "next/image"; 8 | import { FC } from "react"; 9 | import { Card } from "."; 10 | 11 | interface CourseCardProps { 12 | href: string; 13 | course: Course; 14 | faculty?: Faculty; 15 | } 16 | 17 | export const CourseCard: FC = ({ href, course, faculty }) => { 18 | const { code, name, count } = course; 19 | const dispatch = useAppDispatch(); 20 | 21 | const handleClick = () => { 22 | if (faculty) dispatch(setCurrentFaculty(faculty)); 23 | dispatch(setCurrentCourse(course)); 24 | dispatch(pushRecentCourses(course)); 25 | }; 26 | 27 | return ( 28 | 29 |
      30 |
      31 | logo 39 |
      40 |
      41 |
      42 |
      43 | {code} 44 |
      45 |
      46 | {name} 47 |
      48 |
      49 |
        50 |
        51 |
        52 |
        53 |
        Assignments: {count}
        54 |
        55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/app/RecentCourses.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC } from "react"; 4 | import { FaChalkboard } from "react-icons/fa6"; 5 | import { CourseCard } from "../components/Card/CourseCard"; 6 | import { useGetRecentCourses } from "../hooks/useGetRecentCourses"; 7 | import { Faculty } from "../types"; 8 | 9 | interface RecentCoursesProps { 10 | faculties: Faculty[]; 11 | } 12 | 13 | export const RecentCourses: FC = ({ faculties }) => { 14 | const { recentCourses } = useGetRecentCourses(); 15 | 16 | if (recentCourses.length === 0) { 17 | return null; 18 | } 19 | 20 | return ( 21 |
        22 |
        23 |
        24 |
        25 |
        26 | 27 |
        28 | Recent 29 |
        30 |
        31 |
        32 |
        37 |
        38 | {recentCourses.map((c) => { 39 | const faculty = faculties.find((f) => f.code === c.facultyCode); 40 | return ( 41 | 47 | ); 48 | })} 49 |
        50 |
        51 |
        52 |
        53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/[assignmentcode]/record/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getRecordByID } from "@/src/api/record"; 2 | import { AttachmentFile } from "@/src/components/AttachmentFile"; 3 | import { Badge } from "@/src/components/Badge"; 4 | import { CopyButton } from "@/src/components/IconButton/CopyButton"; 5 | import { Problem } from "@/src/types"; 6 | import { formatTime } from "@/src/utils/formatTime"; 7 | import { getPathname } from "@/src/utils/getPathname"; 8 | import { isUrl } from "@/src/utils/isUrl"; 9 | 10 | const RecordPage = async () => { 11 | const pathname = getPathname(); 12 | const recordID = pathname.split("/")[8]; 13 | 14 | const currentRecord = await getRecordByID(recordID); 15 | if (currentRecord instanceof Error) { 16 | return
        Error: {currentRecord.message}
        ; 17 | } 18 | const { id, createdAt, problems } = currentRecord; 19 | 20 | const formattedDate = formatTime(createdAt); 21 | 22 | const problemDiv = (p: Problem) => ( 23 |
        24 |
        {p.question}
        25 | {isUrl(p.answer) ? ( 26 | 27 | ) : ( 28 |
        29 | {p.answer} 30 |
        31 | )} 32 |
        33 | ); 34 | 35 | return ( 36 | <> 37 |
        38 |
        39 | 40 | 41 |
        42 |
        {formattedDate}
        43 |
        44 |
        45 | {problems.map((problem) => problemDiv(problem))} 46 |
        47 | 48 | ); 49 | }; 50 | 51 | export default RecordPage; 52 | -------------------------------------------------------------------------------- /frontend/src/app/faculty/[facultycode]/course/[coursecode]/assignment/[assignmentcode]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getAssignmentByCode } from "@/src/api/assignment"; 2 | import { getPathname } from "@/src/utils/getPathname"; 3 | import Link from "next/link"; 4 | import { FC, PropsWithChildren } from "react"; 5 | import { RecordTabs } from "./RecordTabs"; 6 | 7 | const AssignmentLayout: FC = async ({ children }) => { 8 | const pathname = getPathname(); 9 | const pathParts = pathname.split("/"); 10 | const facultyCode = pathParts[2]; 11 | const courseCode = pathParts[4]; 12 | const assignmentCode = pathParts[6]; 13 | const assignmentsPath = `/faculty/${facultyCode}/course/${courseCode}/assignment`; 14 | 15 | const currentAssignment = await getAssignmentByCode(assignmentCode); 16 | if (currentAssignment instanceof Error) { 17 | return
        Error: {currentAssignment.message}
        ; 18 | } 19 | 20 | const breadcrumb = () => ( 21 |
        22 |
        23 |
        24 | 25 |
        26 | Assignment 27 |
        28 | 29 |
        {">"}
        30 |
        31 |
        {currentAssignment.name}
        32 |
        33 |
        34 | ); 35 | 36 | return ( 37 |
        38 |
        39 | {breadcrumb()} 40 | 41 |
        42 |

        43 | {currentAssignment.name} 44 |

        45 |
        46 |
        47 | {children} 48 |
        49 |
        50 | ); 51 | }; 52 | 53 | export default AssignmentLayout; 54 | -------------------------------------------------------------------------------- /backend/Controllers/FacultyController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using backend.Services.Interfaces; 3 | using backend.DTO; 4 | using backend.Exceptions; 5 | using backend.Parsers; 6 | 7 | namespace backend.Controllers; 8 | 9 | 10 | [Route("faculty")] 11 | [ApiController] 12 | public class FacultyController : ControllerBase 13 | { 14 | private readonly IFacultyService _facultySvc; 15 | private readonly ILogger _log; 16 | 17 | public FacultyController(IFacultyService facultySvc, ILogger log) 18 | { 19 | _facultySvc = facultySvc; 20 | _log = log; 21 | } 22 | 23 | [HttpPost] 24 | public async Task CreateFaculty([FromBody] FacultyDTO facultyDTO) 25 | { 26 | try 27 | { 28 | var faculty = await _facultySvc.Create(facultyDTO); 29 | return Ok(FacultyParser.ModelToDTO(faculty)); 30 | } 31 | catch (ServiceException ex) 32 | { 33 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 34 | } 35 | } 36 | 37 | [HttpGet] 38 | public async Task FindAllFaculty() 39 | { 40 | try 41 | { 42 | var faculties = await _facultySvc.FindAll(); 43 | return Ok(FacultyParser.ModelToDTOList(faculties)); 44 | } 45 | catch (ServiceException ex) 46 | { 47 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 48 | } 49 | } 50 | 51 | [HttpGet("{code}")] 52 | public async Task FindFacultyByCode(string code) 53 | { 54 | try 55 | { 56 | var faculty = await _facultySvc.FindByCode(code); 57 | if (faculty == null) 58 | { 59 | return NotFound(new JSONResponse($"No faculty with code {code} found")); 60 | } 61 | 62 | return Ok(FacultyParser.ModelToDTO(faculty)); 63 | } 64 | catch (ServiceException ex) 65 | { 66 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /extension/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { crx, ManifestV3Export } from "@crxjs/vite-plugin"; 2 | import react from "@vitejs/plugin-react"; 3 | import fs from "fs"; 4 | import { resolve } from "path"; 5 | import { defineConfig } from "vite"; 6 | 7 | import devManifest from "./manifest.dev.json"; 8 | import manifest from "./manifest.json"; 9 | import pkg from "./package.json"; 10 | 11 | const root = resolve(__dirname, "src"); 12 | const pagesDir = resolve(root, "pages"); 13 | const assetsDir = resolve(root, "assets"); 14 | const outDir = resolve(__dirname, "dist"); 15 | const publicDir = resolve(__dirname, "public"); 16 | 17 | const isDev = process.env.__DEV__ === "true"; 18 | 19 | const extensionManifest = { 20 | ...manifest, 21 | ...(isDev ? devManifest : ({} as ManifestV3Export)), 22 | name: isDev ? `DEV: ${manifest.name}` : manifest.name, 23 | version: pkg.version, 24 | }; 25 | 26 | // plugin to remove dev icons from prod build 27 | function stripDevIcons(apply: boolean) { 28 | if (apply) return null; 29 | 30 | return { 31 | name: "strip-dev-icons", 32 | resolveId(source: string) { 33 | return source === "virtual-module" ? source : null; 34 | }, 35 | renderStart(outputOptions: any, inputOptions: any) { 36 | const outDir = outputOptions.dir; 37 | fs.rm(resolve(outDir, "dev-icon-32.png"), () => 38 | console.log(`Deleted dev-icon-32.png frm prod build`) 39 | ); 40 | fs.rm(resolve(outDir, "dev-icon-128.png"), () => 41 | console.log(`Deleted dev-icon-128.png frm prod build`) 42 | ); 43 | }, 44 | }; 45 | } 46 | 47 | export default defineConfig({ 48 | resolve: { 49 | alias: { 50 | "@src": root, 51 | "@assets": assetsDir, 52 | "@pages": pagesDir, 53 | }, 54 | }, 55 | plugins: [ 56 | react(), 57 | crx({ 58 | manifest: extensionManifest as ManifestV3Export, 59 | contentScripts: { 60 | injectCss: true, 61 | }, 62 | }), 63 | stripDevIcons(isDev), 64 | ], 65 | publicDir, 66 | build: { 67 | outDir, 68 | sourcemap: isDev, 69 | emptyOutDir: !isDev, 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /backend/Controllers/CourseController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using backend.Services.Interfaces; 3 | using backend.DTO; 4 | using backend.Exceptions; 5 | using backend.Parsers; 6 | 7 | namespace backend.Controllers; 8 | 9 | 10 | [Route("course")] 11 | [ApiController] 12 | public class CourseController : ControllerBase 13 | { 14 | private readonly ICourseService _courseSvc; 15 | private readonly ILogger _log; 16 | 17 | public CourseController(ICourseService courseSvc, ILogger log) 18 | { 19 | _courseSvc = courseSvc; 20 | _log = log; 21 | } 22 | 23 | [HttpPost] 24 | public async Task CreateCourse([FromBody] CourseDTO courseDTO) 25 | { 26 | try 27 | { 28 | var course = await _courseSvc.Create(courseDTO); 29 | return Ok(CourseParser.ModelToDTO(course)); 30 | } 31 | catch (ServiceException ex) 32 | { 33 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 34 | } 35 | } 36 | 37 | [HttpGet("faculty/{facultyCode}")] 38 | public async Task FindCourseByFacultyCode(string facultyCode) 39 | { 40 | try 41 | { 42 | var faculties = await _courseSvc.FindByFacultyCode(facultyCode); 43 | return Ok(CourseParser.ModelToDTOList(faculties)); 44 | } 45 | catch (ServiceException ex) 46 | { 47 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 48 | } 49 | } 50 | 51 | [HttpGet("{code}")] 52 | public async Task FindCourseByCode(string code) 53 | { 54 | try 55 | { 56 | var course = await _courseSvc.FindByCode(code); 57 | if (course == null) 58 | { 59 | return NotFound(new JSONResponse($"No course with code {code} found")); 60 | } 61 | 62 | return Ok(CourseParser.ModelToDTO(course)); 63 | } 64 | catch (ServiceException ex) 65 | { 66 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /extension/src/pages/content/api.ts: -------------------------------------------------------------------------------- 1 | import { RecordDTO, ScrapeRecord } from "@src/types"; 2 | 3 | const apiUrl: string = chrome.runtime.getManifest().api_url; 4 | const apiKey: string = chrome.runtime.getManifest().api_key; 5 | 6 | export const saveRecord = async (record: ScrapeRecord) => { 7 | const dto = messageToDTO(record); 8 | 9 | try { 10 | const response = await fetch(`${apiUrl}/record`, { 11 | method: "POST", 12 | headers: { 13 | Authorization: `Bearer ${apiKey}`, 14 | "Content-Type": "application/json", 15 | }, 16 | body: JSON.stringify(dto), 17 | }); 18 | 19 | if (!response.ok) { 20 | throw new Error(`HTTP error! status: ${response.status}`); 21 | } 22 | 23 | const result: RecordDTO = await response.json(); 24 | return result; 25 | } catch (error) { 26 | console.error("Error:", error); 27 | throw error; 28 | } 29 | }; 30 | 31 | export const getRecord = async (recordID: string) => { 32 | console.log("getRecord", recordID); 33 | try { 34 | const response = await fetch(`${apiUrl}/record/${recordID}`, { 35 | method: "GET", 36 | headers: { 37 | Authorization: `Bearer ${apiKey}`, 38 | }, 39 | }); 40 | 41 | if (!response.ok) { 42 | throw new Error(`HTTP error! status: ${response.status}`); 43 | } 44 | 45 | const result: RecordDTO = await response.json(); 46 | return result; 47 | } catch (error) { 48 | console.error("Error:", error); 49 | throw error; 50 | } 51 | }; 52 | 53 | type CreateRecord = { 54 | course_code: string; 55 | course_id: string; 56 | course_icon: string; 57 | course: string; 58 | assignment_code: string; 59 | assignment: string; 60 | problems: { 61 | question: string; 62 | answer: string; 63 | }[]; 64 | }; 65 | 66 | const messageToDTO = (record: ScrapeRecord): CreateRecord => { 67 | return { 68 | course_code: record.courseCode, 69 | course_id: record.courseID, 70 | course_icon: record.courseIcon, 71 | course: record.course, 72 | assignment_code: record.assignmentCode, 73 | assignment: record.assignment, 74 | problems: record.problems, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /frontend/src/store/courseSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { cache } from "../cache/localStorage"; 3 | import { RECENT_COURSES_TTL } from "../config/config"; 4 | import { Course } from "../types"; 5 | import { RootState } from "./store"; 6 | 7 | interface CourseState { 8 | courses: Course[]; 9 | currentCourse: Course | null; 10 | recentCourses: Course[]; 11 | } 12 | 13 | const initialState: CourseState = { 14 | courses: [], 15 | currentCourse: null, 16 | recentCourses: [], 17 | }; 18 | 19 | export const courseSlice = createSlice({ 20 | name: "course", 21 | initialState, 22 | reducers: { 23 | setCourses: (state, action: PayloadAction) => { 24 | state.courses = action.payload; 25 | }, 26 | setCurrentCourse: (state, action: PayloadAction) => { 27 | state.currentCourse = action.payload; 28 | }, 29 | setRecentCourses: (state, action: PayloadAction) => { 30 | state.recentCourses = action.payload; 31 | }, 32 | pushRecentCourses: (state, action: PayloadAction) => { 33 | // Prevent duplicate courses 34 | const index = state.recentCourses.findIndex( 35 | (course) => course.id === action.payload.id 36 | ); 37 | if (index !== -1) { 38 | // Move the course to the top 39 | state.recentCourses.splice(index, 1); 40 | state.recentCourses = [action.payload, ...state.recentCourses]; 41 | return; 42 | } 43 | 44 | state.recentCourses = [action.payload, ...state.recentCourses]; 45 | if (state.recentCourses.length > 3) { 46 | state.recentCourses.pop(); 47 | } 48 | 49 | cache.setItem("recentCourses", state.recentCourses, RECENT_COURSES_TTL); 50 | }, 51 | }, 52 | }); 53 | 54 | export const { 55 | setCourses, 56 | setCurrentCourse, 57 | setRecentCourses, 58 | pushRecentCourses, 59 | } = courseSlice.actions; 60 | export const selectCourses = (state: RootState) => state.course.courses; 61 | export const selectCurrentCourse = (state: RootState) => 62 | state.course.currentCourse; 63 | export const selectRecentCourses = (state: RootState) => 64 | state.course.recentCourses; 65 | 66 | export default courseSlice.reducer; 67 | -------------------------------------------------------------------------------- /extension/src/pages/content/index.ts: -------------------------------------------------------------------------------- 1 | import { getRecord, saveRecord } from "./api"; 2 | import { fillin } from "./scripts/fillin"; 3 | import { scrape } from "./scripts/scrape"; 4 | 5 | async function share(url: string) { 6 | const warnings: string[] = []; 7 | 8 | const scrapeRecord = scrape(url); 9 | 10 | const record = await saveRecord(scrapeRecord); 11 | 12 | return { record, scrapeRecord, warnings }; 13 | } 14 | 15 | async function load(recordID: string) { 16 | const warnings: string[] = []; 17 | 18 | const record = await getRecord(recordID); 19 | //warnings when cannot load 20 | await fillin(record); 21 | 22 | return { record, warnings }; 23 | } 24 | 25 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 26 | async function runHandler() { 27 | try { 28 | console.log({ message }); 29 | 30 | if (message.action !== "share" && message.action !== "load") { 31 | throw new Error(`Unknown action: ${message.action}`); 32 | } 33 | 34 | if (message.action === "share") { 35 | const url: string | undefined = message.url; 36 | if (!url) { 37 | throw new Error("no url provided"); 38 | } 39 | 40 | const { record, scrapeRecord, warnings } = await share(url!); 41 | 42 | sendResponse({ 43 | status: "success", 44 | message: `Shared assignment: ${scrapeRecord.assignment} (${record.problems.length} problems)`, 45 | record: record, 46 | warning: warnings.join("\n"), 47 | }); 48 | } else if (message.action === "load") { 49 | const recordID: string | undefined = message.recordID; 50 | if (!recordID) { 51 | throw new Error("no recordID provided"); 52 | } 53 | 54 | const { record, warnings } = await load(recordID!); 55 | 56 | sendResponse({ 57 | status: "success", 58 | message: `Loaded assignment with record ${record.id} (${record.problems.length} problems)`, 59 | warning: warnings.join("\n"), 60 | }); 61 | 62 | return; 63 | } 64 | } catch (e) { 65 | console.error(e); 66 | sendResponse({ status: "error", message: `${e}` }); 67 | } 68 | } 69 | 70 | runHandler(); 71 | return true; 72 | }); 73 | 74 | console.log("ourcourseville content script loaded"); 75 | -------------------------------------------------------------------------------- /backend/Controllers/RecordController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using backend.Services.Interfaces; 3 | using backend.DTO; 4 | using backend.Exceptions; 5 | using backend.Parsers; 6 | 7 | namespace backend.Controllers; 8 | 9 | 10 | [Route("record")] 11 | [ApiController] 12 | public class RecordController : ControllerBase 13 | { 14 | private readonly string _webUrl; 15 | private readonly IRecordService _recordSvc; 16 | private readonly ILogger _log; 17 | 18 | public RecordController(IRecordService recordSvc, ILogger log, IConfiguration configuration) 19 | { 20 | _webUrl = configuration.GetValue("Web:Url") ?? throw new ArgumentNullException("Web:Url"); 21 | _recordSvc = recordSvc; 22 | _log = log; 23 | } 24 | 25 | [HttpPost] 26 | public async Task CreateRecord([FromBody] CreateRecordDTO recordDTO) 27 | { 28 | try 29 | { 30 | var record = await _recordSvc.Create(recordDTO); 31 | return Ok(RecordParser.ModelToCreatedDTO(record, recordDTO, _webUrl)); 32 | } 33 | catch (ServiceException ex) 34 | { 35 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 36 | } 37 | } 38 | 39 | [HttpGet("{id}")] 40 | public async Task FindOneRecord(string id) 41 | { 42 | try 43 | { 44 | var record = await _recordSvc.FindOne(id); 45 | if (record == null) 46 | { 47 | return NotFound(new JSONResponse($"No record with id {id} found")); 48 | } 49 | 50 | return Ok(RecordParser.ModelToDTO(record)); 51 | } 52 | catch (ServiceException ex) 53 | { 54 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 55 | } 56 | } 57 | 58 | [HttpGet("assignment/{asgmCode}")] 59 | public async Task FindRecordByAssignmentID(string asgmCode) 60 | { 61 | try 62 | { 63 | var records = await _recordSvc.FindByAssignmentCode(asgmCode); 64 | 65 | return Ok(RecordParser.ModelToDTOList(records)); 66 | } 67 | catch (ServiceException ex) 68 | { 69 | return StatusCode((int)ex.StatusCode, ex.ToJSON()); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /frontend/src/components/SideBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Course } from "@/src/types"; 2 | import { getPathname } from "@/src/utils/getPathname"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { FC } from "react"; 6 | import { FaArrowLeft } from "react-icons/fa"; 7 | import { FaFileSignature, FaHouse } from "react-icons/fa6"; 8 | import { SideBarItem } from "./SideBarItem"; 9 | 10 | interface SideBarProps { 11 | course: Course | null; 12 | } 13 | 14 | export const SideBar: FC = async ({ course }) => { 15 | const pathname = getPathname(); 16 | const facultyCode = pathname.split("/")[2]; 17 | const courseCode = pathname.split("/")[4]; 18 | 19 | if (!course) { 20 | return null; 21 | } 22 | 23 | const { code, name, icon } = course; 24 | 25 | return ( 26 |
        27 |
        28 | 29 | 36 | 37 |
        38 |
        39 | desktop course icon 47 |
        48 |
        49 | {code} 50 |
        51 |
        {name}
        52 |
        53 |
        54 |
        55 | } text="Home" href="/" /> 56 | } 58 | text="Assignments" 59 | href={`/faculty/${facultyCode}/course/${courseCode}/assignment`} 60 | /> 61 |
        62 |
        63 |
        64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ourcourseville 2 | Platform for students to publish and access assignments' solutions in order to enhance their learning experience and collaboration. 3 | 4 | ## Frontend 5 | ### Stack 6 | - react 7 | - redux 8 | - tailwindcss 9 | 10 | ### Prerequisites 11 | - bun v1.1 12 | - node v20 13 | 14 | ### Setting up 15 | 1. Copy `.env.template` and paste it in the same directory as `.env.local` and fill in the values. 16 | ```bash 17 | NEXT_PUBLIC_RECENT_COURSES_TTL=31536000000 # 1 year 18 | API_URL=http://localhost:5203 # backend url 19 | API_KEY=apikey # backend api key 20 | EXTENSION_URL= # extension download url in homepage 21 | ``` 22 | 2. Install dependencies and run 23 | ```bash 24 | bun install 25 | bun dev 26 | ``` 27 | 28 | ## Backend 29 | ### Stack 30 | - ASP.NET Core 8 31 | - Firestore 32 | - Redis 33 | 34 | ### Prerequisites 35 | - .NET 8 36 | 37 | ### Setting up 38 | 1. Copy `.env.template` and paste it in the same directory as `.env` and fill in the values. 39 | ```bash 40 | ASPNETCORE_ENVIRONMENT=Development 41 | ASPNETCORE_URLS="http://localhost:5203" 42 | 43 | Web__Url=http://localhost:3000 # frontend url 44 | API__Key=apikey # api key 45 | ConnectionStrings__Redis="localhost:6379,password=5678" 46 | TTL__Faculty=86400000 # 1 day 47 | TTL__Course=3600000 # 1 hour 48 | 49 | Firestore__DB=ourcourseville 50 | # Firestore collections 51 | Firestore__Faculties=faculties_dev 52 | Firestore__Courses=courses_dev 53 | Firestore__Assignments=assignments_dev 54 | Firestore__Records=records_dev 55 | 56 | GOOGLE_APPLICATION_CREDENTIALS=firebase-adminsdk.json # path to firebase admin sdk 57 | ``` 58 | 2. Install dependencies 59 | ```bash 60 | dotnet restore 61 | ``` 62 | 3. Create a service account in Firebase and download the json file. Set the path to the json file in the `GOOGLE_APPLICATION_CREDENTIALS` environment variable. 63 | 4. Run the backend 64 | ```bash 65 | dotnet run watch 66 | ``` 67 | 68 | ## Extension 69 | ### Stack 70 | - react 71 | - tailwindcss 72 | 73 | ### Prerequisites 74 | - bun v1.1 75 | - node v20 76 | 77 | ### Setting up 78 | ```bash 79 | bun install 80 | 81 | # this will run nodemon and watch for changes (generates the build in the `dist` folder, you can load the extension in chrome by going to `chrome://extensions/` and enabling developer mode) 82 | bun dev 83 | ``` 84 | 85 | ## Credits 86 | - [Chrome Extension Boilerplate](https://github.com/JohnBra/vite-web-extension) 87 | - [MCV Quiz AI Solver](https://github.com/leomotors/mcv-quiz-ai-solver) 88 | 89 | ## Contributing 90 | Feel free to open PRs or raise issues! -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 6 | theme: { 7 | extend: { 8 | fontSize: { 9 | "2xl": ["32px", { lineHeight: "44px" }], 10 | xl: ["24px", { lineHeight: "32px" }], 11 | lg: ["20px", { lineHeight: "26px" }], 12 | }, 13 | colors: { 14 | default: "var(--default)", 15 | primary: { 16 | default: "var(--primary-default)", 17 | medium: "var(--primary-medium)", 18 | bg: "var(--primary-bg)", 19 | DEFAULT: "hsl(var(--primary))", 20 | foreground: "hsl(var(--primary-foreground))", 21 | }, 22 | secondary: { 23 | default: "var(--secondary-default)", 24 | DEFAULT: "hsl(var(--secondary))", 25 | foreground: "hsl(var(--secondary-foreground))", 26 | }, 27 | success: { 28 | default: "var(--success-default)", 29 | }, 30 | medium: "var(--medium)", 31 | dark: "var(--dark)", 32 | high: "var(--high)", 33 | light: "var(--light)", 34 | background: "hsl(var(--background))", 35 | foreground: "hsl(var(--foreground))", 36 | card: { 37 | DEFAULT: "hsl(var(--card))", 38 | foreground: "hsl(var(--card-foreground))", 39 | }, 40 | popover: { 41 | DEFAULT: "hsl(var(--popover))", 42 | foreground: "hsl(var(--popover-foreground))", 43 | }, 44 | muted: { 45 | DEFAULT: "hsl(var(--muted))", 46 | foreground: "hsl(var(--muted-foreground))", 47 | }, 48 | accent: { 49 | DEFAULT: "hsl(var(--accent))", 50 | foreground: "hsl(var(--accent-foreground))", 51 | }, 52 | destructive: { 53 | DEFAULT: "hsl(var(--destructive))", 54 | foreground: "hsl(var(--destructive-foreground))", 55 | }, 56 | border: "hsl(var(--border))", 57 | input: "hsl(var(--input))", 58 | ring: "hsl(var(--ring))", 59 | chart: { 60 | "1": "hsl(var(--chart-1))", 61 | "2": "hsl(var(--chart-2))", 62 | "3": "hsl(var(--chart-3))", 63 | "4": "hsl(var(--chart-4))", 64 | "5": "hsl(var(--chart-5))", 65 | }, 66 | }, 67 | borderRadius: { 68 | lg: "var(--radius)", 69 | md: "calc(var(--radius) - 2px)", 70 | sm: "calc(var(--radius) - 4px)", 71 | }, 72 | }, 73 | }, 74 | plugins: [require("tailwindcss-animate")], 75 | }; 76 | export default config; 77 | -------------------------------------------------------------------------------- /extension/src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --default: #f1f5f9; 7 | --primary-default: #2b9afb; 8 | --primary-medium: #1d63b9; 9 | --primary-bg: #e6faff; 10 | --medium: #64748b; 11 | --dark: #94a3b8; 12 | --high: #0f172b; 13 | --secondary-default: #fba711; 14 | --success-default: #31c55e; 15 | --light: #f8fafc; 16 | } 17 | 18 | @layer utilities { 19 | .h1 { 20 | font-size: 2.25rem; 21 | } 22 | .h2 { 23 | font-size: 32px; 24 | line-height: 44px; 25 | } 26 | .h3 { 27 | font-size: 24px; 28 | line-height: 32px; 29 | } 30 | .h4 { 31 | font-size: 20px; 32 | line-height: 26px; 33 | } 34 | .h5 { 35 | font-size: 16px; 36 | line-height: 24px; 37 | } 38 | .h6 { 39 | font-size: 14px; 40 | line-height: 20px; 41 | } 42 | } 43 | 44 | @layer base { 45 | :root { 46 | --background: 0 0% 100%; 47 | --foreground: 0 0% 3.9%; 48 | --card: 0 0% 100%; 49 | --card-foreground: 0 0% 3.9%; 50 | --popover: 0 0% 100%; 51 | --popover-foreground: 0 0% 3.9%; 52 | --primary: 0 0% 9%; 53 | --primary-foreground: 0 0% 98%; 54 | --secondary: 0 0% 96.1%; 55 | --secondary-foreground: 0 0% 9%; 56 | --muted: 0 0% 96.1%; 57 | --muted-foreground: 0 0% 45.1%; 58 | --accent: 0 0% 96.1%; 59 | --accent-foreground: 0 0% 9%; 60 | --destructive: 0 84.2% 60.2%; 61 | --destructive-foreground: 0 0% 98%; 62 | --border: 0 0% 89.8%; 63 | --input: 0 0% 89.8%; 64 | --ring: 0 0% 3.9%; 65 | --chart-1: 12 76% 61%; 66 | --chart-2: 173 58% 39%; 67 | --chart-3: 197 37% 24%; 68 | --chart-4: 43 74% 66%; 69 | --chart-5: 27 87% 67%; 70 | --radius: 0.5rem; 71 | } 72 | .dark { 73 | --background: 0 0% 3.9%; 74 | --foreground: 0 0% 98%; 75 | --card: 0 0% 3.9%; 76 | --card-foreground: 0 0% 98%; 77 | --popover: 0 0% 3.9%; 78 | --popover-foreground: 0 0% 98%; 79 | --primary: 0 0% 98%; 80 | --primary-foreground: 0 0% 9%; 81 | --secondary: 0 0% 14.9%; 82 | --secondary-foreground: 0 0% 98%; 83 | --muted: 0 0% 14.9%; 84 | --muted-foreground: 0 0% 63.9%; 85 | --accent: 0 0% 14.9%; 86 | --accent-foreground: 0 0% 98%; 87 | --destructive: 0 62.8% 30.6%; 88 | --destructive-foreground: 0 0% 98%; 89 | --border: 0 0% 14.9%; 90 | --input: 0 0% 14.9%; 91 | --ring: 0 0% 83.1%; 92 | --chart-1: 220 70% 50%; 93 | --chart-2: 160 60% 45%; 94 | --chart-3: 30 80% 55%; 95 | --chart-4: 280 65% 60%; 96 | --chart-5: 340 75% 55%; 97 | } 98 | } 99 | 100 | @layer base { 101 | * { 102 | @apply border-border; 103 | } 104 | body { 105 | @apply bg-background text-foreground; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /backend/Data/Seed/faculties.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "code": "01", 4 | "name": "THE SIRINDHORN THAI LANGUAGE INSTITUTE" 5 | }, 6 | { 7 | "code": "02", 8 | "name": "OFFICE OF ACADEMIC AFFAIRS" 9 | }, 10 | { 11 | "code": "20", 12 | "name": "GRADUATE SCHOOL" 13 | }, 14 | { 15 | "code": "21", 16 | "name": "FACULTY OF ENGINEERING" 17 | }, 18 | { 19 | "code": "22", 20 | "name": "FACULTY OF ARTS" 21 | }, 22 | { 23 | "code": "23", 24 | "name": "FACULTY OF SCIENCE" 25 | }, 26 | { 27 | "code": "24", 28 | "name": "FACULTY OF POLITICAL SCIENCE" 29 | }, 30 | { 31 | "code": "25", 32 | "name": "FACULTY OF ARCHITECTURE" 33 | }, 34 | { 35 | "code": "26", 36 | "name": "FACULTY OF COMMERCE AND ACCOUNTANCY" 37 | }, 38 | { 39 | "code": "27", 40 | "name": "FACULTY OF EDUCATION" 41 | }, 42 | { 43 | "code": "28", 44 | "name": "FACULTY OF COMMUNICATION ARTS" 45 | }, 46 | { 47 | "code": "29", 48 | "name": "FACULTY OF ECONOMICS" 49 | }, 50 | { 51 | "code": "30", 52 | "name": "FACULTY OF MEDICINE" 53 | }, 54 | { 55 | "code": "31", 56 | "name": "FACULTY OF VETERINARY SCIENCE" 57 | }, 58 | { 59 | "code": "32", 60 | "name": "FACULTY OF DENTISTRY" 61 | }, 62 | { 63 | "code": "33", 64 | "name": "FACULTY OF PHARMACEUTICAL SCIENCES" 65 | }, 66 | { 67 | "code": "34", 68 | "name": "FACULTY OF LAW" 69 | }, 70 | { 71 | "code": "35", 72 | "name": "FACULTY OF FINE AND APPLIED ARTS" 73 | }, 74 | { 75 | "code": "36", 76 | "name": "FACULTY OF NURSING" 77 | }, 78 | { 79 | "code": "37", 80 | "name": "FACULTY OF ALLIED HEALTH SCIENCES" 81 | }, 82 | { 83 | "code": "38", 84 | "name": "FACULTY OF PSYCHOLOGY" 85 | }, 86 | { 87 | "code": "39", 88 | "name": "FACULTY OF SPORTS SCIENCE" 89 | }, 90 | { 91 | "code": "40", 92 | "name": "SCHOOL OF AGRICULTURAL RESOURCES" 93 | }, 94 | { 95 | "code": "51", 96 | "name": "COLLEGE OF POPULATION STUDIES" 97 | }, 98 | { 99 | "code": "53", 100 | "name": "COLLEGE OF PUBLIC HEALTH SCIENCES" 101 | }, 102 | { 103 | "code": "55", 104 | "name": "LANGUAGE INSTITUTE" 105 | }, 106 | { 107 | "code": "56", 108 | "name": "SCHOOL OF INTEGRATED INNOVATION" 109 | }, 110 | { 111 | "code": "58", 112 | "name": "SASIN GRADUATE INSTITUTE OF BUSINESS ADMINISTION" 113 | }, 114 | { 115 | "code": "99", 116 | "name": "OTHER UNIVERSITY" 117 | } 118 | ] 119 | -------------------------------------------------------------------------------- /frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { IBM_Plex_Sans_Thai } from "next/font/google"; 3 | import { FC, PropsWithChildren } from "react"; 4 | import { getAssignmentByCode } from "../api/assignment"; 5 | import { getCourseByCode } from "../api/course"; 6 | import { getFacultyByCode } from "../api/faculty"; 7 | import { getRecordByID } from "../api/record"; 8 | import { NavBar } from "../components/NavBar"; 9 | import { Toaster } from "../components/ui/toaster"; 10 | import { getPathname } from "../utils/getPathname"; 11 | import "./globals.css"; 12 | import { LoadState } from "./LoadState"; 13 | import Providers from "./providers"; 14 | 15 | const IBMPlex = IBM_Plex_Sans_Thai({ 16 | weight: ["100", "300", "400", "600", "700"], 17 | subsets: ["thai"], 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: "ourcourseville", 22 | description: "Seizing the means of learning", 23 | }; 24 | 25 | export const RootLayout: FC = async ({ children }) => { 26 | const pathParts = getPathname().split("/"); 27 | 28 | const facultyCode = pathParts.length > 2 ? pathParts[2] : ""; 29 | const currentFaculty_ = await getFacultyByCode(facultyCode); 30 | const currentFaculty = 31 | currentFaculty_ instanceof Error ? undefined : currentFaculty_; 32 | 33 | const courseCode = pathParts.length > 4 ? pathParts[4] : ""; 34 | const currentCourse_ = await getCourseByCode(courseCode); 35 | const currentCourse = 36 | currentCourse_ instanceof Error ? undefined : currentCourse_; 37 | 38 | const assignmentCode = pathParts.length > 6 ? pathParts[6] : ""; 39 | const currentAssignment_ = await getAssignmentByCode(assignmentCode); 40 | const currentAssignment = 41 | currentAssignment_ instanceof Error ? undefined : currentAssignment_; 42 | 43 | const recordID = pathParts.length > 8 ? pathParts[8] : ""; 44 | const currentRecord_ = await getRecordByID(recordID); 45 | const currentRecord = 46 | currentRecord_ instanceof Error ? undefined : currentRecord_; 47 | 48 | return ( 49 | 50 | 56 | 57 | 58 | 63 | 66 | 67 | 73 | 74 | 75 | {children} 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default RootLayout; 83 | -------------------------------------------------------------------------------- /extension/src/pages/content/scripts/scrape.ts: -------------------------------------------------------------------------------- 1 | import { ScrapeRecord } from "@src/types"; 2 | 3 | export const scrape = (url: string): ScrapeRecord => { 4 | const mainElements = document.querySelectorAll("main"); 5 | const innerMain = mainElements[1]; 6 | const form = innerMain.querySelectorAll("form")[0]; 7 | 8 | const problems = Array.from(form.children).filter( 9 | (child) => child.tagName === "DIV" 10 | ); 11 | 12 | const qnas = problems.map((p) => { 13 | const questionDiv = p.children[0].children[0]; 14 | const questionP = questionDiv.getElementsByTagName("p"); 15 | const textArray = Array.from(questionP).map((para) => 16 | para.textContent?.trim() 17 | ); 18 | const question = textArray.join(" "); 19 | 20 | const answerDiv = p.children[0].children[1]; 21 | const textAnswer = Array.from(answerDiv.childNodes) 22 | .reduce((text, node) => { 23 | if (node.nodeType === Node.TEXT_NODE) { 24 | return text + node?.textContent?.trim(); 25 | } 26 | return text; 27 | }, "") 28 | .trim(); 29 | if (textAnswer) return { question, answer: textAnswer }; 30 | 31 | const checkedButton = answerDiv.querySelector( 32 | 'button[data-state="checked"]' 33 | ); 34 | const escapedID = checkedButton?.id?.replace(/"/g, `\\"`); 35 | const label = document.querySelector(`label[for="${escapedID}"]`); 36 | const choiceAnswer = label?.textContent?.trim(); 37 | if (choiceAnswer) 38 | return { 39 | question, 40 | answer: choiceAnswer, 41 | }; 42 | 43 | const fileAnchor = answerDiv.querySelector("a"); 44 | const fileUrl = fileAnchor?.href; 45 | if (fileUrl) return { question, answer: fileUrl }; 46 | 47 | return { question, answer: "No answer found" }; 48 | }); 49 | 50 | const parts = url.split("/"); 51 | const courseID = parts[4]; 52 | const assignmentCode = parts[6].split("#")[0]; 53 | 54 | // courseCode, course 55 | const courseIconImg = document.querySelector( 56 | 'img[alt="desktop course icon"]' 57 | ); 58 | const courseIcon = (courseIconImg as HTMLImageElement).src ?? "null"; 59 | let courseCode = "null"; 60 | let course = "null"; 61 | if (courseIconImg) { 62 | const siblingDiv = courseIconImg.nextElementSibling; 63 | const childDivs = siblingDiv?.querySelectorAll("div"); 64 | if (childDivs && childDivs.length >= 2) { 65 | const rawCourseCode = childDivs[0].textContent?.trim() ?? "null"; 66 | courseCode = rawCourseCode.split(".")[0]; 67 | course = childDivs[1].textContent?.trim() ?? "null"; 68 | } 69 | } 70 | 71 | // assignment 72 | const anchor = document.querySelectorAll( 73 | `a[href="/course/${courseID}/assignments"]` 74 | ); 75 | let assignment = "No assignment name found"; 76 | if (anchor[1]) { 77 | assignment = "asd"; 78 | const parentDiv = anchor[1].closest("div"); 79 | assignment = 80 | parentDiv?.nextElementSibling?.nextElementSibling?.textContent?.trim() ?? 81 | "No assignment name"; 82 | } 83 | 84 | const response: ScrapeRecord = { 85 | courseID, 86 | course, 87 | courseCode, 88 | courseIcon, 89 | assignmentCode, 90 | assignment, 91 | problems: qnas, 92 | }; 93 | 94 | return response; 95 | }; 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | build 4 | 5 | # Logs 6 | 7 | logs 8 | _.log 9 | npm-debug.log_ 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Caches 16 | 17 | .cache 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | 21 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 22 | 23 | # Runtime data 24 | 25 | pids 26 | _.pid 27 | _.seed 28 | *.pid.lock 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | 36 | coverage 37 | *.lcov 38 | 39 | # nyc test coverage 40 | 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 44 | 45 | .grunt 46 | 47 | # Bower dependency directory (https://bower.io/) 48 | 49 | bower_components 50 | 51 | # node-waf configuration 52 | 53 | .lock-wscript 54 | 55 | # Compiled binary addons (https://nodejs.org/api/addons.html) 56 | 57 | build/Release 58 | 59 | # Dependency directories 60 | 61 | node_modules/ 62 | jspm_packages/ 63 | 64 | # Snowpack dependency directory (https://snowpack.dev/) 65 | 66 | web_modules/ 67 | 68 | # TypeScript cache 69 | 70 | *.tsbuildinfo 71 | 72 | # Optional npm cache directory 73 | 74 | .npm 75 | 76 | # Optional eslint cache 77 | 78 | .eslintcache 79 | 80 | # Optional stylelint cache 81 | 82 | .stylelintcache 83 | 84 | # Microbundle cache 85 | 86 | .rpt2_cache/ 87 | .rts2_cache_cjs/ 88 | .rts2_cache_es/ 89 | .rts2_cache_umd/ 90 | 91 | # Optional REPL history 92 | 93 | .node_repl_history 94 | 95 | # Output of 'npm pack' 96 | 97 | *.tgz 98 | 99 | # Yarn Integrity file 100 | 101 | .yarn-integrity 102 | 103 | # dotenv environment variable files 104 | 105 | .env 106 | .env.development.local 107 | .env.test.local 108 | .env.production.local 109 | .env.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | 117 | .next 118 | out 119 | 120 | # Nuxt.js build / generate output 121 | 122 | .nuxt 123 | dist 124 | 125 | # Gatsby files 126 | 127 | # Comment in the public line in if your project uses Gatsby and not Next.js 128 | 129 | # https://nextjs.org/blog/next-9-1#public-directory-support 130 | 131 | # public 132 | 133 | # vuepress build output 134 | 135 | .vuepress/dist 136 | 137 | # vuepress v2.x temp and cache directory 138 | 139 | .temp 140 | 141 | # Docusaurus cache and generated files 142 | 143 | .docusaurus 144 | 145 | # Serverless directories 146 | 147 | .serverless/ 148 | 149 | # FuseBox cache 150 | 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | 155 | .dynamodb/ 156 | 157 | # TernJS port file 158 | 159 | .tern-port 160 | 161 | # Stores VSCode versions used for testing VSCode extensions 162 | 163 | .vscode-test 164 | 165 | # yarn v2 166 | 167 | .yarn/cache 168 | .yarn/unplugged 169 | .yarn/build-state.yml 170 | .yarn/install-state.gz 171 | .pnp.* 172 | 173 | # IntelliJ based IDEs 174 | .idea 175 | 176 | # Finder (MacOS) folder config 177 | .DS_Store 178 | 179 | 180 | obj 181 | bin 182 | firebase-sdk.json 183 | firebase-adminsdk.json 184 | manifest.prod.json 185 | build-prod 186 | build-prod.zip -------------------------------------------------------------------------------- /backend/Services/FacultyService.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | using backend.DTO; 3 | using backend.Models; 4 | using backend.Services.Interfaces; 5 | using backend.Data; 6 | using backend.Exceptions; 7 | using System.Net; 8 | using backend.Config; 9 | using Microsoft.Extensions.Options; 10 | using backend.Repositories.Interfaces; 11 | 12 | namespace backend.Services; 13 | 14 | public class FacultyService : IFacultyService 15 | { 16 | private readonly TTLConfig _conf; 17 | private readonly ICacheRepository _cache; 18 | private readonly CollectionReference _faculties; 19 | private readonly ILogger _log; 20 | 21 | public FacultyService(IOptions conf, ICacheRepository cache, Firestore fs, ILogger log) 22 | { 23 | _conf = conf.Value; 24 | _cache = cache; 25 | _faculties = fs.faculties; 26 | _log = log; 27 | } 28 | 29 | public async Task Create(FacultyDTO facultyDTO) 30 | { 31 | var newFaculty = new Faculty 32 | { 33 | Code = facultyDTO.Code, 34 | Name = facultyDTO.Name, 35 | CreatedAt = Timestamp.GetCurrentTimestamp() 36 | }; 37 | 38 | try 39 | { 40 | DocumentReference document = _faculties.Document(); 41 | await document.SetAsync(newFaculty); 42 | newFaculty.ID = document.Id; 43 | 44 | return newFaculty; 45 | } 46 | catch (Exception ex) 47 | { 48 | _log.LogError(ex, "Error adding faculty"); 49 | throw new ServiceException("Error adding faculty", HttpStatusCode.InternalServerError, ex); 50 | } 51 | } 52 | public async Task> FindAll() 53 | { 54 | try 55 | { 56 | var cacheVal = await _cache.GetAsync>(FindAllKey()); 57 | if (cacheVal != null) return cacheVal; 58 | 59 | QuerySnapshot snapshot = await _faculties.GetSnapshotAsync(); 60 | List faculties = new List(); 61 | 62 | foreach (DocumentSnapshot document in snapshot.Documents) 63 | { 64 | if (document.Exists) 65 | { 66 | Faculty faculty = document.ConvertTo(); 67 | faculty.ID = document.Id; 68 | faculties.Add(faculty); 69 | } 70 | else 71 | { 72 | _log.LogWarning($"Faculty with ID {document.Id} does not exist"); 73 | } 74 | } 75 | 76 | await _cache.SetAsync(FindAllKey(), faculties, _conf.FacultyTTL); 77 | 78 | return faculties; 79 | } 80 | catch (Exception ex) 81 | { 82 | _log.LogError(ex, "Error finding all faculties"); 83 | throw new ServiceException("Error finding all faculties", HttpStatusCode.InternalServerError, ex); 84 | } 85 | } 86 | public async Task FindByCode(string code) 87 | { 88 | try 89 | { 90 | var cacheVal = await _cache.GetAsync(FindByCodeKey(code)); 91 | if (cacheVal != null) return cacheVal; 92 | 93 | Query query = _faculties.WhereEqualTo("Code", code); 94 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 95 | if (snapshot.Documents.Count == 0) return null; 96 | 97 | DocumentSnapshot document = snapshot.Documents[0]; 98 | if (!document.Exists) return null; 99 | 100 | var faculty = document.ConvertTo(); 101 | faculty.ID = document.Id; 102 | 103 | await _cache.SetAsync(FindByCodeKey(code), faculty, _conf.FacultyTTL); 104 | 105 | return faculty; 106 | } 107 | catch (Exception ex) 108 | { 109 | _log.LogError(ex, $"Error finding faculty with code {code}"); 110 | throw new ServiceException($"Error finding faculty with code {code}", HttpStatusCode.InternalServerError, ex); 111 | } 112 | } 113 | 114 | private string FindAllKey() => $"faculty-all"; 115 | private string FindByCodeKey(string code) => $"faculty-code-:{code}"; 116 | } 117 | -------------------------------------------------------------------------------- /extension/src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import { RecordDTO } from "@src/types"; 2 | import { useState } from "react"; 3 | import { FaFileSignature } from "react-icons/fa6"; 4 | import { Badge } from "./components/Badge"; 5 | import { Button } from "./components/Button"; 6 | import { CopyButton } from "./components/CopyButton"; 7 | import { Input } from "./components/Input"; 8 | import { Tile } from "./components/Tile"; 9 | 10 | export default function Popup(): JSX.Element { 11 | const [success, setSuccess] = useState(""); 12 | const [warning, setWarning] = useState(""); 13 | const [error, setError] = useState(""); 14 | 15 | const [recordID, setRecordID] = useState(""); 16 | const [sharedRecord, setSharedRecord] = useState(null); 17 | 18 | async function share() { 19 | const [tab] = await chrome.tabs.query({ 20 | active: true, 21 | currentWindow: true, 22 | }); 23 | 24 | if (!tab || tab.id === undefined) { 25 | setError("Tab not found"); 26 | throw new Error("Tab not found"); 27 | } 28 | 29 | const response = await chrome.tabs.sendMessage(tab.id, { 30 | action: "share", 31 | url: tab.url, 32 | }); 33 | 34 | console.log({ response }); 35 | const record: RecordDTO = response.record; 36 | setSharedRecord(record); 37 | 38 | if (response.status === "error") { 39 | setError(response.message); 40 | return; 41 | } 42 | 43 | setSuccess(response.message); 44 | setWarning(response.warning); 45 | } 46 | 47 | async function load() { 48 | const [tab] = await chrome.tabs.query({ 49 | active: true, 50 | currentWindow: true, 51 | }); 52 | 53 | if (!tab || tab.id === undefined) { 54 | setError("Tab not found"); 55 | throw new Error("Tab not found"); 56 | } 57 | 58 | const response = await chrome.tabs.sendMessage(tab.id, { 59 | action: "load", 60 | recordID: recordID, 61 | }); 62 | 63 | console.log({ response }); 64 | 65 | if (response.status === "error") { 66 | setError(response.message); 67 | return; 68 | } 69 | 70 | setSuccess(response.message); 71 | setWarning(response.warning); 72 | } 73 | 74 | const handleChange = (e: React.ChangeEvent) => { 75 | setRecordID(e.target.value); 76 | }; 77 | 78 | return ( 79 |
        80 |
        81 | 85 |

        Assignments

        86 |
        87 | 88 |
        89 |

        Both buttons only works in Alpha MCV

        90 |

        91 | Make sure you are on the assignment page (assignment tab, not detail 92 | tab) 93 |

        94 |
        95 |
        120 |
        121 | {success &&

        {success}

        } 122 | {warning &&

        {warning}

        } 123 | {error &&

        {error}

        } 124 |
        125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /backend/Services/AssignmentService.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | using backend.DTO; 3 | using backend.Services.Interfaces; 4 | using backend.Data; 5 | using backend.Exceptions; 6 | using System.Net; 7 | 8 | namespace backend.Services; 9 | 10 | #pragma warning disable CS1998 // disable warning for RunTransactionAsync having no await 11 | public class AssignmentService : IAssignmentService 12 | { 13 | private readonly FirestoreDb _db; 14 | private readonly CollectionReference _courses; 15 | private readonly CollectionReference _assignments; 16 | private readonly ILogger _log; 17 | 18 | public AssignmentService(Firestore fs, ILogger log) 19 | { 20 | _db = fs.db; 21 | _courses = fs.courses; 22 | _assignments = fs.assignments; 23 | _log = log; 24 | } 25 | 26 | public async Task Create(AssignmentDTO assignmentDTO) 27 | { 28 | var newAssignment = new Assignment 29 | { 30 | CourseCode = assignmentDTO.CourseCode, 31 | Code = assignmentDTO.Code, 32 | Name = assignmentDTO.Name, 33 | CreatedAt = Timestamp.GetCurrentTimestamp() 34 | }; 35 | 36 | try 37 | { 38 | var courseSnapshot = await _courses 39 | .WhereEqualTo("Code", assignmentDTO.CourseCode) 40 | .Limit(1).GetSnapshotAsync(); 41 | if (courseSnapshot.Documents.Count == 0) 42 | { 43 | _log.LogInformation($"Course with code {assignmentDTO.CourseCode} does not exist"); 44 | throw new ServiceException($"Course with code {assignmentDTO.CourseCode} does not exist", HttpStatusCode.NotFound); 45 | } 46 | 47 | DocumentReference courseDoc = courseSnapshot.Documents[0].Reference; 48 | DocumentReference asgmDoc = _assignments.Document(); 49 | 50 | await _db.RunTransactionAsync(async transaction => 51 | { 52 | transaction.Set(asgmDoc, newAssignment); 53 | transaction.Set(courseDoc, new { Count = FieldValue.Increment(1) }, SetOptions.MergeAll); 54 | newAssignment.ID = asgmDoc.Id; 55 | }); 56 | 57 | return newAssignment; 58 | } 59 | catch (ServiceException ex) 60 | { 61 | throw new ServiceException(ex.Message, HttpStatusCode.InternalServerError); 62 | } 63 | catch (Exception ex) 64 | { 65 | _log.LogError(ex, "Error adding assignment"); 66 | throw new ServiceException("Error adding assignment", HttpStatusCode.InternalServerError, ex); 67 | } 68 | } 69 | public async Task> FindByCourseCode(string courseCode) 70 | { 71 | try 72 | { 73 | Query query = _assignments.WhereEqualTo("CourseCode", courseCode); 74 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 75 | List assignments = new List(); 76 | 77 | foreach (DocumentSnapshot document in snapshot.Documents) 78 | { 79 | if (document.Exists) 80 | { 81 | Assignment assignment = document.ConvertTo(); 82 | assignment.ID = document.Id; 83 | assignments.Add(assignment); 84 | } 85 | else 86 | { 87 | _log.LogWarning($"Assignment with ID {document.Id} does not exist"); 88 | } 89 | } 90 | 91 | return assignments; 92 | } 93 | catch (Exception ex) 94 | { 95 | _log.LogError(ex, $"Error finding assignment with course code {courseCode}"); 96 | throw new ServiceException($"Error finding assignment with course code {courseCode}", HttpStatusCode.InternalServerError, ex); 97 | } 98 | } 99 | public async Task FindByCode(string code) 100 | { 101 | try 102 | { 103 | Query query = _assignments.WhereEqualTo("Code", code); 104 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 105 | if (snapshot.Documents.Count == 0) return null; 106 | 107 | DocumentSnapshot document = snapshot.Documents[0]; 108 | if (!document.Exists) return null; 109 | 110 | var assignment = document.ConvertTo(); 111 | assignment.ID = document.Id; 112 | 113 | return assignment; 114 | } 115 | catch (Exception ex) 116 | { 117 | _log.LogError(ex, $"Error finding assignment with code {code}"); 118 | throw new ServiceException($"Error finding assignment with code {code}", HttpStatusCode.InternalServerError, ex); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /frontend/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react"; 5 | 6 | import type { ToastActionElement, ToastProps } from "@/src/components/ui/toast"; 7 | 8 | const TOAST_LIMIT = 1; 9 | const TOAST_REMOVE_DELAY = 1000000; 10 | 11 | type ToasterToast = ToastProps & { 12 | id: string; 13 | title?: React.ReactNode; 14 | description?: React.ReactNode; 15 | action?: ToastActionElement; 16 | }; 17 | 18 | const actionTypes = { 19 | ADD_TOAST: "ADD_TOAST", 20 | UPDATE_TOAST: "UPDATE_TOAST", 21 | DISMISS_TOAST: "DISMISS_TOAST", 22 | REMOVE_TOAST: "REMOVE_TOAST", 23 | } as const; 24 | 25 | let count = 0; 26 | 27 | function genId() { 28 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 29 | return count.toString(); 30 | } 31 | 32 | type ActionType = typeof actionTypes; 33 | 34 | type Action = 35 | | { 36 | type: ActionType["ADD_TOAST"]; 37 | toast: ToasterToast; 38 | } 39 | | { 40 | type: ActionType["UPDATE_TOAST"]; 41 | toast: Partial; 42 | } 43 | | { 44 | type: ActionType["DISMISS_TOAST"]; 45 | toastId?: ToasterToast["id"]; 46 | } 47 | | { 48 | type: ActionType["REMOVE_TOAST"]; 49 | toastId?: ToasterToast["id"]; 50 | }; 51 | 52 | interface State { 53 | toasts: ToasterToast[]; 54 | } 55 | 56 | const toastTimeouts = new Map>(); 57 | 58 | const addToRemoveQueue = (toastId: string) => { 59 | if (toastTimeouts.has(toastId)) { 60 | return; 61 | } 62 | 63 | const timeout = setTimeout(() => { 64 | toastTimeouts.delete(toastId); 65 | dispatch({ 66 | type: "REMOVE_TOAST", 67 | toastId: toastId, 68 | }); 69 | }, TOAST_REMOVE_DELAY); 70 | 71 | toastTimeouts.set(toastId, timeout); 72 | }; 73 | 74 | export const reducer = (state: State, action: Action): State => { 75 | switch (action.type) { 76 | case "ADD_TOAST": 77 | return { 78 | ...state, 79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 80 | }; 81 | 82 | case "UPDATE_TOAST": 83 | return { 84 | ...state, 85 | toasts: state.toasts.map((t) => 86 | t.id === action.toast.id ? { ...t, ...action.toast } : t 87 | ), 88 | }; 89 | 90 | case "DISMISS_TOAST": { 91 | const { toastId } = action; 92 | 93 | // ! Side effects ! - This could be extracted into a dismissToast() action, 94 | // but I'll keep it here for simplicity 95 | if (toastId) { 96 | addToRemoveQueue(toastId); 97 | } else { 98 | state.toasts.forEach((toast) => { 99 | addToRemoveQueue(toast.id); 100 | }); 101 | } 102 | 103 | return { 104 | ...state, 105 | toasts: state.toasts.map((t) => 106 | t.id === toastId || toastId === undefined 107 | ? { 108 | ...t, 109 | open: false, 110 | } 111 | : t 112 | ), 113 | }; 114 | } 115 | case "REMOVE_TOAST": 116 | if (action.toastId === undefined) { 117 | return { 118 | ...state, 119 | toasts: [], 120 | }; 121 | } 122 | return { 123 | ...state, 124 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 125 | }; 126 | } 127 | }; 128 | 129 | const listeners: Array<(state: State) => void> = []; 130 | 131 | let memoryState: State = { toasts: [] }; 132 | 133 | function dispatch(action: Action) { 134 | memoryState = reducer(memoryState, action); 135 | listeners.forEach((listener) => { 136 | listener(memoryState); 137 | }); 138 | } 139 | 140 | type Toast = Omit; 141 | 142 | function toast({ ...props }: Toast) { 143 | const id = genId(); 144 | 145 | const update = (props: ToasterToast) => 146 | dispatch({ 147 | type: "UPDATE_TOAST", 148 | toast: { ...props, id }, 149 | }); 150 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 151 | 152 | dispatch({ 153 | type: "ADD_TOAST", 154 | toast: { 155 | ...props, 156 | id, 157 | open: true, 158 | onOpenChange: (open) => { 159 | if (!open) dismiss(); 160 | }, 161 | }, 162 | }); 163 | 164 | return { 165 | id: id, 166 | dismiss, 167 | update, 168 | }; 169 | } 170 | 171 | function useToast() { 172 | const [state, setState] = React.useState(memoryState); 173 | 174 | React.useEffect(() => { 175 | listeners.push(setState); 176 | return () => { 177 | const index = listeners.indexOf(setState); 178 | if (index > -1) { 179 | listeners.splice(index, 1); 180 | } 181 | }; 182 | }, [state]); 183 | 184 | return { 185 | ...state, 186 | toast, 187 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 188 | }; 189 | } 190 | 191 | export { toast, useToast }; 192 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FaUniversity } from "react-icons/fa"; 4 | import { FaBars } from "react-icons/fa6"; 5 | 6 | import { FaFileSignature, FaGraduationCap, FaHouse } from "react-icons/fa6"; 7 | 8 | import { usePathname } from "next/navigation"; 9 | 10 | import { EXTENSION_URL } from "@/src/config/config"; 11 | import { useIsMobileViewport } from "@/src/hooks/useIsUnderLargeViewport"; 12 | import { selectCurrentCourse } from "@/src/store/courseSlice"; 13 | import { selectCurrentFaculty } from "@/src/store/facultySlice"; 14 | import { useAppSelector } from "@/src/store/store"; 15 | import clsx from "clsx"; 16 | import { useState } from "react"; 17 | import { ExtensionButton } from "../IconButton/ExtensionButton"; 18 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 19 | import { Logo } from "./Logo"; 20 | import { NavItem } from "./NavItem"; 21 | 22 | export const NavBar = () => { 23 | const pathname = usePathname(); 24 | const pathParts = pathname.split("/"); 25 | const facultyCode = pathParts.length > 2 ? pathParts[2] : ""; 26 | const courseCode = pathParts.length > 4 ? pathParts[4] : ""; 27 | 28 | const matchFaculty = pathname === "/faculty"; 29 | 30 | const courseRegex = /^\/faculty\/([^/]+)\/course$/; 31 | const matchCourse = courseRegex.test(pathname); 32 | 33 | const assignmentRegex = /^\/faculty\/([^/]+)\/course\/([^/]+)\/assignment$/; 34 | const matchAssignment = assignmentRegex.test(pathname); 35 | 36 | const currentFaculty = useAppSelector(selectCurrentFaculty); 37 | const currentCourse = useAppSelector(selectCurrentCourse); 38 | 39 | const facultyCode_ = currentFaculty?.code 40 | ? currentFaculty?.code 41 | : facultyCode; 42 | const courseCode_ = currentCourse?.code ? currentCourse?.code : courseCode; 43 | 44 | const isCoursesEnabled = 45 | currentFaculty?.code !== undefined || facultyCode !== ""; 46 | const coursesPath = `/faculty/${facultyCode_}/course`; 47 | 48 | const isAssignmentsEnabled = 49 | isCoursesEnabled && 50 | (currentCourse?.code !== undefined || courseCode !== ""); 51 | const assignmentsPath = `/faculty/${facultyCode_}/course/${courseCode_}/assignment`; 52 | 53 | const { isMobile } = useIsMobileViewport(); 54 | 55 | const nav = () => ( 56 | <> 57 | 64 | 65 | 66 | 73 | 74 | 75 | 82 | 83 | 84 | 91 | 92 | 93 | 94 | ); 95 | 96 | const [isOpen, setIsOpen] = useState(false); 97 | const toggleMenu = () => { 98 | setIsOpen(!isOpen); 99 | }; 100 | const mobile = () => { 101 | return ( 102 | <> 103 | 104 | 105 | 108 | 109 | 110 |
        111 |
        112 |
        113 | Menu 114 |
        115 |
        setIsOpen(false)}> 116 | {nav()} 117 |
        118 |
        119 |
        120 |
        121 |
        122 | 123 | 124 | 125 | ); 126 | }; 127 | 128 | const desktop = () => ( 129 | <> 130 | 131 |
        {nav()}
        132 |
        133 | 134 |

        Get extension

        135 |
        136 | 137 | ); 138 | 139 | return ( 140 |
        146 | {isMobile ? mobile() : desktop()} 147 |
        148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /frontend/src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Cross2Icon } from "@radix-ui/react-icons" 5 | import * as ToastPrimitives from "@radix-ui/react-toast" 6 | import { cva, type VariantProps } from "class-variance-authority" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /extension/README.md: -------------------------------------------------------------------------------- 1 |
        2 | logo 3 |

        Chrome Extension Boilerplate with
        React + Vite + TypeScript + TailwindCSS

        4 | 5 |
        6 | This is a side product of my Chrome Extension Supatabs. 7 | Supatabs is a 🔥🔥 BLAZINGLY FAST 🔥🔥 free alternative to OneTab with support for chrome tab groups and searching through tabs. 8 |
        9 | 10 |
        11 | If you tend to have thousands of tabs open, are a OneTab user, or use any other tab manager 12 | make sure to check it out here! 13 |
        14 | 15 |
        16 | 17 | ## Table of Contents 18 | 19 | - [Intro](#intro) 20 | - [Why another boilerplate?](#why) 21 | - [Features](#features) 22 | - [Usage](#usage) 23 | - [Setup](#setup) 24 | - [Tech Docs](#tech) 25 | - [Credit](#credit) 26 | - [Contributing](#contributing) 27 | 28 | 29 | ## Intro 30 | This boilerplate is meant to be a minimal quick start for creating chrome extensions using React, Typescript and Tailwind CSS. 31 | 32 | Built for: 33 | > For improved DX and rapid building vite and nodemon are used. 34 | 35 | > Chrome does not accept manifest v2 extensions since Jan 2022, therefore this template uses manifest v3. 36 | 37 | > Firefox + other browsers don't yet support manifest v3, so cross browser usage is not encouraged. 38 | 39 | * Read more about Chrome manifest v2 support [here](https://developer.chrome.com/docs/extensions/mv2/). 40 | * Read more about Firefox Manifest v3 support [here](https://discourse.mozilla.org/t/manifest-v3/94564). 41 | 42 | As soon as Firefox supports manifest v3, support will be added in this repo as well. 43 | 44 | Oh by the way ... I also implemented a chrome local/sync storage hook for react, which works well with this 45 | template. [Check it out here](https://gist.github.com/JohnBra/c81451ea7bc9e77f8021beb4f198ab96). 46 | 47 | ## Why another boilerplate? 48 | I have used webpack react boilerplates and found it too hard to configure. 49 | 50 | Vite is mega easy to understand which makes it easier to get into and to maintain for others. 51 | 52 | I couldn't find another minimal boilerplate for React, TypeScript and Tailwind CSS. So here it is. 53 | 54 | ## Features 55 | - [React 18](https://reactjs.org/) 56 | - [TypeScript](https://www.typescriptlang.org/) 57 | - [Tailwind CSS](https://tailwindcss.com/) 58 | - [ESLint](https://eslint.org/) 59 | - [Chrome Extension Manifest Version 3](https://developer.chrome.com/docs/extensions/mv3/intro/) 60 | - [Github Action](https://github.com/JohnBra/vite-web-extension/actions/workflows/ci.yml) to build and zip your extension (manual trigger) 61 | 62 | ## Usage 63 | 64 | ### Setup 65 | 1. Clone this repository` 66 | 2. Change `name` and `description` in `manifest.json` 67 | 3. Run `yarn` or `npm i` (check your node version >= 16) 68 | 4. Run `yarn dev` or `npm run dev` 69 | 5. Load Extension in Chrome 70 | 1. Open - Chrome browser 71 | 2. Access - chrome://extensions 72 | 3. Tick - Developer mode 73 | 4. Find - Load unpacked extension 74 | 5. Select - `dist` folder in this project (after dev or build) 75 | 6. If you want to build in production, Just run `yarn build` or `npm run build`. 76 | 77 | ### Customization 78 | The template includes **all** of the Chrome extension pages. You will likely have to customize it to fit your needs. 79 | 80 | E.g. you don't want the newtab page to activate whenever you open a new tab: 81 | 1. remove the directory `newtab` and its contents in `src/pages` 82 | 2. remove `chrome_url_overrides: { newtab: 'src/pages/newtab/index.html' },` in `manifest.json` 83 | 84 | If you need to declare extra HTML pages beyond those the manifest accommodates, place them in the Vite config under build.rollupOptions.input. 85 | 86 | This example includes a welcome page to open when the user installs the extension. 87 | 88 | CSS files in the `src/pages/*` directories are not necessary. They are left in there in case you want 89 | to use it in combination with Tailwind CSS. **Feel free to delete them**. 90 | 91 | Tailwind can be configured as usual in the `tailwind.config.cjs` file. See doc link below. 92 | 93 | ### Publish your extension 94 | To upload an extension to the Chrome store you have to pack (zip) it and then upload it to your item in entry 95 | in the Chrome Web Store. 96 | 97 | This repo includes a Github Action Workflow to create a 98 | [optimized prod build and create the zip file](https://github.com/JohnBra/vite-web-extension/actions/workflows/ci.yml). 99 | 100 | To run the workflow do the following: 101 | 1. Go to the **"Actions"** tab in your forked repository from this template 102 | 2. In the left sidebar click on **"Build and Zip Extension"** 103 | 3. Click on **"Run Workflow"** and select the main branch, then **"Run Workflow"** 104 | 4. Refresh the page and click the most recent run 105 | 5. In the summary page **"Artifacts"** section click on the generated **"vite-web-extension"** 106 | 6. Upload this file to the Chrome Web Store as described [here](https://developer.chrome.com/docs/webstore/publish/) 107 | 108 | # Tech Docs 109 | - [Vite](https://vitejs.dev/) 110 | - [Vite Plugin](https://vitejs.dev/guide/api-plugin.html) 111 | - [Chrome Extension with manifest 3](https://developer.chrome.com/docs/extensions/mv3/) 112 | - [Rollup](https://rollupjs.org/guide/en/) 113 | - [@crxjs/vite-plugin](https://crxjs.dev/vite-plugin) 114 | - [Tailwind CSS](https://tailwindcss.com/docs/configuration) 115 | 116 | # Credit 117 | Heavily inspired by [Jonghakseo's vite chrome extension boilerplate](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite). 118 | It uses SASS instead of TailwindCSS if you want to check it out. 119 | 120 | # Contributing 121 | Feel free to open PRs or raise issues! 122 | -------------------------------------------------------------------------------- /backend/Services/CourseService.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | using backend.DTO; 3 | using backend.Models; 4 | using backend.Services.Interfaces; 5 | using backend.Data; 6 | using backend.Exceptions; 7 | using System.Net; 8 | using backend.Config; 9 | using backend.Repositories.Interfaces; 10 | using Microsoft.Extensions.Options; 11 | 12 | namespace backend.Services; 13 | 14 | #pragma warning disable CS1998 // disable warning for RunTransactionAsync having no await 15 | public class CourseService : ICourseService 16 | { 17 | private readonly TTLConfig _conf; 18 | private readonly ICacheRepository _cache; 19 | private readonly IFacultyService _facultySvc; 20 | private readonly FirestoreDb _db; 21 | private readonly CollectionReference _faculties; 22 | private readonly CollectionReference _courses; 23 | private readonly ILogger _log; 24 | 25 | public CourseService(IOptions conf, ICacheRepository cache, IFacultyService facultySvc, Firestore fs, ILogger log) 26 | { 27 | _conf = conf.Value; 28 | _cache = cache; 29 | _facultySvc = facultySvc; 30 | _db = fs.db; 31 | _faculties = fs.faculties; 32 | _courses = fs.courses; 33 | _log = log; 34 | } 35 | 36 | public async Task Create(CourseDTO courseDTO) 37 | { 38 | // check if faculty exists 39 | var faculty = await _facultySvc.FindByCode(courseDTO.FacultyCode); 40 | if (faculty == null) 41 | { 42 | _log.LogError($"Faculty with code {courseDTO.FacultyCode} does not exist"); 43 | throw new ServiceException($"Faculty with code {courseDTO.FacultyCode} does not exist", HttpStatusCode.NotFound); 44 | } 45 | 46 | var newCourse = new Course 47 | { 48 | FacultyCode = courseDTO.FacultyCode, 49 | Code = courseDTO.Code, 50 | Icon = courseDTO.Icon, 51 | Name = courseDTO.Name, 52 | CreatedAt = Timestamp.GetCurrentTimestamp() 53 | }; 54 | 55 | try 56 | { 57 | var facultySnapshot = await _faculties 58 | .WhereEqualTo("Code", courseDTO.FacultyCode) 59 | .Limit(1).GetSnapshotAsync(); 60 | if (facultySnapshot.Documents.Count == 0) 61 | { 62 | _log.LogInformation($"Faculty with code {courseDTO.FacultyCode} does not exist"); 63 | throw new ServiceException($"Faculty with code {courseDTO.FacultyCode} does not exist", HttpStatusCode.NotFound); 64 | } 65 | 66 | DocumentReference facultyDoc = facultySnapshot.Documents[0].Reference; 67 | DocumentReference courseDoc = _courses.Document(); 68 | 69 | await _db.RunTransactionAsync(async transaction => 70 | { 71 | transaction.Set(courseDoc, newCourse); 72 | transaction.Set(facultyDoc, new { Count = FieldValue.Increment(1) }, SetOptions.MergeAll); 73 | newCourse.ID = courseDoc.Id; 74 | }); 75 | 76 | return newCourse; 77 | } 78 | catch (ServiceException ex) 79 | { 80 | throw new ServiceException(ex.Message, HttpStatusCode.InternalServerError); 81 | } 82 | catch (Exception ex) 83 | { 84 | _log.LogError(ex, "Error adding course"); 85 | throw new ServiceException("Error adding course", HttpStatusCode.InternalServerError, ex); 86 | } 87 | } 88 | public async Task> FindByFacultyCode(string facultyCode) 89 | { 90 | try 91 | { 92 | var cacheVal = await _cache.GetAsync>(FindByFacultyKey(facultyCode)); 93 | if (cacheVal != null) return cacheVal; 94 | 95 | Query query = _courses.WhereEqualTo("FacultyCode", facultyCode); 96 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 97 | List courses = new List(); 98 | 99 | foreach (DocumentSnapshot document in snapshot.Documents) 100 | { 101 | if (document.Exists) 102 | { 103 | Course course = document.ConvertTo(); 104 | course.ID = document.Id; 105 | courses.Add(course); 106 | } 107 | else 108 | { 109 | _log.LogWarning($"Course with ID {document.Id} does not exist"); 110 | } 111 | } 112 | 113 | await _cache.SetAsync(FindByFacultyKey(facultyCode), courses, _conf.CourseTTL); 114 | 115 | return courses; 116 | } 117 | catch (Exception ex) 118 | { 119 | _log.LogError(ex, $"Error finding course with faculty code {facultyCode}"); 120 | throw new ServiceException($"Error finding course with faculty code {facultyCode}", HttpStatusCode.InternalServerError, ex); 121 | } 122 | } 123 | public async Task FindByCode(string code) 124 | { 125 | try 126 | { 127 | var cacheVal = await _cache.GetAsync(FindByCodeKey(code)); 128 | if (cacheVal != null) return cacheVal; 129 | 130 | Query query = _courses.WhereEqualTo("Code", code); 131 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 132 | if (snapshot.Documents.Count == 0) return null; 133 | 134 | DocumentSnapshot document = snapshot.Documents[0]; 135 | if (!document.Exists) return null; 136 | 137 | var course = document.ConvertTo(); 138 | course.ID = document.Id; 139 | 140 | await _cache.SetAsync(FindByCodeKey(code), course, _conf.CourseTTL); 141 | 142 | return course; 143 | } 144 | catch (Exception ex) 145 | { 146 | _log.LogError(ex, $"Error finding course with code {code}"); 147 | throw new ServiceException($"Error finding course with code {code}", HttpStatusCode.InternalServerError, ex); 148 | } 149 | } 150 | 151 | private string FindByFacultyKey(string code) => $"course-faculty-:{code}"; 152 | private string FindByCodeKey(string code) => $"course-code-:{code}"; 153 | } 154 | -------------------------------------------------------------------------------- /backend/Services/RecordService.cs: -------------------------------------------------------------------------------- 1 | using Google.Cloud.Firestore; 2 | using backend.DTO; 3 | using backend.Models; 4 | using backend.Services.Interfaces; 5 | using backend.Exceptions; 6 | using System.Net; 7 | using backend.Data; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | 11 | namespace backend.Services; 12 | 13 | #pragma warning disable CS1998 // disable warning for RunTransactionAsync having no await 14 | public class RecordService : IRecordService 15 | { 16 | private readonly ICourseService _courseSvc; 17 | private readonly IAssignmentService _assignmentSvc; 18 | private readonly FirestoreDb _db; 19 | private readonly CollectionReference _assignments; 20 | private readonly CollectionReference _records; 21 | private readonly ILogger _log; 22 | 23 | public RecordService(ICourseService courseSvc, IAssignmentService assignmentSvc, Firestore fs, ILogger log) 24 | { 25 | _courseSvc = courseSvc; 26 | _assignmentSvc = assignmentSvc; 27 | _db = fs.db; 28 | _assignments = fs.assignments; 29 | _records = fs.records; 30 | _log = log; 31 | } 32 | 33 | public async Task Create(CreateRecordDTO recordDTO) 34 | { 35 | var newRecord = new Record 36 | { 37 | AssignmentCode = recordDTO.AssignmentCode, 38 | Problems = recordDTO.Problems, 39 | CreatedAt = Timestamp.GetCurrentTimestamp() 40 | }; 41 | 42 | try 43 | { 44 | // check if course exists 45 | var course = await _courseSvc.FindByCode(recordDTO.CourseCode); 46 | if (course == null) 47 | { 48 | _log.LogInformation($"Course with code {recordDTO.CourseCode} does not exist, creating new course"); 49 | var newAsgm = await _courseSvc.Create(new CourseDTO 50 | { 51 | FacultyCode = recordDTO.CourseCode.Substring(0, 2), 52 | Code = recordDTO.CourseCode, 53 | Icon = recordDTO.CourseIcon ?? "", 54 | Name = recordDTO.Course 55 | }); 56 | } 57 | 58 | // check assignment exists, create if not 59 | var assignment = await _assignmentSvc.FindByCode(recordDTO.AssignmentCode); 60 | if (assignment == null) 61 | { 62 | _log.LogInformation($"Assignment with code {recordDTO.AssignmentCode} does not exist, creating new assignment"); 63 | var newAsgm = await _assignmentSvc.Create(new AssignmentDTO 64 | { 65 | CourseCode = recordDTO.CourseCode, 66 | Code = recordDTO.AssignmentCode, 67 | Name = recordDTO.Assignment 68 | }); 69 | } 70 | 71 | // check if record with exact problem solution already exists 72 | var problemsHash = ComputeProblemsHash(newRecord.Problems); 73 | var snapshot = await _records 74 | .WhereEqualTo("ProblemsHash", problemsHash) 75 | .WhereEqualTo("AssignmentCode", recordDTO.AssignmentCode) 76 | .Limit(1).GetSnapshotAsync(); 77 | if (snapshot.Count > 0) 78 | { 79 | _log.LogInformation($"Record with exact problem solution already exists"); 80 | var record = snapshot[0].ConvertTo(); 81 | record.ID = snapshot[0].Id; 82 | 83 | return record; 84 | } 85 | 86 | var asgmSnapshot = await _assignments 87 | .WhereEqualTo("Code", recordDTO.AssignmentCode) 88 | .Limit(1).GetSnapshotAsync(); 89 | if (asgmSnapshot.Documents.Count == 0) 90 | { 91 | _log.LogInformation($"Assignment with code {recordDTO.AssignmentCode} does not exist"); 92 | throw new ServiceException($"Assignment with code {recordDTO.AssignmentCode} does not exist", HttpStatusCode.NotFound); 93 | } 94 | 95 | DocumentReference asgmDoc = asgmSnapshot.Documents[0].Reference; 96 | DocumentReference recordDoc = _records.Document(); 97 | 98 | await _db.RunTransactionAsync(async transaction => 99 | { 100 | newRecord.ProblemsHash = ComputeProblemsHash(newRecord.Problems); 101 | transaction.Set(recordDoc, newRecord); 102 | transaction.Set(asgmDoc, new { Count = FieldValue.Increment(1) }, SetOptions.MergeAll); 103 | newRecord.ID = recordDoc.Id; 104 | }); 105 | 106 | return newRecord; 107 | } 108 | catch (ServiceException ex) 109 | { 110 | throw new ServiceException(ex.Message, HttpStatusCode.InternalServerError); 111 | } 112 | catch (Exception ex) 113 | { 114 | _log.LogError(ex, "Error adding record"); 115 | throw new ServiceException("Error adding record", HttpStatusCode.InternalServerError, ex); 116 | } 117 | } 118 | 119 | public async Task FindOne(string id) 120 | { 121 | try 122 | { 123 | DocumentReference docRef = _records.Document(id); 124 | DocumentSnapshot snapshot = await docRef.GetSnapshotAsync(); 125 | if (!snapshot.Exists) return null; 126 | 127 | var record = snapshot.ConvertTo(); 128 | record.ID = snapshot.Id; 129 | 130 | return record; 131 | } 132 | catch (Exception ex) 133 | { 134 | _log.LogError(ex, $"Error finding record with id {id}"); 135 | throw new ServiceException($"Error finding record with id {id}", HttpStatusCode.InternalServerError, ex); 136 | } 137 | } 138 | 139 | public async Task> FindByAssignmentCode(string asgmCode) 140 | { 141 | try 142 | { 143 | Query query = _records.WhereEqualTo("AssignmentCode", asgmCode); 144 | QuerySnapshot snapshot = await query.GetSnapshotAsync(); 145 | List records = new List(); 146 | 147 | foreach (DocumentSnapshot document in snapshot.Documents) 148 | { 149 | if (document.Exists) 150 | { 151 | Record record = document.ConvertTo(); 152 | record.ID = document.Id; 153 | records.Add(record); 154 | } 155 | else 156 | { 157 | _log.LogWarning($"Record with ID {document.Id} does not exist"); 158 | } 159 | } 160 | 161 | return records; 162 | } 163 | catch (Exception ex) 164 | { 165 | _log.LogError(ex, $"Error finding record with AssignmentCode {asgmCode}"); 166 | throw new ServiceException($"Error finding record with AssignmentCode {asgmCode}", HttpStatusCode.InternalServerError, ex); 167 | } 168 | } 169 | 170 | private string ComputeProblemsHash(List problems) 171 | { 172 | using (SHA256 sha256 = SHA256.Create()) 173 | { 174 | string problemsConcat = ""; 175 | foreach (var problem in problems) 176 | { 177 | problemsConcat += $"{problem.Question}:{problem.Answer},"; 178 | } 179 | 180 | byte[] bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(problemsConcat)); 181 | StringBuilder sb = new StringBuilder(); 182 | foreach (byte b in bytes) 183 | { 184 | sb.Append(b.ToString("x2")); 185 | } 186 | 187 | return sb.ToString(); 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------