Skip to content

Commit

Permalink
Merge pull request #3 from whats2000/main
Browse files Browse the repository at this point in the history
Fix: Fix course loading show undine selection
whats2000 authored Nov 18, 2024
2 parents d84b0ae + cf061f9 commit e8bd897
Showing 84 changed files with 4,150 additions and 23,963 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: deploy

on:
push:
branches: ["main"]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: false

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Setup Node.js v20.x
uses: actions/setup-node@v1
with:
node-version: '20.x'

- name: Install
run: yarn install
working-directory: client-website/

- name: Build
run: yarn build
working-directory: client-website/

- name: Setup Pages
uses: actions/configure-pages@v3

- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: 'client-website/dist'

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
45 changes: 0 additions & 45 deletions .github/workflows/deploy.yml

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 中山大學選課助手 React 版
# 中山大學選課助手 React TypeScript
## 進入網頁
https://CelleryLin.github.io/selector_helper/

23 changes: 0 additions & 23 deletions app/.gitignore

This file was deleted.

70 changes: 0 additions & 70 deletions app/README.md

This file was deleted.

22,906 changes: 0 additions & 22,906 deletions app/package-lock.json

This file was deleted.

64 changes: 0 additions & 64 deletions app/package.json

This file was deleted.

128 changes: 0 additions & 128 deletions app/public/index.html

This file was deleted.

8 changes: 0 additions & 8 deletions app/src/App.test.js

This file was deleted.

Binary file removed app/src/components/logo.png
Binary file not shown.
13 changes: 0 additions & 13 deletions app/src/index.css

This file was deleted.

18 changes: 0 additions & 18 deletions app/src/index.js

This file was deleted.

13 changes: 0 additions & 13 deletions app/src/reportWebVitals.js

This file was deleted.

5 changes: 0 additions & 5 deletions app/src/setupTests.js

This file was deleted.

24 changes: 24 additions & 0 deletions client-website/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
File renamed without changes.
File renamed without changes.
50 changes: 50 additions & 0 deletions client-website/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:

- Configure the top-level `parserOptions` property like this:

```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```

- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:

```js
// eslint.config.js
import react from 'eslint-plugin-react'

export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
28 changes: 28 additions & 0 deletions client-website/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';

export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2021,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
);
120 changes: 120 additions & 0 deletions client-website/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" type="image/svg+xml" href="/logo.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta
name="description"
content="中山選課助手,幫助你快速查詢國立中山大學的課程資訊。"
/>
<meta
name="keywords"
content="中山大學, 選課, 課程, 課程查詢, 課程表, 課程時間表, 選課助手, 助手"
/>

<!-- Open Graph / Facebook -->
<meta property="og:type" content="website"/>
<meta
property="og:url"
content="https://whats2000.github.io/CourseSelectorHelperReact/"
/>
<meta property="og:title" content="中山選課助手"/>
<meta
property="og:description"
content="中山選課助手,幫助你快速查詢國立中山大學的課程資訊。"
/>
<meta
property="og:image"
content="https://whats2000.github.io/CourseSelectorHelperReact/logo.svg"
/>

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image"/>
<meta
property="twitter:url"
content="https://whats2000.github.io/CourseSelectorHelperReact/"
/>
<meta property="twitter:title" content="中山選課助手"/>
<meta
property="twitter:description"
content="中山選課助手,幫助你快速查詢國立中山大學的課程資訊。"
/>
<meta
property="twitter:image"
content="https://whats2000.github.io/CourseSelectorHelperReact/logo.svg"
/>
<meta charset="UTF-8"/>

<link rel="apple-touch-icon" href="/logo.svg"/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json"/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->

<style>
/* 加載動畫的CSS樣式 */
#loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
z-index: 1000;
}

.spinner {
border: 5px solid rgba(0, 0, 0, 0.1);
border-top: 5px solid #333;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 2s linear infinite;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

.loading-text {
margin-top: 20px;
font-size: 18px;
color: #333;
text-align: center;
max-width: 90%;
}
</style>
<title>中山選課助手</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="loading">
<div class="spinner"></div>
<div class="loading-text">正在加載網頁...</div>
<div class="loading-text">如持續加載超過10秒,請嘗試重新整理頁面。</div>
</div>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
49 changes: 49 additions & 0 deletions client-website/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "client-website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@swc/plugin-styled-components": "^5.0.0",
"bootstrap": "^5.3.3",
"gh-pages": "^6.2.0",
"papaparse": "^5.4.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.5",
"react-bootstrap-icons": "^1.11.4",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-ga4": "^2.1.0",
"react-lazy-load-image-component": "^1.6.2",
"react-syntax-highlighter": "^15.6.1",
"react-virtuoso": "^4.12.0",
"styled-components": "^6.1.13"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/papaparse": "^5.3.15",
"@types/react": "^18.3.12",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^18.3.1",
"@types/react-lazy-load-image-component": "^1.6.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"prettier": "^3.3.3",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
}
}
File renamed without changes
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions client-website/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
318 changes: 181 additions & 137 deletions app/src/App.jsx → client-website/src/App.tsx

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions client-website/src/api/NSYSUCourseAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { AcademicYear, NSYSUCourse, SemesterUpdate } from '@/types';

const BASE_URL = 'https://whats2000.github.io/NSYSUCourseAPI';

export class NSYSUCourseAPI {
/**
* 取得所有可用學期列表
*/
static async getAvailableSemesters(): Promise<AcademicYear> {
const response = await fetch(`${BASE_URL}/version.json`);
if (!response.ok) {
throw new Error('Failed to fetch available semesters');
}
return response.json();
}

/**
* 取得指定學年度的學期更新資訊
* @param academicYear - 學年度
*/
static async getSemesterUpdates(
academicYear: string,
): Promise<SemesterUpdate> {
const response = await fetch(`${BASE_URL}/${academicYear}/version.json`);
if (!response.ok) {
throw new Error('Failed to fetch semester updates');
}
return response.json();
}

/**
* 取得指定學年度、更新時間的所有課程
* @param academicYear - 學年度
* @param updateTime - 更新時間
*/
static async getCourses(
academicYear: string,
updateTime: string,
): Promise<NSYSUCourse[]> {
const response = await fetch(
`${BASE_URL}/${academicYear}/${updateTime}/all.json`,
);
if (!response.ok) {
throw new Error('Failed to fetch courses');
}

return response.json().then((courses: NSYSUCourse[]) => {
return Array.from(new Set(courses.map((course) => course.id))).map(
(id) => courses.find((course) => course.id === id)!,
);
});
}

/**
* 取得最新學期的所有課程
*/
static async getLatestCourses(): Promise<NSYSUCourse[]> {
const semesters = await NSYSUCourseAPI.getAvailableSemesters();
const latestAcademicYear = semesters.latest;
const updates = await NSYSUCourseAPI.getSemesterUpdates(latestAcademicYear);
const latestUpdateTime = updates.latest;
return NSYSUCourseAPI.getCourses(latestAcademicYear, latestUpdateTime);
}
}
110 changes: 110 additions & 0 deletions client-website/src/api/NSYSUCourseAPIOld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Course, CourseDataFilesInfo } from '@/types';
import Papa from 'papaparse';

const BASE_URL =
'https://api.github.com/repos/CelleryLin/selector_helper_old/contents/all_classes';

export class NSYSUCourseAPIOld {
/**
* 解析 CSV 內容並返回去重的課程資料
* @param csvText CSV 文字
*/
private static parseCourseData(csvText: string): Course[] {
const results = Papa.parse<Course>(csvText, {
header: true,
skipEmptyLines: true,
});
return results.data.filter(
(course, index, self) =>
index ===
self.findIndex(
(c) =>
c['Name'] === course['Name'] &&
c['Number'] === course['Number'] &&
c['Teacher'] === course['Teacher'],
),
);
}

/**
* 取得所有可用課程資料檔案
*/
static async getAvailableSemesters(): Promise<CourseDataFilesInfo[]> {
try {
const response = await fetch(BASE_URL);
if (!response.ok) {
console.error('Failed to fetch available course data files');
return [];
}
const files = await response.json();
if (!Array.isArray(files)) {
console.error('Invalid course data files, it should be an array');
return [];
}

if (files.length === 0) {
console.error('No course data files found');
return [];
}

const groupedFiles = files
.filter((file) => file.name.endsWith('.csv'))
.reduce(
(
acc: {
[key: string]: CourseDataFilesInfo[];
},
file,
) => {
const match = file.name.match(/all_classes_(\d{3})([123])_/);
if (!match) return acc;

const key = `${match[1]}-${match[2]}`; // Group key: academicYear-semester
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(file);

return acc;
},
{},
);

// Select the latest file for each academic year and semester
return Object.values(groupedFiles).map((group) => {
return group.sort((a, b) => b.name.localeCompare(a.name))[0];
});
} catch (error) {
console.error(error);
}

return [];
}

/**
* 下載指定課程資料檔案
* @param version 課程資料檔案資訊
*/
static async getSemesterUpdates(
version: CourseDataFilesInfo,
): Promise<Course[]> {
try {
const response = await fetch(version.download_url);
if (!response.ok) {
console.error('Failed to fetch course data file');
return [];
}
const csvText = await response.text();

if (!csvText) {
console.error('Empty course data file');
return [];
}

return this.parseCourseData(csvText);
} catch (error) {
console.error(error);
return [];
}
}
}
File renamed without changes
1 change: 1 addition & 0 deletions client-website/src/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { Component } from 'react';
import { Modal, Button, Form, Card } from 'react-bootstrap';
import { entryNotificationConfig } from '../config';
import {
Megaphone,
FileEarmarkText,
ArrowUpCircle,
} from 'react-bootstrap-icons';
import styled from 'styled-components';
import { websiteColor } from '../config';

import { ENTRY_NOTIFICATION_CONFIG, WEBSITE_COLOR } from '../config';

const TextWithIcon = styled(Card.Text)`
display: flex;
@@ -21,13 +21,13 @@ const TextWithIcon = styled(Card.Text)`
`;

const FillFormButton = styled(Button)`
background-color: ${websiteColor.mainColor};
border-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
&:hover,
&:focus {
background-color: ${websiteColor.mainDarkerColor};
border-color: ${websiteColor.mainDarkerColor};
background-color: ${WEBSITE_COLOR.mainDarkerColor};
border-color: ${WEBSITE_COLOR.mainDarkerColor};
}
a {
@@ -47,18 +47,18 @@ class EntryNotification extends Component {

if (
announcementSeen !== 'true' ||
versionSeen !== entryNotificationConfig.version
versionSeen !== ENTRY_NOTIFICATION_CONFIG.version
) {
this.setState({ show: true });
}
}

/**
* 渲染列表
* @param items {string[]} 列表項目
* @returns {JSX.Element[]} 列表元素
* @param items {(string | React.ReactNode)[]} 列表項目
* @returns {React.ReactNode} 列表元素
*/
renderList(items) {
renderList(items: (string | React.ReactNode)[]): React.ReactNode {
return items.map((item, index) => <li key={index}>{item}</li>);
}

@@ -73,12 +73,12 @@ class EntryNotification extends Component {
* 處理「不再顯示」的事件
* @param event {Event} 事件
*/
handleDontShowAgain = (event) => {
handleDontShowAgain = (event: React.ChangeEvent<HTMLInputElement>) => {
const { checked } = event.target;
localStorage.setItem('entryNotificationSeen', checked ? 'true' : 'false');
localStorage.setItem(
'entryNotificationVersion',
entryNotificationConfig.version,
ENTRY_NOTIFICATION_CONFIG.version,
);
};

@@ -94,24 +94,24 @@ class EntryNotification extends Component {
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{entryNotificationConfig.description}</p>
<p>{ENTRY_NOTIFICATION_CONFIG.description}</p>

<TextWithIcon>
<ArrowUpCircle /> 更新內容:
</TextWithIcon>
<ul>{this.renderList(entryNotificationConfig.updates)}</ul>
<ul>{this.renderList(ENTRY_NOTIFICATION_CONFIG.updates)}</ul>

<TextWithIcon>
<FileEarmarkText /> 回饋表單:
</TextWithIcon>
<ul>
<li>
<a
href={entryNotificationConfig.feedbackFormUrl}
href={ENTRY_NOTIFICATION_CONFIG.feedbackFormUrl}
target='_blank'
rel='noreferrer'
>
{entryNotificationConfig.feedbackFormUrl}
{ENTRY_NOTIFICATION_CONFIG.feedbackFormUrl}
</a>
</li>
</ul>
@@ -128,7 +128,7 @@ class EntryNotification extends Component {
</Button>
<FillFormButton>
<a
href={entryNotificationConfig.feedbackFormUrl}
href={ENTRY_NOTIFICATION_CONFIG.feedbackFormUrl}
target='_blank'
rel='noreferrer'
>
Original file line number Diff line number Diff line change
@@ -13,16 +13,19 @@ import {
JournalCheck,
List,
Megaphone,
Moisture,
Search,
} from 'react-bootstrap-icons';
import styled from 'styled-components';
import { websiteColor } from '../config';
import ReactGA from 'react-ga4';
import Banner from './banner.svg';

import type { AcademicYear, CourseDataFilesInfo } from '@/types';
import { WEBSITE_COLOR } from '../config';
import Banner from '../assets/banner.svg';

// 自定義 Navbar 樣式
const StyledNavbar = styled(Navbar)`
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
transition: top 0.3s; // 添加過渡效果
`;

@@ -81,7 +84,7 @@ const StyledNavDropdown = styled(NavDropdown)`
.dropdown-item {
&:hover {
color: white;
background-color: ${websiteColor.mainDarkerColor};
background-color: ${WEBSITE_COLOR.mainDarkerColor};
}
}
}
@@ -102,7 +105,27 @@ const StyledNavDropdown = styled(NavDropdown)`
}
`;

class Header extends Component {
interface HeaderProps {
currentTab: string;
currentCourseHistoryData: string;
availableCourseHistoryData: CourseDataFilesInfo[];
onTabChange: (tab: string) => void;
switchVersion: (version: CourseDataFilesInfo) => void;
convertVersion: (version: string) => string | React.ReactNode;
toggleExperimentalFeatures: () => void;
isExperimentalFeaturesEnabled: boolean;
selectedSemester: string;
availableSemesters: AcademicYear;
onSemesterChange: (semester: string) => void;
}

interface HeaderState {
showOffcanvas: boolean;
lastScrollY: number;
showNavbar: boolean;
}

class Header extends Component<HeaderProps, HeaderState> {
state = {
showOffcanvas: false,
lastScrollY: 0,
@@ -132,6 +155,12 @@ class Header extends Component {
},
];

semesterCodeMap = {
1: '上',
2: '下',
3: '暑',
};

/**
* 切換 Offcanvas 顯示狀態
*/
@@ -143,7 +172,7 @@ class Header extends Component {
* 點擊 Offcanvas 中的鏈接時,關閉 Offcanvas
* @param tab 鏈接標題
*/
handleNavClick = (tab) => {
handleNavClick = (tab: string) => {
this.props.onTabChange(tab);

// send ga4 event
@@ -172,6 +201,17 @@ class Header extends Component {
window.removeEventListener('scroll', this.handleScroll);
}

/**
* 將學期代碼轉換為顯示格式
* @param semester 學期代碼
*/
convertSemesterToDisplay = (semester: string) => {
const year = semester.slice(0, -1);
const semesterCode =
this.semesterCodeMap[parseInt(semester.slice(-1)) as 1 | 2 | 3];
return `${year} ${semesterCode}`;
};

/**
* 註冊 scroll 事件
*/
@@ -197,6 +237,11 @@ class Header extends Component {
availableCourseHistoryData,
switchVersion,
convertVersion,
toggleExperimentalFeatures,
isExperimentalFeaturesEnabled,
availableSemesters,
selectedSemester,
onSemesterChange,
} = this.props;
const currentVersionDisplay = convertVersion(currentCourseHistoryData);

@@ -240,27 +285,80 @@ class Header extends Component {
activeKey={currentTab}
>
<Nav.Item className='d-flex align-items-center'>
<StyledNavDropdown
title={
(
<>
<ClockHistory />
<span className='ms-2'>{currentVersionDisplay}</span>
</>
) || '找尋資料中...'
}
id='nav-dropdown-course-history'
className='px-3 float-start'
{!isExperimentalFeaturesEnabled && (
<StyledNavDropdown
title={
currentVersionDisplay ? (
<>
<ClockHistory />
<span className='ms-2'>
{currentVersionDisplay}
</span>
</>
) : (
'找尋資料中...'
)
}
id='nav-dropdown-course-history'
className='px-3 float-start'
>
{availableCourseHistoryData.map((data, index) => (
<NavDropdown.Item
key={index}
onClick={() => switchVersion(data)}
>
{convertVersion(data.name)}
</NavDropdown.Item>
))}
</StyledNavDropdown>
)}
{isExperimentalFeaturesEnabled && (
<StyledNavDropdown
title={
selectedSemester && availableSemesters.history ? (
<>
<ClockHistory />
<span className='ms-2'>
{this.convertSemesterToDisplay(selectedSemester)}
</span>
</>
) : (
'找尋資料中...'
)
}
id='nav-dropdown-course-history'
className='px-3 float-start'
>
{Object.keys(availableSemesters.history)
.sort((a, b) => b.localeCompare(a))
.map((year) => ({
key: year,
label: this.convertSemesterToDisplay(year),
value: year,
}))
.map((semester) => (
<NavDropdown.Item
key={semester.key}
onClick={() => onSemesterChange(semester.value)}
>
{semester.label}
</NavDropdown.Item>
))}
</StyledNavDropdown>
)}
</Nav.Item>
<Nav.Item className='d-flex align-items-center'>
<Nav.Link
onClick={toggleExperimentalFeatures}
className='px-3 d-flex align-items-center'
>
{availableCourseHistoryData.map((data, index) => (
<NavDropdown.Item
key={index}
onClick={() => switchVersion(data)}
>
{convertVersion(data.name)}
</NavDropdown.Item>
))}
</StyledNavDropdown>
<Moisture />
<span className='ms-2'>
{isExperimentalFeaturesEnabled
? '關閉實驗性功能'
: '開啟自動更新API (實驗性)'}
</span>
</Nav.Link>
</Nav.Item>
</Nav>
<Nav
Original file line number Diff line number Diff line change
@@ -25,7 +25,11 @@ const LoadingContainer = styled.div`
}
`;

class LoadingSpinner extends Component {
interface LoadingSpinnerProps {
loadingName: string;
}

class LoadingSpinner extends Component<LoadingSpinnerProps> {
render() {
const { loadingName } = this.props;

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Component } from 'react';
import styled from 'styled-components';

import type { Course, TimeSlot, Weekday } from '@/types';
import CourseBlock from './ScheduleTable/CourseBlock';

import { timeSlot, weekday } from '../config';
import { TIMESLOT, WEEKDAY } from '../config';

const StyledTableContainer = styled.div`
border-radius: 0.375rem;
@@ -22,15 +22,17 @@ const HeaderCell = styled.th`
font-weight: normal;
padding: 2px !important;
// background-color: lightgray !important;
opacity: 1; !important;
opacity: 1;
!important;
`;

const TimeSlotCell = styled.th`
width: 4%;
padding: 2px !important;
font-weight: normal;
// background-color: lightgray !important;
opacity: 1; !important;
opacity: 1;
!important;
`;

// Cellery: 更改選取樣式
@@ -54,20 +56,34 @@ const CourseCellDetectiveMode = styled(CourseCell)`
}
`;

class ScheduleTable extends Component {
interface ScheduleTableProps {
selectedCourses: Set<Course>;
currentTab: string;
handleCourseSelect: (course: Course, isSelected: boolean) => void;
hoveredCourseId: string | null;
onCourseHover: (courseId: string | null) => void;
searchTimeSlot: TimeSlot[];
toggleSearchTimeSlot: (timeSlot: TimeSlot) => void;
}

class ScheduleTable extends Component<ScheduleTableProps> {
setting = {
columns: weekday.length + 1,
weekday: weekday,
timeSlots: timeSlot,
columns: WEEKDAY.length + 1,
weekday: WEEKDAY,
timeSlots: TIMESLOT,
};

/**
* 建立課表
* @returns {{}} 課表
*/
createCourseTable = () => {
createCourseTable = (): {
[key: string]: Course[];
} => {
const { selectedCourses } = this.props;
const coursesTable = {};
const coursesTable: {
[key: string]: Course[];
} = {};

this.setting.timeSlots.forEach((timeSlot) => {
this.setting.weekday.forEach((weekday) => {
@@ -89,8 +105,10 @@ class ScheduleTable extends Component {

/**
* 切換課程時間區塊選取狀態
* @param {string} weekday 星期
* @param {string} timeSlot 節次
*/
toggleTimeSlotSelect = (weekday, timeSlot) => {
toggleTimeSlotSelect = (weekday: Weekday, timeSlot: string) => {
const { currentTab, toggleSearchTimeSlot } = this.props;
if (currentTab !== '課程偵探') return;

@@ -99,9 +117,11 @@ class ScheduleTable extends Component {

/**
* 檢查時間區塊是否被選取
* @param {string} weekday 星期
* @param {string} timeSlot 節次
* @returns {boolean}
*/
isTimeSlotSelected = (weekday, timeSlot) => {
isTimeSlotSelected = (weekday: string, timeSlot: string): boolean => {
const { currentTab, searchTimeSlot } = this.props;
if (currentTab !== '課程偵探') return false;
return searchTimeSlot.some(
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Component } from 'react';
import { Trash3 } from 'react-bootstrap-icons';
import styled from 'styled-components';
import { websiteColor } from '../../config';

import type { Course } from '@/types';
import { WEBSITE_COLOR } from '../../config';

const DeleteButton = styled.div`
position: absolute;
top: -10px;
left: -10px;
width: 15px;
height: 15px;
background-color: ${websiteColor.mainLighterColor};
color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainLighterColor};
color: ${WEBSITE_COLOR.mainColor};
border-radius: 50%;
display: flex;
align-items: center;
@@ -44,7 +46,14 @@ const StyledCourseBlock = styled.div`
}
`;

class CourseBlock extends Component {
interface CourseBlockProps {
course: Course;
onCourseHover: (courseId: string | null) => void;
hoveredCourseId: string | null;
handleCourseSelect: (course: Course, isSelected: boolean) => void;
}

class CourseBlock extends Component<CourseBlockProps> {
/**
* 處理刪除課程的函數
*/
@@ -58,7 +67,7 @@ class CourseBlock extends Component {
* @param courseUniqueCode {string} 課程唯一代碼
* @returns {string} 顏色
*/
getHashColor = (courseUniqueCode) => {
getHashColor = (courseUniqueCode: string): string => {
let hash = 0;
// Cellery: 只保留最前面的英文代碼,屬性一樣者用同樣顏色
courseUniqueCode = courseUniqueCode.replace(/([a-zA-Z]*).*/g, '$1');
@@ -79,11 +88,11 @@ class CourseBlock extends Component {

const courseBlockStyle = {
backgroundColor: isHover
? websiteColor.mainColor
? WEBSITE_COLOR.mainColor
: this.getHashColor(course['Number'] + course['Name']),
color: isHover ? 'white' : 'initial',
boxShadow: isHover
? `0 0 0 0.25rem ${websiteColor.boxShadowColor}`
? `0 0 0 0.25rem ${WEBSITE_COLOR.boxShadowColor}`
: 'none',
// whats2000: 字體大小調整
fontSize: '0.75rem',
Original file line number Diff line number Diff line change
@@ -1,79 +1,94 @@
import { Component } from 'react';
import React, { Component } from 'react';

import type { Course, TimeSlot } from '@/types';
import { COURSE_DAY_NAMES, DEFAULT_FILTER_OPTIONS } from '../config';
import AllCourse from './SelectorSetting/AllCourse';
import RequiredCourse from './SelectorSetting/RequiredCourse';
import CourseDetective from './SelectorSetting/CourseDetective';
import Announcement from './SelectorSetting/Announcement';
import SelectedCourse from './SelectorSetting/SelectedCourse';

import { courseDayName, defaultFilterOptions } from '../config';
interface SelectorSettingProps {
isCollapsed: boolean;
currentTab: string;
courses: Course[];
selectedCourses: Set<Course>;
hoveredCourseId: string | null;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onClearAllSelectedCourses: () => void;
onCourseHover: (courseId: string | null) => void;
latestCourseHistoryData: string;
convertVersion: (version: string) => string | React.ReactNode;
searchTimeSlot: TimeSlot[];
}

interface SelectorSettingState {
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
}

class SelectorSetting extends Component {
class SelectorSetting extends Component<
SelectorSettingProps,
SelectorSettingState
> {
state = {
filterOptions: defaultFilterOptions,
filterOptions: DEFAULT_FILTER_OPTIONS,
};

componentDidMount() {
this.calculateFilterOptions(this.props.courses);
}

componentDidUpdate(prevProps, prevState, snapshot) {
componentDidUpdate(prevProps: SelectorSettingProps) {
if (prevProps.courses !== this.props.courses) {
this.calculateFilterOptions(this.props.courses);
}
}

/**
* 計算篩選選項
* @param courses {Array} 課程列表
* @param courses {Course[]} 課程列表
*/
calculateFilterOptions = (courses) => {
calculateFilterOptions = (courses: Course[]) => {
let updateFilterOptions = {
系所: new Set(),
學分: new Set(),
系所: new Set<string>(),
學分: new Set<string>(),
};

courses?.forEach((course) => {
updateFilterOptions['系所'].add(course['Department']);
updateFilterOptions['學分'].add(course['Credit'].toString());
});

// 將 Set 轉換為 Array
updateFilterOptions['系所'] = Array.from(
updateFilterOptions['系所'],
).sort();
updateFilterOptions['學分'] = Array.from(
updateFilterOptions['學分'],
).sort();

// 更新狀態
this.setState((prevState) => ({
filterOptions: {
...prevState.filterOptions,
系所: {
...prevState.filterOptions['系所'],
options: updateFilterOptions['系所'],
dropdown: prevState.filterOptions['系所'].dropdown,
options: Array.from(updateFilterOptions['系所']).sort(),
},
學分: {
...prevState.filterOptions['學分'],
options: updateFilterOptions['學分'],
dropdown: prevState.filterOptions['學分'].dropdown,
options: Array.from(updateFilterOptions['學分']).sort(),
},
},
}));
};

/**
* 計算總學分與總時數
* @param selectedCourses {Set: Object} 已選課程的 Set
* @param selectedCourses {Set} 已選擇的課程集合
* @returns {{totalCredits: number, totalHours: number}} 總學分與總時數
*/
calculateTotalCreditsAndHours = (selectedCourses) => {
calculateTotalCreditsAndHours = (
selectedCourses: Set<Course>,
): { totalCredits: number; totalHours: number } => {
let totalCredits = 0;
let totalHours = 0;

selectedCourses.forEach((course) => {
totalCredits += parseFloat(course['Credit'] ?? '0.0');
courseDayName.forEach((day) => {
COURSE_DAY_NAMES.forEach((day) => {
totalHours += course[day]?.length ?? 0;
});
});
@@ -87,7 +102,10 @@ class SelectorSetting extends Component {
* @param selectedCourses {Set} 已選擇的課程集合
* @returns {boolean} 如果衝突,返回 true
*/
detectTimeConflict = (course, selectedCourses) => {
detectTimeConflict = (
course: Course,
selectedCourses: Set<Course>,
): boolean => {
for (let selectedCourse of selectedCourses) {
if (this.isConflict(course, selectedCourse)) {
return true;
@@ -98,12 +116,12 @@ class SelectorSetting extends Component {

/**
* 判斷兩個課程是否衝突
* @param course1 {Object} 第一個課程
* @param course2 {Object} 第二個課程
* @param course1 {Course} 第一個課程
* @param course2 {Course} 第二個課程
* @returns {boolean} 如果衝突,返回 true
*/
isConflict = (course1, course2) => {
for (let day of courseDayName) {
isConflict = (course1: Course, course2: Course): boolean => {
for (let day of COURSE_DAY_NAMES) {
if (course1[day] && course2[day]) {
const time1 = course1[day].split('');
const time2 = course2[day].split('');
@@ -153,7 +171,6 @@ class SelectorSetting extends Component {
selectedCourses={selectedCourses}
hoveredCourseId={hoveredCourseId}
onCourseSelect={onCourseSelect}
onClearAllSelectedCourses={onClearAllSelectedCourses}
onCourseHover={onCourseHover}
filterOptions={filterOptions}
detectTimeConflict={this.detectTimeConflict}
@@ -182,7 +199,6 @@ class SelectorSetting extends Component {
onCourseHover={onCourseHover}
selectedCourses={selectedCourses}
calculateTotalCreditsAndHours={this.calculateTotalCreditsAndHours}
onClearAllSelectedCourses={onClearAllSelectedCourses}
/>
),
公告: (
@@ -195,7 +211,9 @@ class SelectorSetting extends Component {

return (
<>
{mapTabToComponent[currentTab] ?? (
{currentTab in mapTabToComponent ? (
mapTabToComponent[currentTab as keyof typeof mapTabToComponent]
) : (
<h1>我很確 Tab 傳遞某處出錯,請回報</h1>
)}
</>
Original file line number Diff line number Diff line change
@@ -2,11 +2,22 @@ import { Component } from 'react';
import { Card, Col } from 'react-bootstrap';
import styled from 'styled-components';

import type {
AdvancedFilterType,
AdvancedFilterElement,
AdvancedFilterOption,
FilterOption,
Course,
} from '@/types';
import {
COURSE_DATA_NAME_MAP,
COURSE_DAY_NAMES,
DEFAULT_ADVANCE_FILTER,
DEFAULT_FILTER_OPTIONS,
} from '../../config';
import CoursesList from './AllCourse/CoursesList';
import ListInformation from './AllCourse/ListInformation';

import { courseDataNameMap, courseDayName } from '../../config';

const StyledCardBody = styled(Card.Body)`
height: 100%;
padding: 0;
@@ -16,10 +27,34 @@ const StyledCardBody = styled(Card.Body)`
}
`;

class AllCourse extends Component {
interface AllCourseProps {
courses: Course[];
selectedCourses: Set<Course>;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onClearAllSelectedCourses: () => void;
onCourseHover: (courseId: string | null) => void;
hoveredCourseId: string | null;
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
isCollapsed: boolean;
detectTimeConflict: (course: Course, selectedCourses: Set<Course>) => boolean;
calculateTotalCreditsAndHours: (courses: Set<Course>) => {
totalCredits: number;
totalHours: number;
};
}

interface AllCourseState {
basicFilter: string;
advancedFilters: AdvancedFilterType;
displaySelectedOnly: boolean;
displayConflictCourses: boolean;
filteredCourses: Course[];
}

class AllCourse extends Component<AllCourseProps, AllCourseState> {
state = {
basicFilter: '',
advancedFilters: {},
advancedFilters: DEFAULT_ADVANCE_FILTER,
displaySelectedOnly: false,
displayConflictCourses: true,
filteredCourses: this.props.courses,
@@ -40,7 +75,7 @@ class AllCourse extends Component {
}
}

componentDidUpdate(prevProps, prevState, snapshot) {
componentDidUpdate(prevProps: AllCourseProps, prevState: AllCourseState) {
if (
this.state.basicFilter !== prevState.basicFilter ||
this.state.advancedFilters !== prevState.advancedFilters ||
@@ -62,23 +97,22 @@ class AllCourse extends Component {
* 處理基本篩選事件
* @param filter {string} 篩選字串
*/
handleBasicFilterChange = (filter) => {
handleBasicFilterChange = (filter: string) => {
this.setState({ basicFilter: filter });
};

/**
* 處理進階篩選事件
* @param advancedFilters {object} 進階篩選器
*/
handleAdvancedFilterChange = (advancedFilters) => {
handleAdvancedFilterChange = (advancedFilters: AdvancedFilterType) => {
this.setState({ advancedFilters });
};

/**
* 獲取過濾後的課程列表
* @returns {Array} 過濾後的課程列表
*/
getFilteredCourses = () => {
getFilteredCourses = (): Course[] => {
const { courses } = this.props;
const { basicFilter, displayConflictCourses, advancedFilters } = this.state;

@@ -115,15 +149,28 @@ class AllCourse extends Component {

/**
* 獲取進階過濾後的課程列表
* @param courses {Array} 課程列表
* @param filters {Object} 進階篩選器
* @returns {Array} 過濾後的課程列表
* @param courses {Course[]} 課程列表
* @param advancedFilters {AdvancedFilterType} 進階篩選器
* @returns {Course[]} 過濾後的課程列表
*/
applyAdvancedFilters = (courses, filters) => {
applyAdvancedFilters = (
courses: Course[],
advancedFilters: AdvancedFilterType,
): Course[] => {
return courses.filter((course) => {
return Object.entries(filters).every(([filterName, filter]) => {
return (
Object.entries(advancedFilters) as [
AdvancedFilterOption,
AdvancedFilterElement,
][]
).every(([filterName, filter]) => {
// 文字篩選器:只要有文字,就進行篩選
if (filter.value !== undefined && filter.value !== '') {
if (
filter.value !== undefined &&
filter.value !== '' &&
filterName !== '星期' &&
filterName !== '節次'
) {
return this.applyTextFilter(course, filterName, filter);
}

@@ -145,18 +192,21 @@ class AllCourse extends Component {

/**
* 用匹配文字篩選課程
* @param course {Object} 課程
* @param course {Course} 課程
* @param filterName {string} 篩選器名稱
* @param filter {Object} 篩選器
* @param filter {FilterOption} 篩選器
* @returns {boolean} 如果匹配,返回 true
*/
applyTextFilter = (course, filterName, filter) => {
// console.log(filter);
const courseValue = course[courseDataNameMap[filterName]]?.toLowerCase();
applyTextFilter = (
course: Course,
filterName: FilterOption,
filter: AdvancedFilterElement,
): boolean => {
const courseValue = course[COURSE_DATA_NAME_MAP[filterName]]?.toLowerCase();
const filterLogic =
filter.filterLogic === undefined ? 'include' : filter.filterLogic; // equal, include, exclude
// 使用逗號或空格分割每個組
const filterGroups = filter.value.toLowerCase().split(/[,]/);
const filterGroups: string[] = filter.value.toLowerCase().split(/[,]/);

return filterGroups.some((group) => {
// 使用空格分割每個關鍵字
@@ -178,57 +228,57 @@ class AllCourse extends Component {

/**
* 用星期或節次篩選課程
* @param course {Object} 課程
* @param course {Course} 課程
* @param filterName {string} 篩選器名稱
* @param filter {Object} 篩選器
* @returns {boolean} 如果匹配,返回 true
*/
applyTimeFilter = (course, filterName, filter) => {
applyTimeFilter = (
course: Course,
filterName: string,
filter: AdvancedFilterElement,
): boolean => {
// 檢查是否包含或排除
const filterLogic =
filter.filterLogic === undefined ? 'include' : filter.filterLogic;
const filterLogic = filter.filterLogic ?? 'include'; // equal, include, exclude

if (filterName === '星期') {
if (filterLogic !== 'equal') {
// 檢查是否有任何一天匹配
const daysMatched = courseDayName.some((day) => {
return filter[day] && course[day];
const daysMatched = COURSE_DAY_NAMES.some((day) => {
return filter.activeOptions[day] && course[day];
});
return filterLogic === 'include' ? daysMatched : !daysMatched;
} else {
// 檢查是否有所有天匹配
const daysMatched = courseDayName.every((day) => {
return (filter[day] === true) === (course[day] !== '');
return COURSE_DAY_NAMES.every((day) => {
return filter.activeOptions[day] === (course[day] !== '');
});
return daysMatched;
}
}

if (filterName === '節次') {
if (filterLogic !== 'equal') {
// 檢查是否有任何一節匹配
let periodsMatched = false;
courseDayName.forEach((day) => {
COURSE_DAY_NAMES.forEach((day) => {
if (course[day]) {
periodsMatched =
periodsMatched ||
course[day].split('').some((period) => {
return filter[period];
return filter.activeOptions[period];
});
}
});
return filterLogic === 'include' ? periodsMatched : !periodsMatched;
} else {
// 檢查是否有所有節次匹配
let periodsMatched = true;
let filterPeriods = Object.keys(filter)
.filter(
(key) => key !== 'active' && key !== 'filterLogic' && filter[key],
)
let filterPeriods = Object.keys(filter.activeOptions)
.filter((period) => filter.activeOptions[period])
.sort()
.join('');
// console.log(filterPeriods);
courseDayName.forEach((day) => {
COURSE_DAY_NAMES.forEach((day) => {
if (course[day]) {
periodsMatched =
periodsMatched &&
@@ -247,27 +297,30 @@ class AllCourse extends Component {
/**
* 用選項篩選課程
* @param course {Object} 課程
* @param filterName {string} 篩選器名稱
* @param filterName {FilterOption} 篩選器名稱
* @param filter {Object} 篩選器
* @returns {boolean} 如果匹配,返回 true
*/
applyOptionFilter = (course, filterName, filter) => {
applyOptionFilter = (
course: Course,
filterName: FilterOption,
filter: AdvancedFilterElement,
): boolean => {
// equal method is not implemented
let isInclude = filter.filterLogic === 'include' ? 'include' : 'exclude';
const courseValue = course[courseDataNameMap[filterName]]?.toString();
let matched = Object.keys(filter).some((option) => {
if (option === 'active' || option === 'include') return false;
return filter[option] && courseValue === option;
const courseValue = course[COURSE_DATA_NAME_MAP[filterName]]?.toString();
let matched = Object.keys(filter.activeOptions).some((option) => {
return filter.activeOptions[option] && courseValue === option;
});
return isInclude ? matched : !matched;
};

/**
* 過濾掉時間衝突的課程
* @param courses {Array} 課程列表
* @returns {Array} 經過時間衝突過濾的課程列表
* @param courses {Course[]} 課程列表
* @returns {Course[]} 經過時間衝突過濾的課程列表
*/
filterOutConflictCourses = (courses) => {
filterOutConflictCourses = (courses: Course[]): Course[] => {
const { selectedCourses } = this.props;
return courses.filter((course) => {
// 如果課程已被選擇,則不進行衝突檢查,直接保留
@@ -297,7 +350,6 @@ class AllCourse extends Component {

render() {
const {
courses,
selectedCourses,
onCourseSelect,
onClearAllSelectedCourses,
@@ -328,7 +380,6 @@ class AllCourse extends Component {
</Card.Subtitle>
</Card.Header>
<ListInformation
courses={courses}
selectedCourses={selectedCourses}
onClearAllSelectedCourses={onClearAllSelectedCourses}
basicFilter={basicFilter}
@@ -354,6 +405,7 @@ class AllCourse extends Component {
displayConflictCourses={displayConflictCourses}
detectTimeConflict={detectTimeConflict}
hoveredCourseId={hoveredCourseId}
displaySelectedOnly={displaySelectedOnly}
onCourseSelect={onCourseSelect}
onCourseHover={onCourseHover}
/>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import { Component } from 'react';
import styled from 'styled-components';

const HeaderRow = styled.div`
Original file line number Diff line number Diff line change
@@ -1,76 +1,44 @@
import { Component } from 'react';
import styled from 'styled-components';
import ReactGA from 'react-ga4';
import { Form, OverlayTrigger, Popover, Stack } from 'react-bootstrap';
import type { Placement } from 'react-bootstrap/types';

const CourseRow = styled.div`
font-size: 12px;
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
background-color: #fafafa;
import type { Course, Weekday } from '@/types';
import {
CourseInfo,
CourseRow,
SmallCourseInfo,
StyledLink,
StyledPopover,
Tag,
TinyCourseInfo,
} from '#/common/CommonStyle.tsx';

&:hover {
background-color: #f0f0f0;
}
`;

const CourseInfo = styled.div`
flex: 1;
text-align: center;
overflow: hidden;
text-overflow: fade;
font-size: 0.8rem;
&:last-child {
margin-right: 0;
}
`;

const SmallCourseInfo = styled(CourseInfo)`
flex: 0.4;
`;

const TinyCourseInfo = styled(CourseInfo)`
flex: 0.25;
`;

const Tag = styled.div`
background-color: #eef;
border: 1px solid #ddf;
border-radius: 4px;
padding: 2px 5px;
margin: 2px;
font-size: 0.7rem;
font-weight: bold;
`;

const StyledLink = styled.a`
display: inline-block;
text-decoration: none;
color: black;
&:hover {
text-decoration: underline;
}
`;
interface ItemProps {
course: Course;
isConflict: boolean;
isSelected: boolean;
isHovered: boolean;
isCollapsed: boolean;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onCourseHover: (courseId: string | null) => void;
}

const StyledPopover = styled(Popover)`
&.popover {
// whats2000: 正式修正彈出視窗位置抖動問題
position: fixed;
max-width: 400px;
}
`;
interface ItemState {
showPopover: boolean;
placement: Placement | undefined;
}

class Item extends Component {
class Item extends Component<ItemProps, ItemState> {
state = {
showPopover: false,
placement: window.innerWidth < 992 ? 'bottom' : 'left',
placement:
window.innerWidth < 992 ? ('left' as Placement) : ('left' as Placement),
};

infoCells = {
infoCells: {
[key: string]: keyof Course;
} = {
名額: 'Restrict',
點選: 'Select',
選上: 'Selected',
@@ -122,7 +90,7 @@ class Item extends Component {
* 處理彈出視窗顯示
* @param show
*/
handleTogglePopover = (show) => {
handleTogglePopover = (show: boolean) => {
// Cellery: 延遲顯示彈出視窗防止閃爍
this.setState({ showPopover: show });
};
@@ -178,7 +146,7 @@ class Item extends Component {
Saturday: '六',
Sunday: '日',
};
const time = Object.keys(days)
const time = (Object.keys(days) as Weekday[])
.map((day) =>
course[day] ? (
<Tag key={day}>
@@ -212,7 +180,7 @@ class Item extends Component {
// whats2000: 去除重複的學程
.filter((program, index, self) => self.indexOf(program) === index)
.map((program) => (
<Tag key={program}>{program.trim().replaceAll("'", '')}</Tag>
<Tag key={program}>{program.trim().replace("'", '')}</Tag>
))
) : (
<></>
@@ -304,7 +272,7 @@ class Item extends Component {
}
>
{classColor[course['Class']]
? `${classColor[course['Class']].name} ${gradeCode[course['Grade']]}`
? `${classColor[course['Class']].name} ${gradeCode[parseInt(course['Grade'])]}`
: '缺失'}
</Tag>
);
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import React, { Component } from 'react';
import { Card } from 'react-bootstrap';
import { Virtuoso } from 'react-virtuoso';
import Item from './AllCourseList/Item';

import type { Course } from '@/types';
import Header from './AllCourseList/Header';
import Item from './AllCourseList/Item';

class CoursesList extends Component {
interface CoursesListProps {
isCollapsed: boolean;
courses: Course[];
displaySelectedOnly: boolean;
selectedCourses: Set<Course>;
displayConflictCourses: boolean;
detectTimeConflict: (course: Course, selectedCourses: Set<Course>) => boolean;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onCourseHover: (courseId: string | null) => void;
hoveredCourseId: string | null;
}

class CoursesList extends Component<CoursesListProps> {
/**
* 渲染列表項目
* @param {number} index
* @returns {JSX.Element}
* @returns {React.ReactNode}
*/
renderItem = (index) => {
renderItem = (index: number): React.ReactNode => {
if (index === 0) {
return <Header />;
}
@@ -82,7 +96,7 @@ class CoursesList extends Component {
data={dataWithHeader}
itemContent={this.renderItem}
topItemCount={1}
></Virtuoso>
/>
);
}
}
Original file line number Diff line number Diff line change
@@ -8,7 +8,9 @@ import {
} from 'react-bootstrap';
import AdvancedFilter from './ListInformation/AdvancedFilter';
import styled from 'styled-components';
import { websiteColor } from '../../../config';

import type { AdvancedFilterType, Course } from '@/types';
import { DEFAULT_FILTER_OPTIONS, WEBSITE_COLOR } from '@/config';
import ClearSelectedCourses from './ListInformation/ClearSelectedCourses';

const ButtonsRow = styled.div`
@@ -19,29 +21,47 @@ const ButtonsRow = styled.div`
/* 定義活動按鈕的樣式 */
.btn-check:checked + .btn {
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
color: white;
border-color: ${websiteColor.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
}
`;

const StyledButton = styled(ToggleButton)`
color: ${websiteColor.mainColor};
border-color: ${websiteColor.mainColor};
color: ${WEBSITE_COLOR.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
&:focus,
&:active:focus,
&:not(:disabled):not(.disabled).active:focus {
box-shadow: 0 0 0 0.2rem ${websiteColor.boxShadowColor};
box-shadow: 0 0 0 0.2rem ${WEBSITE_COLOR.boxShadowColor};
}
`;

class ListInformation extends Component {
interface ListInformationProps {
selectedCourses: Set<Course>;
basicFilter: string;
advancedFilters: AdvancedFilterType;
onBasicFilterChange: (value: string) => void;
onAdvancedFilterChange: (filter: AdvancedFilterType) => void;
onClearAllSelectedCourses: () => void;
displaySelectedOnly: boolean;
displayConflictCourses: boolean;
toggleDisplayConflictCourses: () => void;
toggleOnlySelected: () => void;
calculateTotalCreditsAndHours: (courses: Set<Course>) => {
totalCredits: number;
totalHours: number;
};
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
}

class ListInformation extends Component<ListInformationProps> {
/**
* 處理篩選課程名稱的事件
* @param e {React.ChangeEvent<HTMLInputElement>} 事件
*/
handleFilterChange = (e) => {
handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { onBasicFilterChange } = this.props;
onBasicFilterChange(e.target.value);
};
@@ -62,7 +82,6 @@ class ListInformation extends Component {

render() {
const {
courses,
selectedCourses,
basicFilter,
advancedFilters,
@@ -91,7 +110,6 @@ class ListInformation extends Component {
<InputGroup.Text>{totalCredits} 學分</InputGroup.Text>
<InputGroup.Text>{totalHours} 小時</InputGroup.Text>
<AdvancedFilter
courses={courses}
advancedFilters={advancedFilters}
onAdvancedFilterChange={onAdvancedFilterChange}
filterOptions={filterOptions}
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import React, { Component } from 'react';
import { Component } from 'react';
import { Offcanvas, Button } from 'react-bootstrap';
import { Filter } from 'react-bootstrap-icons';
import styled from 'styled-components';

import type { AdvancedFilterOption, AdvancedFilterType } from '@/types';
import { DEFAULT_FILTER_OPTIONS, WEBSITE_COLOR } from '@/config';
import FilterRow from './AdvancedFilter/FilterRow';
import { websiteColor } from '../../../../config';

const StyledButton = styled(Button)`
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
&:hover {
background-color: ${websiteColor.mainDarkerColor};
background-color: ${WEBSITE_COLOR.mainDarkerColor};
}
&:active {
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
}
`;

@@ -23,7 +25,20 @@ const StyledFilterRow = styled.div`
margin-bottom: 1rem;
`;

class AdvancedFilter extends Component {
interface AdvancedFilterProps {
advancedFilters: AdvancedFilterType;
onAdvancedFilterChange: (filter: AdvancedFilterType) => void;
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
}

interface AdvancedFilterState {
show: boolean;
}

class AdvancedFilter extends Component<
AdvancedFilterProps,
AdvancedFilterState
> {
state = {
show: false,
};
@@ -43,7 +58,10 @@ class AdvancedFilter extends Component {
* @param filterName {String} 篩選器名稱
* @param optionName {String} 選項名稱
*/
filterNameToDisplayName = (filterName, optionName) => {
filterNameToDisplayName = (
filterName: AdvancedFilterOption,
optionName: string,
) => {
const filter = this.props.filterOptions[filterName];

// 檢查是否有 displayName 映射
@@ -82,17 +100,19 @@ class AdvancedFilter extends Component {
<StyledFilterRow className='text-muted fst-italic'>
可以用空格分隔多個關鍵字,空格是「且」的意思,逗號是「或」的意思,系所篩選包含通識與博雅喔!
</StyledFilterRow>
{Object.keys(filterOptions).map((filterName, index) => (
<FilterRow
key={index}
filterOptions={filterOptions}
filterName={filterName}
isDropdown={filterOptions[filterName].dropdown}
advancedFilters={advancedFilters}
onAdvancedFilterChange={onAdvancedFilterChange}
filterNameToDisplayName={this.filterNameToDisplayName}
/>
))}
{(Object.keys(filterOptions) as AdvancedFilterOption[]).map(
(filterName, index) => (
<FilterRow
key={index}
filterOptions={filterOptions}
filterName={filterName}
isDropdown={filterOptions[filterName].dropdown}
advancedFilters={advancedFilters}
onAdvancedFilterChange={onAdvancedFilterChange}
filterNameToDisplayName={this.filterNameToDisplayName}
/>
),
)}
</Offcanvas.Body>
</Offcanvas>
</>
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import React, { Component } from 'react';
import { Dropdown, Form } from 'react-bootstrap';
import styled from 'styled-components';
import { websiteColor } from '../../../../../config';

import type {
AdvancedFilterType,
AdvancedFilterElement,
AdvancedFilterOption,
} from '@/types';
import { DEFAULT_FILTER_OPTIONS, WEBSITE_COLOR } from '@/config';

const StyledDropdownMenu = styled(Dropdown.Menu)`
max-height: 350px;
@@ -12,25 +18,25 @@ const StyledDropdownMenu = styled(Dropdown.Menu)`
&.active {
color: white;
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
}
&:active {
color: white;
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
}
}
`;

const StyledDropdownToggle = styled(Dropdown.Toggle)`
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
&:hover {
background-color: ${websiteColor.mainDarkerColor};
background-color: ${WEBSITE_COLOR.mainDarkerColor};
}
&:active {
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
}
`;

@@ -58,34 +64,50 @@ const FilterInput = styled.div`

const FilterSwitch = styled(Form.Check)`
.form-check-input:checked {
background-color: ${websiteColor.mainColor};
border-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
}
.form-check-input:focus {
border-color: ${websiteColor.mainColor};
box-shadow: 0 0 0 0.2rem ${websiteColor.boxShadowColor};
border-color: ${WEBSITE_COLOR.mainColor};
box-shadow: 0 0 0 0.2rem ${WEBSITE_COLOR.boxShadowColor};
}
`;

class FilterRow extends Component {
interface FilterRowProps {
filterName: AdvancedFilterOption;
isDropdown: boolean;
advancedFilters: AdvancedFilterType;
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
onAdvancedFilterChange: (filter: AdvancedFilterType) => void;
filterNameToDisplayName: (
AdvancedFilterOption: AdvancedFilterOption,
optionName: string,
) => string;
}

interface FilterRowState {
searchValue: string;
}

class FilterRow extends Component<FilterRowProps, FilterRowState> {
state = {
searchValue: '',
};

/**
* 計算篩選器選項
* @param e {InputEvent} 輸入事件
* @param e {ChangeEventHandler<HTMLInputElement>} 輸入事件
*/
handleSearchChange = (e) => {
handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searchValue: e.target.value.toLowerCase() });
};

/**
* 生成篩選器選項
* @returns {string[]} 篩選器選項
*/
generateFilteredOptions = () => {
generateFilteredOptions = (): string[] => {
const { searchValue } = this.state;
const { filterName, filterOptions } = this.props;

@@ -99,9 +121,11 @@ class FilterRow extends Component {
// 如果有顯示名稱,則先將顯示名稱轉換為小寫並與輸入值比較
if (hasDisplayName) {
const displayName =
filterOptions[filterName].optionDisplayName[index].toLowerCase();
filterOptions?.[filterName]?.optionDisplayName?.[index].toLowerCase();

return displayName.startsWith(searchValue.toLowerCase());
if (displayName) {
return displayName.startsWith(searchValue.toLowerCase());
}
}
// 如果沒有顯示名稱,則直接比較選項值
return option.toLowerCase().startsWith(searchValue.toLowerCase());
@@ -110,10 +134,10 @@ class FilterRow extends Component {

/**
* 處理篩選器模式變化
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
* @param mode {string} 篩選器模式
*/
handleFilterModeChange = (filterName, mode) => {
handleFilterModeChange = (filterName: AdvancedFilterOption, mode: string) => {
const updatedAdvancedFilters = {
...this.props.advancedFilters,
[filterName]: {
@@ -127,23 +151,24 @@ class FilterRow extends Component {

/**
* 全選
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
*/
handleSelectAll = (filterName) => {
handleSelectAll = (filterName: AdvancedFilterOption) => {
// 創建一個新的選中狀態對象,將所有選項設為選中
const selected = {
active: this.props.advancedFilters[filterName]?.active ?? false,
filterLogic:
this.props.advancedFilters[filterName]?.filterLogic ?? 'include',
const advancedFilterElement: AdvancedFilterElement = {
value: '',
active: this.props.advancedFilters[filterName].active,
filterLogic: this.props.advancedFilters[filterName].filterLogic,
activeOptions: {},
};
this.props.filterOptions[filterName].options.forEach((option) => {
selected[option] = true;
advancedFilterElement.activeOptions[option] = true;
});

// 創建 advancedFilters 的副本並更新
const updatedAdvancedFilters = {
...this.props.advancedFilters,
[filterName]: selected,
[filterName]: advancedFilterElement,
};

// 更新父組件的狀態
@@ -152,16 +177,18 @@ class FilterRow extends Component {

/**
* 取消全選
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
*/
handleDeselectAll = (filterName) => {
handleDeselectAll = (filterName: AdvancedFilterOption) => {
// 創建 advancedFilters 的副本並將對應篩選器的選中狀態清空
const updatedAdvancedFilters = {
...this.props.advancedFilters,
[filterName]: {
value: '',
active: this.props.advancedFilters[filterName]?.active ?? false,
filterLogic:
this.props.advancedFilters[filterName]?.filterLogic ?? 'include',
activeOptions: {},
},
};

@@ -171,15 +198,19 @@ class FilterRow extends Component {

/**
* 選擇/取消選擇選項
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
* @param option {string} 選項名稱
*/
handleOptionSelect = (filterName, option) => {
handleOptionSelect = (filterName: AdvancedFilterOption, option: string) => {
// 創建當前篩選選項的副本
const selected = { ...(this.props.advancedFilters[filterName] || {}) };

// 更新選項的選中狀態
selected[option] = !selected[option];
if (selected.activeOptions[option]) {
delete selected.activeOptions[option];
} else {
selected.activeOptions[option] = true;
}

// 創建整個 advancedFilters 的副本並更新相應的篩選器
const updatedAdvancedFilters = {
@@ -193,10 +224,13 @@ class FilterRow extends Component {

/**
* 處理文字篩選器變化
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
* @param value {string} 篩選器值
*/
handleTextFilterChange = (filterName, value) => {
handleTextFilterChange = (
filterName: AdvancedFilterOption,
value: string,
) => {
// 創建 advancedFilters 的副本並更新相應的過濾器值
const updatedAdvancedFilters = {
...this.props.advancedFilters,
@@ -212,9 +246,9 @@ class FilterRow extends Component {

/**
* 處理篩選器啟用狀態變化
* @param filterName {string} 篩選器名稱
* @param filterName {AdvancedFilterOption} 篩選器名稱
*/
handleFilterActivationChange = (filterName) => {
handleFilterActivationChange = (filterName: AdvancedFilterOption) => {
// 創建 advancedFilters 的副本並更新相應的過濾器值,預設是關閉的
const updatedAdvancedFilters = {
...this.props.advancedFilters,
@@ -235,7 +269,12 @@ class FilterRow extends Component {

const filteredOptions = this.generateFilteredOptions();

const selected = advancedFilters[filterName] || {};
const selected: AdvancedFilterElement = advancedFilters[filterName] || {
value: '',
active: false,
filterLogic: 'include',
activeOptions: {},
};
const textInput = advancedFilters[filterName]?.value || '';
const filterLogic = advancedFilters[filterName]?.filterLogic ?? 'include';

@@ -260,15 +299,7 @@ class FilterRow extends Component {
<Dropdown autoClose='outside'>
<StyledDropdownToggle variant='success'>
{!selected.active ? '未啟用' : '選擇了'}{' '}
{
Object.keys(selected).filter(
(key) =>
key !== 'active' &&
key !== 'filterLogic' &&
selected[key],
).length
}{' '}
{Object.keys(selected.activeOptions).length}
</StyledDropdownToggle>
<StyledDropdownMenu>
<Dropdown.Item
@@ -296,7 +327,7 @@ class FilterRow extends Component {
filteredOptions.map((option, index) => (
<Dropdown.Item
key={index}
active={selected[option]}
active={selected.activeOptions[option]}
onClick={() =>
this.handleOptionSelect(filterName, option)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import React, { Component } from 'react';
import { Component } from 'react';
import { Button, Modal } from 'react-bootstrap';

class ClearSelectedCourses extends Component {
interface ClearSelectedCoursesProps {
onClearAllSelectedCourses: () => void;
}

interface ClearSelectedCoursesState {
showModal: boolean;
}

class ClearSelectedCourses extends Component<
ClearSelectedCoursesProps,
ClearSelectedCoursesState
> {
state = {
showModal: false,
};
Original file line number Diff line number Diff line change
@@ -11,7 +11,8 @@ import {
Envelope,
} from 'react-bootstrap-icons';
import styled from 'styled-components';
import { announcementData } from '../../config';

import { ANNOUNCEMENT_DATA } from '../../config';

const TextWithIcon = styled(Card.Text)`
display: flex;
@@ -23,13 +24,18 @@ const TextWithIcon = styled(Card.Text)`
}
`;

class Announcement extends Component {
interface AnnouncementProps {
latestCourseHistoryData: string;
convertVersion: (version: string) => string | React.ReactNode;
}

class Announcement extends Component<AnnouncementProps> {
/**
* 渲染列表
* @param {string[]} items 列表項目
* @returns {JSX.Element[]} 列表元素
*/
renderList(items) {
renderList(items: React.ReactNode[]): React.ReactNode[] {
return items.map((item, index) => <li key={index}>{item}</li>);
}

@@ -39,7 +45,7 @@ class Announcement extends Component {
<Card>
<Card.Header className='text-center'>
<Card.Title className='fw-bolder mb-0 p-2'>
🙈中山大學選課小助手 {announcementData.version}
🙈中山大學選課小助手 {ANNOUNCEMENT_DATA.version}
</Card.Title>
</Card.Header>
<Card.Body>
@@ -59,11 +65,11 @@ class Announcement extends Component {
<ul>
<li>
<a
href={announcementData.feedbackFormUrl}
href={ANNOUNCEMENT_DATA.feedbackFormUrl}
target='_blank'
rel='noreferrer'
>
{announcementData.feedbackFormUrl}
{ANNOUNCEMENT_DATA.feedbackFormUrl}
</a>
</li>
</ul>
@@ -72,25 +78,25 @@ class Announcement extends Component {
<TextWithIcon>
<InfoCircle /> 使用須知:
</TextWithIcon>
<ul>{this.renderList(announcementData.description)}</ul>
<ul>{this.renderList(ANNOUNCEMENT_DATA.description)}</ul>
</Col>
<Col lg={6} md={6}>
<TextWithIcon>
<ArrowUpCircle /> 更新內容:
</TextWithIcon>
<ul>{this.renderList(announcementData.updates)}</ul>
<ul>{this.renderList(ANNOUNCEMENT_DATA.updates)}</ul>
</Col>
<Col lg={6} md={6}>
<TextWithIcon>
<Gear /> 主要功能:
</TextWithIcon>
<ul>{this.renderList(announcementData.features)}</ul>
<ul>{this.renderList(ANNOUNCEMENT_DATA.features)}</ul>
</Col>
<Col lg={6} md={6}>
<TextWithIcon>
<ExclamationCircle /> 已知問題:
</TextWithIcon>
<ul>{this.renderList(announcementData.knownIssues)}</ul>
<ul>{this.renderList(ANNOUNCEMENT_DATA.knownIssues)}</ul>
</Col>
<Col lg={6} md={6}>
<TextWithIcon>
@@ -99,7 +105,7 @@ class Announcement extends Component {
<ul>
<li>
<a
href={announcementData.githubUrl}
href={ANNOUNCEMENT_DATA.githubUrl}
target='_blank'
rel='noreferrer'
>
@@ -115,8 +121,8 @@ class Announcement extends Component {
<ul>
<li>
總負責人:
<a href={`mailto:${announcementData.contactEmail}`}>
{announcementData.contactEmail}
<a href={`mailto:${ANNOUNCEMENT_DATA.contactEmail}`}>
{ANNOUNCEMENT_DATA.contactEmail}
</a>
</li>
</ul>
@@ -125,10 +131,10 @@ class Announcement extends Component {
</Card.Body>
<Card.Footer className='text-center text-muted fst-italic fw-light'>
<Card.Text className='text-center'>
{announcementData.termsofuse[0]}
{ANNOUNCEMENT_DATA.termsofuse[0]}
</Card.Text>
<Card.Text className='text-center'>
{announcementData.copyright.map((text) => {
{ANNOUNCEMENT_DATA.copyright.map((text) => {
return (
<small key={text}>
{text}
Original file line number Diff line number Diff line change
@@ -2,11 +2,11 @@ import { Component } from 'react';
import { Card, Col } from 'react-bootstrap';
import styled from 'styled-components';

import type { Course, TimeSlot } from '@/types';
import { COURSE_DETECTIVE_ELEMENTS } from '../../config';
import CoursesList from './AllCourse/CoursesList';
import ListInformation from './CourseDetective/ListInformation';

import { courseDetectiveElements } from '../../config';

const StyledCardBody = styled(Card.Body)`
height: 100%;
padding: 0;
@@ -16,25 +16,49 @@ const StyledCardBody = styled(Card.Body)`
}
`;

class CourseDetective extends Component {
interface CourseDetectiveProps {
courses: Course[];
selectedCourses: Set<Course>;
isCollapsed: boolean;
detectTimeConflict: (course: Course, selectedCourses: Set<Course>) => boolean;
calculateTotalCreditsAndHours: (selectedCourses: Set<Course>) => {
totalCredits: number;
totalHours: number;
};
searchTimeSlot: TimeSlot[];
hoveredCourseId: string | null;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onCourseHover: (courseId: string | null) => void;
}

interface CourseDetectiveState {
orderElements: typeof COURSE_DETECTIVE_ELEMENTS;
filteredCourses: Course[];
}

class CourseDetective extends Component<
CourseDetectiveProps,
CourseDetectiveState
> {
state = {
orderElements: courseDetectiveElements,
orderElements: COURSE_DETECTIVE_ELEMENTS,
filteredCourses: [],
};

filterConditions = {
'liberal-arts': (course) => course.Department.startsWith('博雅'),
'sports-fitness': (course) =>
'liberal-arts': (course: Course) => course.Department.startsWith('博雅'),
'sports-fitness': (course: Course) =>
course.Name === '運動與健康:體適能' ||
course.Name === '運動與健康:初級游泳',
'sports-other': (course) =>
'sports-other': (course: Course) =>
course.Name.startsWith('運動與健康:') &&
course.Name !== '運動與健康:體適能' &&
course.Name !== '運動與健康:初級游泳',
'cross-department': (course) => course.Department.startsWith('跨院'),
'chinese-critical-thinking': (course) =>
'cross-department': (course: Course) =>
course.Department.startsWith('跨院'),
'chinese-critical-thinking': (course: Course) =>
course.Name.startsWith('中文思辨與表達'),
'random-courses': (course) =>
'random-courses': (course: Course) =>
!course.Department.includes('碩') &&
!course.Department.includes('博') &&
!course.Department.startsWith('博雅') &&
@@ -45,20 +69,20 @@ class CourseDetective extends Component {
course.Name !== '英文中級' &&
course.Name !== '英文中高級' &&
course.Name !== '英文高級',
'random-graduate-courses': (course) =>
'random-graduate-courses': (course: Course) =>
(course.Department.includes('碩') || course.Department.includes('博')) &&
!course.Department.startsWith('博雅'),
'english-beginner': (course) => course.Name === '英文初級',
'english-intermediate': (course) => course.Name === '英文中級',
'english-advanced-mid': (course) => course.Name === '英文中高級',
'english-advanced': (course) => course.Name === '英文高級',
'english-beginner': (course: Course) => course.Name === '英文初級',
'english-intermediate': (course: Course) => course.Name === '英文中級',
'english-advanced-mid': (course: Course) => course.Name === '英文中高級',
'english-advanced': (course: Course) => course.Name === '英文高級',
};

componentDidMount() {
const savedOrderElements = localStorage.getItem('orderElements')
? JSON.parse(localStorage.getItem('orderElements'))
? JSON.parse(localStorage.getItem('orderElements') || '[]')
: null;
if (savedOrderElements) {
if (savedOrderElements && savedOrderElements.length !== 0) {
this.setState({ orderElements: savedOrderElements }, () => {
this.reorderAndFilterCourses();
});
@@ -69,7 +93,7 @@ class CourseDetective extends Component {
}
}

componentDidUpdate(prevProps, prevState, snapshot) {
componentDidUpdate(prevProps: CourseDetectiveProps) {
if (
prevProps.courses !== this.props.courses ||
prevProps.searchTimeSlot !== this.props.searchTimeSlot
@@ -82,7 +106,7 @@ class CourseDetective extends Component {
* 設定排序元素序位
* @param newOrderElements {Array} 新的排序元素
*/
setOrderElements = (newOrderElements) =>
setOrderElements = (newOrderElements: typeof COURSE_DETECTIVE_ELEMENTS) => {
this.setState(
{
orderElements: newOrderElements,
@@ -95,13 +119,14 @@ class CourseDetective extends Component {
this.reorderAndFilterCourses();
},
);
};

/**
* 洗牌陣列
* @param array {Array} 陣列
* @returns {Array} 洗牌後的陣列
* @param array {Course[]} 陣列
* @returns {Course[]} 洗牌後的陣列
*/
shuffleArray = (array) => {
shuffleArray = (array: Course[]): Course[] => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]; // 交换元素
@@ -127,13 +152,19 @@ class CourseDetective extends Component {
: [...courses];

// Step 2: 基於排序元素篩選課程
let orderedAndFilteredCourses = [];
let orderedAndFilteredCourses: Course[] = [];
let addedCourseIds = new Set();

orderElements
.filter((element) => element.enabled)
.forEach((element) => {
const filterCondition = this.filterConditions[element.id];
const id = element.id;
if (!(id in this.filterConditions)) {
return;
}

const filterCondition =
this.filterConditions[id as keyof typeof this.filterConditions];
if (!filterCondition) return;

let matchingCourses = timeSlotFilteredCourses.filter(
@@ -161,7 +192,7 @@ class CourseDetective extends Component {
* 切換排序元素啟用狀態
* @param id {string} 元素 id
*/
toggleOrderElementEnable = (id) => {
toggleOrderElementEnable = (id: string) => {
this.setState(
(prevState) => ({
orderElements: prevState.orderElements.map((element) => {
@@ -225,6 +256,7 @@ class CourseDetective extends Component {
hoveredCourseId={hoveredCourseId}
onCourseSelect={onCourseSelect}
onCourseHover={onCourseHover}
displaySelectedOnly={false}
/>
</StyledCardBody>
</Card>
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import {
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
@@ -16,18 +17,20 @@ import {
import { Button, Card, InputGroup, Offcanvas } from 'react-bootstrap';
import { SortNumericUp } from 'react-bootstrap-icons';
import styled from 'styled-components';

import type { Course } from '@/types';
import { COURSE_DETECTIVE_ELEMENTS, WEBSITE_COLOR } from '@/config';
import { SortableItem } from './ListInformation/SortableItem';
import { websiteColor } from '../../../config';

const StyledButton = styled(Button)`
background-color: ${websiteColor.mainColor};
border-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
display: flex;
align-items: center;
&:hover {
background-color: ${websiteColor.mainDarkerColor};
border-color: ${websiteColor.mainDarkerColor};
background-color: ${WEBSITE_COLOR.mainDarkerColor};
border-color: ${WEBSITE_COLOR.mainDarkerColor};
}
`;

@@ -43,13 +46,24 @@ const StyledTextRow = styled.div`
margin-bottom: 1rem;
`;

function ListInformation({
interface ListInformationProps {
elements: { id: string; content: string; enabled: boolean }[];
setElements: (newOrderElements: typeof COURSE_DETECTIVE_ELEMENTS) => void;
calculateTotalCreditsAndHours: (selectedCourses: Set<Course>) => {
totalCredits: number;
totalHours: number;
};
selectedCourses: Set<Course>;
toggleElementEnable: (id: string) => void;
}

const ListInformation: React.FC<ListInformationProps> = ({
elements,
setElements,
calculateTotalCreditsAndHours,
selectedCourses,
toggleElementEnable,
}) {
}) => {
// const [displayConflictCourses, setDisplayConflictCourses] = useState(false);
const [show, setShow] = useState(false);
const sensors = useSensors(
@@ -70,7 +84,7 @@ function ListInformation({
// setDisplayConflictCourses(prevState => !prevState);
// };

const handleDragEnd = (event) => {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;

if (!over || active.id === over.id) {
@@ -152,6 +166,6 @@ function ListInformation({
</Offcanvas>
</Card.Body>
);
}
};

export default ListInformation;
Original file line number Diff line number Diff line change
@@ -1,38 +1,53 @@
import React from 'react';
import { Form } from 'react-bootstrap';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import styled from 'styled-components';
import { websiteColor } from '../../../../config';
import React from 'react';

import { WEBSITE_COLOR } from '@/config';

const StyledFormCheckWrapper = styled.div`
.form-check-input:checked {
background-color: ${websiteColor.mainColor};
border-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
}
.form-check-input:focus {
box-shadow: 0 0 0 0.25rem ${websiteColor.boxShadowColor};
box-shadow: 0 0 0 0.25rem ${WEBSITE_COLOR.boxShadowColor};
}
.form-check-input:disabled ~ .form-check-label {
color: ${websiteColor.mainLighterColor};
color: ${WEBSITE_COLOR.mainLighterColor};
}
.form-check-input:checked ~ .form-check-label::before {
background-color: ${websiteColor.mainColor};
background-color: ${WEBSITE_COLOR.mainColor};
}
.form-switch .form-check-input:checked ~ .form-check-label::before {
border-color: ${websiteColor.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
}
.form-check-label::before {
border-color: ${websiteColor.mainColor};
border-color: ${WEBSITE_COLOR.mainColor};
}
`;

export function SortableItem({ id, index, content, enableDrag, toggleEnable }) {
interface SortableItemProps {
id: string;
index: number;
content: string;
enableDrag: boolean;
toggleEnable: (id: string) => void;
}

export const SortableItem: React.FC<SortableItemProps> = ({
id,
index,
content,
enableDrag,
toggleEnable,
}) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: id,
@@ -53,7 +68,7 @@ export function SortableItem({ id, index, content, enableDrag, toggleEnable }) {
touchAction: 'none',
};

const handleToggle = (event) => {
const handleToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
toggleEnable(id);
};
@@ -73,4 +88,4 @@ export function SortableItem({ id, index, content, enableDrag, toggleEnable }) {
</StyledFormCheckWrapper>
</div>
);
}
};
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import { Component } from 'react';
import { Card, Col } from 'react-bootstrap';
import styled from 'styled-components';

import type { Course } from '@/types';
import { DEFAULT_FILTER_OPTIONS } from '@/config.tsx';
import ListInformation from './RequiredCourse/ListInformation';
import CoursesList from './AllCourse/CoursesList';

@@ -14,7 +16,32 @@ const StyledCardBody = styled(Card.Body)`
}
`;

class RequiredCourse extends Component {
interface RequiredCourseProps {
isCollapsed: boolean;
courses: Course[];
selectedCourses: Set<Course>;
hoveredCourseId: string | null;
onCourseSelect: (course: Course, isSelected: boolean) => void;
onCourseHover: (courseId: string | null) => void;
detectTimeConflict: (course: Course, selectedCourses: Set<Course>) => boolean;
calculateTotalCreditsAndHours: (courses: Set<Course>) => {
totalCredits: number;
totalHours: number;
};
filterOptions: typeof DEFAULT_FILTER_OPTIONS;
}

interface RequiredCourseState {
requiredCourseFilters: {
[key in Partial<keyof Course>]?: string;
};
filteredCourses: Course[];
}

class RequiredCourse extends Component<
RequiredCourseProps,
RequiredCourseState
> {
state = {
requiredCourseFilters: {},
filteredCourses: this.props.courses,
@@ -30,7 +57,10 @@ class RequiredCourse extends Component {
}
}

componentDidUpdate(prevProps, prevState, snapshot) {
componentDidUpdate(
_prevProps: RequiredCourseProps,
prevState: RequiredCourseState,
) {
if (this.state.requiredCourseFilters !== prevState.requiredCourseFilters) {
this.applyFilters();
localStorage.setItem(
@@ -44,7 +74,9 @@ class RequiredCourse extends Component {
* 處理進階篩選事件
* @param requiredFilters {object} 必修篩選器
*/
handleRequiredCourseFilterChange = (requiredFilters) => {
handleRequiredCourseFilterChange = (requiredFilters: {
[key in Partial<keyof Course>]?: string;
}) => {
this.setState((prevState) => ({
requiredCourseFilters: {
...prevState.requiredCourseFilters,
@@ -62,7 +94,12 @@ class RequiredCourse extends Component {

const filteredCourses = courses.filter((course) => {
// 逐一檢查篩選條件,返回符合所有條件的課程
return Object.entries(requiredCourseFilters).every(([key, value]) => {
return (
Object.entries(requiredCourseFilters) as [
Partial<keyof Course>,
string,
][]
).every(([key, value]) => {
if (!value) return true; // 如果篩選條件為空,則不應用該條件
return course[key] === value && course['CompulsoryElective'] === '必';
});
@@ -114,6 +151,7 @@ class RequiredCourse extends Component {
hoveredCourseId={hoveredCourseId}
onCourseSelect={onCourseSelect}
onCourseHover={onCourseHover}
displaySelectedOnly={false}
/>
</StyledCardBody>
</Card>
Loading

0 comments on commit e8bd897

Please sign in to comment.