├── 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 |
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 |
21 |
22 |
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 |
15 | {text}
16 |
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 |
27 |
28 |
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 |
16 | {text}
17 |
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 |
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 |
25 |
31 |
37 | {text}
38 |
39 |
{text}
40 |
41 |
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 |
27 |
28 |
34 | {icon}
35 |
36 |
42 | {text}
43 |
44 |
45 |
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 |
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 |
47 |
48 |
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 |
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 |
33 |
34 | Back
35 |
36 |
37 |
38 |
39 |
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 |
96 | {sharedRecord && (
97 | <>
98 |
99 |
100 |
101 |
102 |
103 |
104 | View Solution
105 |
106 |
107 | >
108 | )}
109 |
110 |
111 | Load solutions using record ID
112 |
113 |
handleChange(e)}
116 | placeholder="input recordID here..."
117 | />
118 |
119 |
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 |
106 |
107 |
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 |
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 |
--------------------------------------------------------------------------------