├── .husky
├── .gitignore
└── pre-commit
├── .prettierignore
├── public
├── logo.png
├── favicon.ico
├── logo_256.png
├── sitemap.txt
├── manifest.json
├── index.html
└── lib
│ └── canvas2png.js
├── src
├── ExchangeSetting.css
├── CourseReviewFilter.css
├── config.js
├── App.css
├── CourseReviewList.css
├── Home.jsx
├── Exchange.jsx
├── CourseReview.jsx
├── Loading.jsx
├── ExchangeList.jsx
├── CourseReviewFilter.jsx
├── App.jsx
├── index.js
├── Share.jsx
├── ExchangeItem.jsx
├── CourseGrid.jsx
├── CourseTable.jsx
├── registerServiceWorker.js
├── ToolBar.jsx
├── ExchangeListContainer.jsx
├── CourseReviewList.jsx
├── CourseList.jsx
├── Navigation.jsx
├── ExchangeSetting.jsx
├── CourseReviewContainer.jsx
└── CourseTableContainer.jsx
├── .prettierrc.json
├── .gitignore
├── .env.production
├── .env.development
├── LICENSE
├── package.json
├── README.md
└── CODE_OF_CONDUCT.md
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/x3388638/KeBiau/HEAD/public/logo.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/x3388638/KeBiau/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo_256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/x3388638/KeBiau/HEAD/public/logo_256.png
--------------------------------------------------------------------------------
/src/ExchangeSetting.css:
--------------------------------------------------------------------------------
1 | .react-tagsinput-input {
2 | margin-bottom: 0;
3 | padding: 0;
4 | }
5 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "endOfLine": "lf",
5 | "trailingComma": "none"
6 | }
7 |
--------------------------------------------------------------------------------
/src/CourseReviewFilter.css:
--------------------------------------------------------------------------------
1 | .react-tagsinput-tag {
2 | padding: 1px 5px !important;
3 | margin-bottom: 0 !important;
4 | }
5 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | FB: {
3 | AppID: process.env.REACT_APP_FB_APP_ID
4 | }
5 | }
6 |
7 | export default CONFIG
8 |
--------------------------------------------------------------------------------
/public/sitemap.txt:
--------------------------------------------------------------------------------
1 | https://x3388638.github.io/KeBiau/
2 | https://x3388638.github.io/KeBiau/#/
3 | https://x3388638.github.io/KeBiau/#/exchange
4 | https://x3388638.github.io/KeBiau/#/review
5 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | background: #f7f7f7;
3 | font-family: Arial, '文泉驛正黑', 'WenQuanYi Zen Hei', '儷黑 Pro', 'LiHei Pro',
4 | '微軟正黑體', 'Microsoft JhengHei', '標楷體', DFKai-SB, sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/src/CourseReviewList.css:
--------------------------------------------------------------------------------
1 | @media (min-width: 768px) {
2 | .bricklayer-column-sizer {
3 | width: 50%;
4 | }
5 | }
6 |
7 | @media (max-width: 767px) {
8 | .bricklayer-column-sizer {
9 | width: 100%;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/Home.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import App from './App.jsx'
4 | import CourseTableContainer from './CourseTableContainer.jsx'
5 |
6 | export default class Home extends React.Component {
7 | render() {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Exchange.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import App from './App.jsx'
4 | import ExchangeListContainer from './ExchangeListContainer.jsx'
5 |
6 | export default class Exchange extends React.Component {
7 | render() {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/CourseReview.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import App from './App.jsx'
4 | import CourseReviewContainer from './CourseReviewContainer.jsx'
5 |
6 | export default class CourseReview extends React.Component {
7 | render() {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_BASE_URL=https://2yc.tw/KeBiau
2 | REACT_APP_FB_APP_ID=1218737564897109
3 | REACT_APP_FIREBASE_API_KEY=AIzaSyDsof_DycXOzeXGVwix3nTgHGZwtseEzJ0
4 | REACT_APP_FIREBASE_AUTH_DOMAIN=kebiau-7864f.firebaseapp.com
5 | REACT_APP_FIREBASE_DATABASE_URL=https://kebiau-7864f.firebaseio.com
6 | REACT_APP_FIREBASE_PROJECT_ID=kebiau-7864f
7 | REACT_APP_FIREBASE_STORAGE_BUCKET=kebiau-7864f.appspot.com
8 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID=223133259969
9 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | PORT=9487
2 | REACT_APP_BASE_URL=http://127.0.0.1:9487/KeBiau
3 | REACT_APP_FB_APP_ID=231164050744735
4 | REACT_APP_FIREBASE_API_KEY=AIzaSyAy_bOF1JSFEWat1bIIvJWk47BntHjMwLU
5 | REACT_APP_FIREBASE_AUTH_DOMAIN=kebiau-test.firebaseapp.com
6 | REACT_APP_FIREBASE_DATABASE_URL=https://kebiau-test.firebaseio.com
7 | REACT_APP_FIREBASE_PROJECT_ID=kebiau-test
8 | REACT_APP_FIREBASE_STORAGE_BUCKET=kebiau-test.appspot.com
9 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID=970493852269
10 |
--------------------------------------------------------------------------------
/src/Loading.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BarLoader } from 'react-spinners'
3 | import styled from 'styled-components'
4 |
5 | const LoadingWrapper = styled.div`
6 | display: flex;
7 | justify-content: center;
8 | margin-top: ${(props) => `${props.marginTop || 20}px`};
9 | `
10 |
11 | export default class Loading extends React.PureComponent {
12 | render() {
13 | return (
14 |
15 |
16 |
17 | )
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/ExchangeList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container, Row, Col } from 'reactstrap'
3 |
4 | import ExchangeItem from './ExchangeItem'
5 |
6 | export default class ExchangeList extends React.Component {
7 | render() {
8 | const containerStyle = {
9 | marginTop: '190px',
10 | transition: 'all .5s'
11 | }
12 |
13 | return (
14 |
15 | {this.props.exchangeList.map((item, i) => {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | )
23 | })}
24 |
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 YY Chang
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.
22 |
--------------------------------------------------------------------------------
/src/CourseReviewFilter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import TagsInput from 'react-tagsinput'
3 | import { ButtonGroup, Button } from 'reactstrap'
4 |
5 | import './CourseReviewFilter.css'
6 |
7 | export default class CourseReviewFilter extends React.Component {
8 | render() {
9 | return (
10 |
11 |
搜尋課程
12 |
19 |
20 |
21 | this.props.onChangeSortType(1)}
24 | >
25 | 時間優先
26 | {' '}
27 | this.props.onChangeSortType(2)}
30 | >
31 | 評價優先
32 |
33 |
34 |
35 |
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container } from 'reactstrap'
3 | import PropTypes from 'prop-types'
4 | import styled from 'styled-components'
5 |
6 | import Navigation from './Navigation.jsx'
7 |
8 | import './App.css'
9 |
10 | const RelativeContainer = styled(Container)`
11 | position: relative;
12 | `
13 |
14 | export default class App extends React.Component {
15 | constructor(props) {
16 | super(props)
17 | this.state = {
18 | user: null
19 | }
20 |
21 | this.db = window.firebase.database()
22 | window.firebase.auth().onAuthStateChanged((user) => {
23 | if (user) {
24 | this.setState({
25 | user: Object.assign({}, user.providerData[0], {
26 | uuid: user.uid
27 | })
28 | })
29 | } else {
30 | this.setState({
31 | user: {
32 | uid: null
33 | }
34 | })
35 | }
36 | })
37 | }
38 |
39 | getChildContext() {
40 | return {
41 | user: this.state.user
42 | }
43 | }
44 |
45 | render() {
46 | return (
47 |
48 |
49 | {this.props.children}
50 |
51 | )
52 | }
53 | }
54 |
55 | App.childContextTypes = {
56 | user: PropTypes.object
57 | }
58 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { HashRouter as Router, Route, Redirect, Switch } from 'react-router-dom'
4 | import 'bootstrap/dist/css/bootstrap.min.css'
5 |
6 | import Home from './Home.jsx'
7 | // import Exchange from './Exchange.jsx'
8 | import Share from './Share.jsx'
9 | // import CourseReview from './CourseReview.jsx'
10 |
11 | import registerServiceWorker from './registerServiceWorker'
12 |
13 | // Initialize Firebase
14 | const config = {
15 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
16 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
17 | databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL,
18 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
19 | storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
20 | messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID
21 | }
22 | window.firebase.initializeApp(config)
23 |
24 | ReactDOM.render(
25 |
26 |
27 |
28 | {/* */}
29 |
30 | {/* */}
31 |
32 |
33 | ,
34 | document.getElementById('app')
35 | )
36 | registerServiceWorker()
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ke-biau",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "https://x3388638.github.io/KeBiau",
6 | "dependencies": {
7 | "bootstrap": "^4.5.0",
8 | "lodash.clonedeep": "^4.5.0",
9 | "moment": "^2.27.0",
10 | "prop-types": "^15.7.2",
11 | "react": "^16.4.1",
12 | "react-addons-css-transition-group": "^15.6.0",
13 | "react-addons-transition-group": "^15.6.0",
14 | "react-dom": "^16.13.1",
15 | "react-router-dom": "^4.2.2",
16 | "react-scripts": "^3.4.1",
17 | "react-spinners": "^0.10.4",
18 | "react-tagsinput": "^3.17.0",
19 | "reactstrap": "^6.3.0",
20 | "styled-components": "^3.3.3"
21 | },
22 | "scripts": {
23 | "start": "react-scripts start",
24 | "build": "react-scripts build",
25 | "test": "react-scripts test --env=jsdom",
26 | "eject": "react-scripts eject",
27 | "deploy": "gh-pages -d build",
28 | "prettier:check": "prettier --check './**/*.{js,json,jsx,html,css}'",
29 | "prettier:write": "prettier --write './**/*.{js,json,jsx,html,css}'"
30 | },
31 | "devDependencies": {
32 | "gh-pages": "^1.0.0",
33 | "husky": "^5.0.9",
34 | "lint-staged": "^10.5.4",
35 | "prettier": "^2.2.1"
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | },
49 | "lint-staged": {
50 | "*.{js,json,jsx,html,css}": "npm run prettier:check"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #  自己的課表自己排 2.0
2 | [暨大](http://www.gazette.ncnu.edu.tw/)專屬排課表、換課、課程評價平台
3 | Website: https://2yc.tw/KeBiau/#/
4 |
5 | 曾經,你是否也跟我一樣,
6 | 選課前,浪費了一堆時間、一堆紙與墨水在思考要修什麼課,
7 | 修這個好,還是修那個好?會不會衝堂?
8 | 這樣的課程組合時間會不會太緊繃?
9 | 欠缺完善的規劃,導致搶課失利,修了沒興趣的課,上課度沽,期末哭哭。
10 |
11 | 選課後,想去教務系統看個課表,卻發現難以閱讀,
12 | 還要再一次的花費不少時間修改、重做一份精簡又不失內涵且繽紛的課表。
13 |
14 | 又或是,想換課但 FB 社團雜亂無章,
15 | 過濾困難又不好意思主動發文。
16 |
17 | 然而更重要的是,希望能選到與自己興趣相符的課程,
18 | 但課綱往往流於形式,又缺乏客觀的課程資訊來源,只能道聽途說。
19 |
20 | 如果,你也為此苦惱,
21 | 別再猶豫,這正是你要的——自己的課表自己排 2.0。
22 |
23 | ## 排課表
24 | #### 輕鬆、快速、直覺、簡易產生個人化的精緻課表。
25 | 
26 |
27 | ### 重點功能
28 | - 以 FB/Google 登入做帳號管理
29 | - 原汁原味的教務系統課程資訊
30 | - 多元欄位篩選課程
31 | - 一鍵加入課程
32 | - 自動判斷衝堂
33 | - 一鍵過濾衝堂
34 | - 一鍵分享
35 | - 一鍵匯出 (xls/png)
36 | - 自由配色
37 | - 一鍵清除標記顏色
38 | - 一鍵清除課表
39 | - 搜尋課程
40 | - 自由編輯課程內容
41 | - 自訂時段,客製化加入時間區段與內容
42 |
43 | ## 換課 (已停用)
44 | #### 換課資訊不再散落四處,隨機排序人人平等,輕鬆過濾,換課萬無一失。
45 | 
46 |
47 | ## 課程評價 (已停用)
48 | #### 課程資訊透明化,讓選課不再猶豫,修課不再後悔。
49 | 
50 |
51 | - 具名評論,流言不再
52 | - 一鍵排序、一鍵過濾
53 | - 評論回饋,客觀加倍
54 |
55 | ## 技術使用
56 | client: [create-react-app](https://github.com/facebookincubator/create-react-app) for view、[react-router](https://github.com/ReactTraining/react-router) for route、[reactstrap](https://github.com/reactstrap/reactstrap) (with Bootstrap 4.1) & [bricklayer](https://github.com/ademilter/bricklayer) & [styled-components](https://www.styled-components.com/) for layout
57 | server: none
58 | database: [firebase](https://firebase.google.com/)
59 |
--------------------------------------------------------------------------------
/src/Share.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { Row, Col, Alert } from 'reactstrap'
4 | import styled from 'styled-components'
5 |
6 | import App from './App.jsx'
7 | import CourseTable from './CourseTable.jsx'
8 |
9 | const ShareContainer = styled.div`
10 | background: #fff;
11 | padding: 20px 5px;
12 | box-shadow: 0 0 10px 0 #080808;
13 | margin-top: 55px;
14 | margin-bottom: 55px;
15 | `
16 |
17 | export default class Share extends React.Component {
18 | constructor(props) {
19 | super(props)
20 |
21 | this.state = {
22 | customTable: 'Loading...'
23 | }
24 |
25 | this.db = window.firebase.database()
26 | }
27 |
28 | componentDidMount() {
29 | const routeHash = this.context.router.route.match.params.hash
30 | const uuid = routeHash.substring(0, routeHash.length - 4)
31 | const hash = routeHash.substring(routeHash.length - 4, routeHash.length)
32 | this.db
33 | .ref(`sharedTable/${uuid}`)
34 | .once('value')
35 | .then((snapshot) => {
36 | const data = snapshot.val()
37 | if (data === null) {
38 | this.setState({
39 | customTable: '連結無效'
40 | })
41 | return
42 | }
43 |
44 | const tableData = JSON.parse(data)[hash]
45 | if (!tableData) {
46 | this.setState({
47 | customTable: '連結無效'
48 | })
49 | return
50 | }
51 |
52 | this.setState({
53 | customTable: tableData
54 | })
55 | })
56 | }
57 |
58 | render() {
59 | return (
60 |
61 |
62 |
63 |
64 | {typeof this.state.customTable === 'string' && (
65 |
66 | {this.state.customTable}
67 |
68 | )}
69 |
70 | {typeof this.state.customTable !== 'string' && (
71 |
72 | )}
73 |
74 |
75 |
76 |
77 | )
78 | }
79 | }
80 |
81 | Share.contextTypes = {
82 | router: PropTypes.object
83 | }
84 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at z3388638@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/src/ExchangeItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Row, Col, Card, Badge } from 'reactstrap'
3 | import moment from 'moment'
4 | import styled from 'styled-components'
5 |
6 | const Container = styled(Card)`
7 | padding: 5px 5px 5px 30px;
8 | `
9 |
10 | const Profile = styled.img`
11 | border-radius: 50px;
12 | `
13 |
14 | const Username = styled.a`
15 | color: #365899;
16 | font-weight: bold;
17 | `
18 |
19 | const Timestamp = styled.td`
20 | font-size: 13px;
21 | color: #989898;
22 | `
23 |
24 | const Desc = styled.div`
25 | font-size: 14px;
26 | color: #666;
27 | border-left: 3px solid #bbb;
28 | `
29 |
30 | const CourseTitle = styled.h6`
31 | border-bottom: 1px solid #787878;
32 | `
33 |
34 | const Course = styled.div`
35 | font-size: 14px;
36 | `
37 |
38 | export default class ExchangeItem extends React.PureComponent {
39 | render() {
40 | const { fbLink, fbPicture, name, time, desc, have, want } = this.props.item
41 | return (
42 |
43 |
44 |
45 |
89 |
90 | {desc.split('\n').map((val, i) => {
91 | return {val}
92 | })}
93 |
94 |
95 |
96 | 想要的課
97 | {want.map((wnatCourse, i) => {
98 | return (
99 |
100 |
101 | {i + 1}
102 | {' '}
103 | {wnatCourse}
104 |
105 | )
106 | })}
107 |
108 |
109 | 不需要的課
110 | {have.map((haveCourse, i) => {
111 | return (
112 |
113 |
114 | {i + 1}
115 | {' '}
116 | {haveCourse}
117 |
118 | )
119 | })}
120 |
121 |
122 |
123 | )
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/CourseGrid.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const EditBtnContainer = styled.span`
5 | top: -8px;
6 | position: relative;
7 | `
8 |
9 | const DelBtn = styled.span`
10 | display: none;
11 | cursor: pointer;
12 | color: #c9302c !important;
13 | font-size: 20px;
14 | `
15 |
16 | const EditBtn = styled.i`
17 | display: none;
18 | cursor: pointer;
19 | font-size: 14px;
20 | &:hover {
21 | color: #0275d8;
22 | }
23 | `
24 |
25 | const Grid = styled.td`
26 | vertical-align: middle !important;
27 | background: ${(props) => `${props.bg} !important` || 'none'};
28 | color: ${_getTextColor};
29 | &:hover {
30 | background: #fafafa;
31 | ${DelBtn} {
32 | display: initial;
33 | }
34 |
35 | ${EditBtn} {
36 | display: initial;
37 | }
38 | }
39 | `
40 |
41 | function _getTextColor(props) {
42 | if (props.bg) {
43 | const { r, g, b } = _hexToRgb(props.bg)
44 | const bri = Math.sqrt(r * r * 0.241 + g * g * 0.691 + b * b * 0.068)
45 | if (bri < 125) {
46 | return '#fff'
47 | }
48 |
49 | return ''
50 | }
51 |
52 | return ''
53 | }
54 |
55 | function _hexToRgb(hex) {
56 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
57 | return result
58 | ? {
59 | r: parseInt(result[1], 16),
60 | g: parseInt(result[2], 16),
61 | b: parseInt(result[3], 16)
62 | }
63 | : null
64 | }
65 |
66 | export default class CourseGrid extends React.Component {
67 | constructor(props) {
68 | super(props)
69 | this.state = {
70 | mouseEnter: false
71 | }
72 |
73 | this.handleMouseEnter = this.handleMouseEnter.bind(this)
74 | this.handleMouseLeave = this.handleMouseLeave.bind(this)
75 | this.handleDel = this.handleDel.bind(this)
76 | this.handleEdit = this.handleEdit.bind(this)
77 | }
78 |
79 | handleMouseEnter() {
80 | this.setState({
81 | mouseEnter: true
82 | })
83 | }
84 |
85 | handleMouseLeave() {
86 | this.setState({
87 | mouseEnter: false
88 | })
89 | }
90 |
91 | handleDel(e) {
92 | if (this.props.shared) return
93 | const time = e.target.parentNode.parentNode.parentNode.getAttribute(
94 | 'data-time'
95 | )
96 | const rowspan = e.target.parentNode.parentNode.getAttribute('rowspan')
97 | this.props.onDelCourse(time, rowspan, this.props.dayOfWeek)
98 | }
99 |
100 | handleEdit(e) {
101 | if (this.props.shared) return
102 | this.props.onEditCourse({
103 | time: e.target.parentNode.parentNode.parentNode.parentNode.getAttribute(
104 | 'data-time'
105 | ),
106 | dayOfWeek: this.props.dayOfWeek,
107 | title: this.props.title,
108 | desc: this.props.desc,
109 | bg: this.props.bg
110 | })
111 | }
112 |
113 | hexToRgb(hex) {
114 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
115 | return result
116 | ? {
117 | r: parseInt(result[1], 16),
118 | g: parseInt(result[2], 16),
119 | b: parseInt(result[3], 16)
120 | }
121 | : null
122 | }
123 |
124 | render() {
125 | return (
126 |
132 | {!this.props.shared && this.props.title && this.state.mouseEnter && (
133 |
134 |
135 | ×
136 |
137 |
138 |
139 |
144 |
145 |
146 | )}
147 |
148 | {this.props.title && this.props.title}
149 |
150 | {this.props.desc && this.props.desc}
151 |
152 | )
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/CourseTable.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Table } from 'reactstrap'
3 | import styled from 'styled-components'
4 |
5 | import CourseGrid from './CourseGrid'
6 |
7 | const DelBtn = styled.i`
8 | display: none;
9 | &:hover {
10 | cursor: pointer;
11 | color: #c9302c !important;
12 | }
13 | `
14 |
15 | const WeekendTitle = styled.th`
16 | width: auto;
17 | &:hover {
18 | ${DelBtn} {
19 | display: initial;
20 | }
21 | }
22 | `
23 |
24 | export default class CourseTable extends React.Component {
25 | componentDidUpdate() {
26 | const classArr = document
27 | .getElementById('CustomTable')
28 | .className.replace('table-bordered', '')
29 | .trim()
30 | .split(' ')
31 | document.getElementById('CustomTable').className = classArr.join(' ')
32 | setTimeout(() => {
33 | document.getElementById('CustomTable').className += ' table-bordered'
34 | }, 1)
35 | }
36 |
37 | render() {
38 | const timeNo = [
39 | 'a/08',
40 | 'b/09',
41 | 'c/10',
42 | 'd/11',
43 | 'z/12',
44 | 'e/13',
45 | 'f/14',
46 | 'g/15',
47 | 'h/16',
48 | 'i/17',
49 | 'j/18',
50 | 'k/19',
51 | 'l/20',
52 | 'm/21'
53 | ]
54 | return (
55 |
56 |
63 |
64 |
65 |
66 | 星期一
67 | 星期二
68 | 星期三
69 | 星期四
70 | 星期五
71 | {this.props.tableData.sat && (
72 |
73 | 星期六{' '}
74 | {
78 | !this.props.shared && this.props.onDelSatOrSun('sat')
79 | }}
80 | >
81 |
82 | )}
83 |
84 | {this.props.tableData.sun && (
85 |
86 | 星期日{' '}
87 | {
91 | !this.props.shared && this.props.onDelSatOrSun('sun')
92 | }}
93 | >
94 |
95 | )}
96 |
97 |
98 |
99 | {timeNo.map((t, i) => {
100 | return (
101 |
102 | {t}
103 | {Object.keys(this.props.tableData.course[t]).map((key, i) => {
104 | const courseData = this.props.tableData.course[t][key]
105 | if (courseData === null) {
106 | return null
107 | }
108 |
109 | if (!this.props.tableData.sat && key === '5') {
110 | return null
111 | }
112 |
113 | if (!this.props.tableData.sun && key === '6') {
114 | return null
115 | }
116 |
117 | return (
118 |
126 | )
127 | })}
128 |
129 | )
130 | })}
131 |
132 |
133 |
134 | )
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | )
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl)
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl)
41 | }
42 | })
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then((registration) => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.')
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.')
65 | }
66 | }
67 | }
68 | }
69 | })
70 | .catch((error) => {
71 | console.error('Error during service worker registration:', error)
72 | })
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then((response) => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then((registration) => {
86 | registration.unregister().then(() => {
87 | window.location.reload()
88 | })
89 | })
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl)
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | )
99 | })
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then((registration) => {
105 | registration.unregister()
106 | })
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/ToolBar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Button,
4 | ButtonDropdown,
5 | DropdownToggle,
6 | DropdownMenu,
7 | DropdownItem
8 | } from 'reactstrap'
9 |
10 | export default class ToolBar extends React.Component {
11 | constructor(props) {
12 | super(props)
13 | this.state = {
14 | exportDropdownOpen: false,
15 | clearDropdownOpen: false
16 | }
17 |
18 | this.toggleExportDropdown = this.toggleExportDropdown.bind(this)
19 | this.toggleClearDropdown = this.toggleClearDropdown.bind(this)
20 | }
21 |
22 | toggleExportDropdown() {
23 | this.setState((prevState) => ({
24 | exportDropdownOpen: !prevState.exportDropdownOpen
25 | }))
26 | }
27 |
28 | toggleClearDropdown() {
29 | this.setState((prevState) => ({
30 | clearDropdownOpen: !prevState.clearDropdownOpen
31 | }))
32 | }
33 |
34 | handleExportExcel(e) {
35 | document.querySelectorAll('.uniBR').forEach((val) => {
36 | val.setAttribute('style', 'mso-data-placement:same-cell')
37 | })
38 | const html =
39 | ' ' +
40 | document.getElementById('CustomTable').parentNode.innerHTML +
41 | ''
42 | window.open('data:application/vnd.ms-excel,' + encodeURIComponent(html))
43 | e.preventDefault()
44 | }
45 |
46 | handleExportPNG() {
47 | window.html2canvas(document.querySelectorAll('#CustomTable'), {
48 | onrendered: function (canvas) {
49 | //
50 | }
51 | })
52 | window.html2canvas(document.querySelectorAll('#CustomTable'), {
53 | onrendered: function (canvas) {
54 | window.Canvas2Image.saveAsPNG(canvas)
55 | }
56 | })
57 | }
58 |
59 | render() {
60 | const btnStyle = { cursor: 'pointer', marginBottom: '5px' }
61 | return (
62 |
63 |
70 | {' '}
71 | 儲存
72 |
73 |
80 | {' '}
81 | 分享
82 |
83 |
90 |
91 | {' '}
92 | 匯出
93 |
94 |
95 |
96 | .xls
97 |
98 |
99 | .png
100 |
101 |
102 |
103 |
104 |
111 |
112 | {' '}
113 | 清除
114 |
115 |
116 |
117 | 標記
118 |
119 |
120 | 課表
121 |
122 |
123 |
124 |
131 | {' '}
132 | 自訂時段
133 |
134 |
135 | )
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/ExchangeListContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Row, Col, Container, Alert } from 'reactstrap'
3 | import PropTypes from 'prop-types'
4 | import moment from 'moment'
5 | import cloenDeep from 'lodash.clonedeep'
6 |
7 | import ExchangeSetting from './ExchangeSetting.jsx'
8 | import ExchangeList from './ExchangeList.jsx'
9 | import Loading from './Loading'
10 |
11 | export default class ExchangeListContainer extends React.Component {
12 | constructor(props) {
13 | super(props)
14 | this.state = {
15 | exchangeList: {},
16 | filter: null
17 | }
18 |
19 | this.db = window.firebase.database()
20 | this.handleSaveExchangeSetting = this.handleSaveExchangeSetting.bind(this)
21 | this.handleUnpublish = this.handleUnpublish.bind(this)
22 | this.handleFilter = this.handleFilter.bind(this)
23 | this.parseList = this.parseList.bind(this)
24 | }
25 |
26 | componentDidMount() {
27 | this.getExchangeList()
28 | }
29 |
30 | getExchangeList() {
31 | this.db
32 | .ref('exchangeList/')
33 | .once('value')
34 | .then((snapshot) => {
35 | const data = snapshot.val() || {}
36 | this.setState({
37 | exchangeList: data
38 | })
39 | })
40 | }
41 |
42 | handleSaveExchangeSetting(have, want, desc) {
43 | const {
44 | displayName: name,
45 | uid: fbid,
46 | fbLink = null,
47 | fbPicture = '',
48 | uuid
49 | } = this.context.user
50 | const time = moment().format()
51 | this.db
52 | .ref(`exchangeList/${uuid}`)
53 | .set(
54 | JSON.stringify({
55 | fbid,
56 | fbLink,
57 | fbPicture,
58 | name,
59 | want,
60 | have,
61 | desc,
62 | time
63 | })
64 | )
65 | .then(() => {
66 | this.getExchangeList()
67 | })
68 | }
69 |
70 | handleUnpublish() {
71 | this.db
72 | .ref(`exchangeList/${this.context.user.uuid}`)
73 | .remove()
74 | .then(() => {
75 | this.getExchangeList()
76 | })
77 | }
78 |
79 | handleFilter(keywords) {
80 | this.setState({
81 | filter: keywords
82 | })
83 | }
84 |
85 | parseList() {
86 | const exchangeList = cloenDeep(this.state.exchangeList)
87 | const filterArr = this.state.filter ? cloenDeep(this.state.filter) : null
88 |
89 | let result = Object.values(exchangeList).map((val) => {
90 | return JSON.parse(val)
91 | })
92 |
93 | result = this.shuffle(result)
94 | if (filterArr) {
95 | result = result.filter((val) => {
96 | let valid = false
97 | let testString = val.have.join('') + val.want.join('')
98 | filterArr.forEach((keyword) => {
99 | if (testString.includes(keyword)) {
100 | valid = true
101 | }
102 | })
103 |
104 | return valid
105 | })
106 | }
107 |
108 | return result
109 | }
110 |
111 | shuffle(array) {
112 | var currentIndex = array.length,
113 | temporaryValue,
114 | randomIndex
115 |
116 | // While there remain elements to shuffle...
117 | while (0 !== currentIndex) {
118 | // Pick a remaining element...
119 | randomIndex = Math.floor(Math.random() * currentIndex)
120 | currentIndex -= 1
121 |
122 | // And swap it with the current element.
123 | temporaryValue = array[currentIndex]
124 | array[currentIndex] = array[randomIndex]
125 | array[randomIndex] = temporaryValue
126 | }
127 |
128 | return array
129 | }
130 |
131 | render() {
132 | const containerStyle = {
133 | height: 'calc(100vh - 52px)',
134 | overflow: 'auto',
135 | background: '#fff'
136 | }
137 |
138 | const exchangeList = this.parseList()
139 |
140 | return (
141 |
142 | {this.context.user && !this.context.user.uid && (
143 |
144 |
145 |
146 | 請先登入
147 |
148 |
149 |
150 | )}
151 |
152 | {this.context.user && this.context.user.uid && (
153 |
154 |
161 |
162 | {!exchangeList.length && }
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 | )}
171 |
172 | )
173 | }
174 | }
175 |
176 | ExchangeListContainer.contextTypes = {
177 | user: PropTypes.object
178 | }
179 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | 自己的課表自己排 2.0 - 暨大專屬排課表、換課、課程評價平台
35 |
36 |
40 |
44 |
48 |
52 |
56 |
79 |
80 |
81 |
82 |
102 |
103 |
109 |
141 |
142 |
143 |
144 |
--------------------------------------------------------------------------------
/src/CourseReviewList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Card, CardBody, CardColumns } from 'reactstrap'
3 | import moment from 'moment'
4 | import styled, { css } from 'styled-components'
5 |
6 | import './CourseReviewList.css'
7 |
8 | const ReviewCard = styled(Card)`
9 | border-radius: 0;
10 | box-shadow: 0 0 5px 0px #dddddd;
11 | `
12 |
13 | ReviewCard.DelBtn = styled.span`
14 | position: absolute;
15 | right: 15px;
16 | font-size: 26px;
17 | top: 12px;
18 | cursor: pointer;
19 | height: 17px;
20 | line-height: 17px;
21 | color: #d9534f;
22 | &:hover {
23 | color: #c9302c;
24 | }
25 | `
26 |
27 | ReviewCard.UserImg = styled.img`
28 | border-radius: 30px;
29 | `
30 |
31 | ReviewCard.Username = styled.span`
32 | color: #365899;
33 | font-size: 14px;
34 | font-weight: bold;
35 | `
36 |
37 | ReviewCard.Date = styled.span`
38 | font-size: 12px;
39 | color: #90949c;
40 | `
41 |
42 | ReviewCard.Title = styled.div`
43 | font-weight: bold;
44 | `
45 |
46 | ReviewCard.Content = styled.div`
47 | white-space: pre-line;
48 | `
49 |
50 | ReviewCard.LikeBtn = styled.span`
51 | flex: 1;
52 | padding: 7px;
53 | transition: all 0.3s;
54 | ${(props) =>
55 | props.like &&
56 | css`
57 | color: #0275d8;
58 | font-weight: bold;
59 | `}
60 |
61 | &:hover {
62 | background: #0275d8;
63 | color: #fff;
64 | cursor: pointer;
65 | }
66 | `
67 |
68 | ReviewCard.DisLikeBtn = styled.span`
69 | flex: 1;
70 | padding: 7px;
71 | transition: all 0.3s;
72 | ${(props) =>
73 | props.dislike &&
74 | css`
75 | color: #d9534f;
76 | font-weight: bold;
77 | `}
78 |
79 | &:hover {
80 | background: #d9534f;
81 | color: #fff;
82 | cursor: pointer;
83 | }
84 | `
85 |
86 | class ReviewItem extends React.Component {
87 | render() {
88 | const data = this.props.data
89 | const like = +data.currentUserLike === 1
90 | const dislike = +data.currentUserLike === -1
91 | return (
92 |
93 |
94 |
151 |
152 |
153 | {data.cid ? `${data.cid} ` : ''}
154 | {data.cname}
155 | {data.teacher ? ` | ${data.teacher}` : ''}
156 |
157 | {data.content}
158 |
159 |
160 |
161 | {
165 | this.props.onLike(1, data.currentUserLike, data.key)
166 | }}
167 | >
168 | {' '}
172 | {data.like['1'] || 0}
173 |
174 | {
178 | this.props.onLike(-1, data.currentUserLike, data.key)
179 | }}
180 | >
181 | {' '}
187 | {data.like['-1'] || 0}
188 |
189 |
190 |
191 |
192 | )
193 | }
194 | }
195 |
196 | export default class CourseReviewList extends React.Component {
197 | componentWillReceiveProps() {
198 | !!window.reviewListBricks && window.reviewListBricks.destroy()
199 | }
200 |
201 | componentDidUpdate() {
202 | window.reviewListBricks = new window.Bricklayer(
203 | document.querySelector('.bricklayer')
204 | )
205 | }
206 |
207 | render() {
208 | return (
209 |
210 | {this.props.reviewList.map((data, i) => {
211 | return
212 | })}
213 |
214 | )
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/src/CourseList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button } from 'reactstrap'
3 | import styled from 'styled-components'
4 | import Loading from './Loading'
5 |
6 | const CommonRow = styled.div`
7 | display: grid;
8 | grid-template-columns: 2fr 3fr 1fr 1fr 2fr 3fr 1fr 1fr 1fr;
9 | grid-gap: 2px;
10 | margin: 5px;
11 | `
12 |
13 | const TableHeader = styled(CommonRow)`
14 | margin: 0 5px 5px;
15 | position: sticky;
16 | top: 0;
17 | left: 0;
18 | background: #fff;
19 | z-index: 10;
20 | border-bottom: 1px solid #adadad;
21 | & span {
22 | padding: 10px 0px;
23 | font-weight: bold;
24 | }
25 |
26 | @media screen and (max-width: 992px) {
27 | display: none;
28 | }
29 | `
30 |
31 | const TableRow = styled(CommonRow)`
32 | background: #f9f9f9;
33 | border-radius: 2px;
34 | & span {
35 | display: flex;
36 | align-items: center;
37 | padding: 5px;
38 | &.TableRow__link,
39 | &.TableRow__btn {
40 | justify-content: center;
41 | }
42 | }
43 |
44 | @media screen and (max-width: 991px) {
45 | padding: 5px;
46 | box-shadow: 1px 1px 2px 0px #878787;
47 | margin-bottom: 10px;
48 | grid-template-columns: 1fr 3fr 1fr;
49 | grid-template-rows: 1fr 1fr 1fr 1fr;
50 | grid-template-areas:
51 | 'cid cname classes'
52 | 'teacher teacher teacher'
53 | 'time time time'
54 | 'location location location'
55 | 'link link btn';
56 | & span {
57 | padding: 0 8px;
58 | }
59 |
60 | & span.TableRow__cid {
61 | grid-area: cid;
62 | }
63 | & span.TableRow__cname {
64 | grid-area: cname;
65 | justify-content: center;
66 | font-weight: bold;
67 | }
68 | & span.TableRow__classes {
69 | grid-area: classes;
70 | justify-content: flex-end;
71 | &::after {
72 | content: '班';
73 | padding-left: 2px;
74 | }
75 | }
76 | & span.TableRow__time {
77 | grid-area: time;
78 | &::before {
79 | content: '時間:';
80 | padding-right: 2px;
81 | }
82 | }
83 | & span.TableRow__location {
84 | grid-area: location;
85 | &::before {
86 | content: '地點:';
87 | padding-right: 2px;
88 | }
89 | }
90 | & span.TableRow__teacher {
91 | grid-area: teacher;
92 | &::before {
93 | content: '教師:';
94 | padding-right: 2px;
95 | }
96 | }
97 | & span.TableRow__grade {
98 | display: none;
99 | }
100 | & span.TableRow__link {
101 | grid-area: link;
102 | justify-content: flex-end;
103 | }
104 | & span.TableRow__btn {
105 | grid-area: btn;
106 | & button {
107 | width: 100%;
108 | }
109 | }
110 | }
111 | `
112 |
113 | class DeptSelector extends React.Component {
114 | constructor(props) {
115 | super(props)
116 |
117 | this.state = {
118 | selected: ''
119 | }
120 |
121 | this.handleSelect = this.handleSelect.bind(this)
122 | }
123 |
124 | componentDidMount() {
125 | this.setState({
126 | selected: '通識'
127 | })
128 | }
129 |
130 | handleSelect(e) {
131 | this.setState({
132 | selected: e.target.value
133 | })
134 |
135 | this.props.onChangeDept(e.target.value)
136 | }
137 |
138 | render() {
139 | return (
140 |
141 |
開課單位:
142 |
143 | {Object.keys(this.props.deptList).map((val, i) => {
144 | return (
145 |
146 | {val}
147 |
148 | )
149 | })}
150 | {' '}
151 |
157 | 篩選課程
158 | {' '}
159 |
165 | {this.props.filterConflict ? '取消過濾' : '過濾衝堂'}
166 |
167 |
173 | 各系所課程地圖
174 |
175 |
176 | )
177 | }
178 | }
179 |
180 | export default class CourseList extends React.Component {
181 | render() {
182 | const list = this.props.courseList
183 | return (
184 |
185 |
186 |
194 | {!list.length &&
}
195 |
196 | {!!list.length && (
197 |
198 |
199 | 課號
200 | 課程名稱
201 | 班別
202 | 時段
203 | 授課地點
204 | 教師
205 | 年級
206 |
207 |
208 |
209 | {Object.keys(list).map((val) =>
210 | this.props.filterConflict && list[val].isConflict ? null : (
211 |
212 | {list[val].cid}
213 | {list[val].cname}
214 |
215 | {list[val].classes}
216 |
217 | {list[val].time}
218 |
219 | {list[val].location}
220 |
221 |
222 | {list[val].teacher}
223 |
224 | {list[val].grade}
225 |
226 |
231 | 課綱{' '}
232 |
236 |
237 |
238 |
239 | {
244 | this.props.onAddCourse(list[val], true)
245 | }}
246 | >
247 | {list[val].isConflict ? '衝堂' : '加入'}
248 |
249 |
250 |
251 | )
252 | )}
253 |
254 | )}
255 |
256 |
257 | )
258 | }
259 | }
260 |
--------------------------------------------------------------------------------
/src/Navigation.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Collapse,
4 | Navbar,
5 | NavbarToggler,
6 | NavbarBrand,
7 | Nav,
8 | NavItem,
9 | NavLink,
10 | Tooltip,
11 | UncontrolledDropdown,
12 | DropdownToggle,
13 | DropdownMenu,
14 | DropdownItem
15 | } from 'reactstrap'
16 | import { Link } from 'react-router-dom'
17 | import PropTypes from 'prop-types'
18 | import styled from 'styled-components'
19 |
20 | const NavigationBar = styled(Navbar)`
21 | z-index: 100;
22 | padding: 0.3rem 1rem;
23 | `
24 |
25 | const Hide850 = styled.span`
26 | @media (max-width: 850px) and (min-width: 576px) {
27 | display: none;
28 | }
29 | `
30 |
31 | const UserName = styled.span`
32 | @media (max-width: 1000px) and (min-width: 576px) {
33 | display: none;
34 | }
35 | `
36 |
37 | export default class Navigation extends React.Component {
38 | constructor(props) {
39 | super(props)
40 | this.toggleNavbar = this.toggleNavbar.bind(this)
41 | this.toggleLoginTooltip = this.toggleLoginTooltip.bind(this)
42 | this.toggleLogoutTooltip = this.toggleLogoutTooltip.bind(this)
43 | this.state = {
44 | navbarIsOpen: false,
45 | loginTooltip: false,
46 | logoutTooltip: false
47 | }
48 | }
49 |
50 | toggleNavbar() {
51 | this.setState((prevState) => ({
52 | navbarIsOpen: !prevState.navbarIsOpen
53 | }))
54 | }
55 |
56 | toggleLoginTooltip() {
57 | this.setState((prevState) => ({
58 | loginTooltip: !prevState.loginTooltip
59 | }))
60 | }
61 |
62 | toggleLogoutTooltip() {
63 | this.setState((prevState) => ({
64 | logoutTooltip: !prevState.logoutTooltip
65 | }))
66 | }
67 |
68 | handleLogin(providerName) {
69 | let provider
70 | switch (providerName) {
71 | case 'fb': {
72 | provider = new window.firebase.auth.FacebookAuthProvider()
73 | break
74 | }
75 |
76 | case 'google':
77 | default: {
78 | provider = new window.firebase.auth.GoogleAuthProvider()
79 | break
80 | }
81 | }
82 |
83 | window.firebase
84 | .auth()
85 | .signInWithPopup(provider)
86 | .then(function () {
87 | window.location.reload()
88 | })
89 | .catch(function (error) {
90 | const errorCode = error.code
91 | const errorMessage = error.message
92 | console.error(`[${errorCode}] ${errorMessage}`)
93 | })
94 | }
95 |
96 | handleLogout() {
97 | window.firebase
98 | .auth()
99 | .signOut()
100 | .then(function () {
101 | window.location.reload()
102 | })
103 | .catch(function (error) {
104 | console.error(error)
105 | })
106 | }
107 |
108 | render() {
109 | const route = this.context.router.route
110 | const user = this.context.user
111 | const loginStatusInit = !!user
112 | const isLogin = loginStatusInit && !!user.uid
113 | return (
114 |
115 |
116 | {' '}
121 | 自己的課表自己排 2.0
122 |
123 |
124 |
125 |
126 |
127 |
133 | {' '}
134 |
135 | 我的 課表
136 |
137 |
138 |
139 | {/*
140 |
148 | {' '}
149 |
150 | 換課平台
151 |
152 |
153 |
154 |
155 |
163 | {' '}
164 |
165 | 課程 評價
166 |
167 |
168 | */}
169 |
170 |
175 |
176 | {' '}
177 | 選課教學
178 |
179 |
180 |
181 |
182 |
187 |
188 | {' '}
192 | 回報
193 |
194 |
195 |
196 |
197 |
198 | {loginStatusInit && isLogin && (
199 |
200 |
201 | {user.displayName}
202 |
203 |
204 | )}
205 | {loginStatusInit && isLogin ? (
206 |
207 |
208 | {' '}
209 | 登出
210 |
217 | 登出
218 |
219 |
220 |
221 | ) : (
222 |
223 |
224 | 登入
225 |
226 |
227 | {
229 | this.handleLogin('fb')
230 | }}
231 | >
232 | {' '}
233 | Facebook
234 |
235 | {
237 | this.handleLogin('google')
238 | }}
239 | >
240 | Google
241 |
242 |
243 |
244 | )}
245 |
246 |
247 |
248 | )
249 | }
250 | }
251 |
252 | Navigation.contextTypes = {
253 | user: PropTypes.object,
254 | router: PropTypes.object
255 | }
256 |
--------------------------------------------------------------------------------
/src/ExchangeSetting.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Container, Row, Col, Card, Label, Input, Button } from 'reactstrap'
3 | import TagsInput from 'react-tagsinput'
4 | import PropTypes from 'prop-types'
5 | import styled from 'styled-components'
6 |
7 | import 'react-tagsinput/react-tagsinput.css'
8 | import './ExchangeSetting.css'
9 |
10 | const SettingContainer = styled(Container)`
11 | background: #fff;
12 | box-shadow: 0 0 5px 0 #888888;
13 | position: absolute;
14 | width: 100%;
15 | z-index: 1;
16 | left: 0;
17 | top: ${(props) => props.top};
18 | transition: all 0.5s;
19 | `
20 |
21 | const Toggle = styled.div`
22 | border-top: 1px solid rgba(0, 0, 0, 0.125);
23 | background: #fafafa;
24 | &:hover {
25 | background: #f0f0f0;
26 | cursor: pointer;
27 | }
28 | `
29 |
30 | export default class ExchangeSetting extends React.Component {
31 | constructor(props) {
32 | super(props)
33 | this.state = {
34 | wantTags: [],
35 | haveTags: [],
36 | desc: '',
37 | filterTags: [],
38 | published: false,
39 | settingOpen: true
40 | }
41 |
42 | this.handleChangeWant = this.handleChangeWant.bind(this)
43 | this.handleChangeHave = this.handleChangeHave.bind(this)
44 | this.handleChangeDesc = this.handleChangeDesc.bind(this)
45 | this.handleChangeFilter = this.handleChangeFilter.bind(this)
46 | this.handleSave = this.handleSave.bind(this)
47 | this.handleFilter = this.handleFilter.bind(this)
48 | this.handleToggleSetting = this.handleToggleSetting.bind(this)
49 | this.setSettingsFromProps = this.setSettingsFromProps.bind(this)
50 | }
51 |
52 | componentDidMount() {
53 | this.setSettingsFromProps(this.props)
54 | }
55 |
56 | componentWillReceiveProps(nextProps) {
57 | this.setSettingsFromProps(nextProps)
58 | }
59 |
60 | setSettingsFromProps(props) {
61 | const exchangeSetting = props.exchangeList[this.context.user.uuid]
62 | ? JSON.parse(props.exchangeList[this.context.user.uuid])
63 | : null
64 | if (exchangeSetting) {
65 | this.setState({
66 | wantTags: exchangeSetting.want,
67 | haveTags: exchangeSetting.have,
68 | desc: exchangeSetting.desc,
69 | published: true
70 | })
71 | } else {
72 | this.setState({
73 | wantTags: [],
74 | haveTags: [],
75 | desc: '',
76 | published: false
77 | })
78 | }
79 | }
80 |
81 | handleChangeWant(wantTags) {
82 | this.setState({
83 | wantTags
84 | })
85 | }
86 |
87 | handleChangeHave(haveTags) {
88 | this.setState({
89 | haveTags
90 | })
91 | }
92 |
93 | handleChangeDesc(e) {
94 | this.setState({
95 | desc: e.target.value
96 | })
97 | }
98 |
99 | handleChangeFilter(filterTags) {
100 | this.setState({
101 | filterTags
102 | })
103 | }
104 |
105 | handleSave() {
106 | if (!!this.state.haveTags.length || !!this.state.wantTags.length) {
107 | this.props.onSave(
108 | this.state.haveTags,
109 | this.state.wantTags,
110 | this.state.desc
111 | )
112 | } else {
113 | alert('請確保 Tag 有變成綠綠的 (於輸入框按下 Enter)')
114 | }
115 | }
116 |
117 | handleFilter(clear) {
118 | const keywords = this.state.filterTags
119 | if (clear || !keywords.length) {
120 | if (!clear && !keywords.length) {
121 | alert('請確保 Tag 有變成綠綠的 (於輸入框按下 Enter)')
122 | }
123 |
124 | this.setState({
125 | filterTags: []
126 | })
127 |
128 | this.props.onFilter(null)
129 | return
130 | }
131 |
132 | this.props.onFilter(keywords)
133 | }
134 | handleToggleSetting() {
135 | this.setState((prevState) => ({
136 | settingOpen: !prevState.settingOpen
137 | }))
138 | }
139 |
140 | render() {
141 | return (
142 |
152 |
153 |
154 |
155 |
設定個人換課資訊
156 |
157 |
158 |
159 | 想要的課
160 |
167 |
168 |
169 | 不需要的課
170 |
177 |
178 |
179 | 補充說明
180 |
187 |
188 |
189 |
190 |
191 | {this.state.published && (
192 | {
197 | this.props.onUnpublish()
198 | }}
199 | >
200 | 取消發佈
201 |
202 | )}
203 |
204 |
210 | 儲存並發佈
211 |
212 |
213 |
214 |
215 |
216 |
217 | 過濾換課資訊
218 |
219 |
220 |
227 |
228 |
229 | this.handleFilter(false)}
235 | >
236 | {' '}
237 | 過濾
238 | {' '}
239 | this.handleFilter(true)}
245 | >
246 | {' '}
247 | 清除
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 | )
262 | }
263 | }
264 |
265 | ExchangeSetting.contextTypes = {
266 | user: PropTypes.object
267 | }
268 |
--------------------------------------------------------------------------------
/public/lib/canvas2png.js:
--------------------------------------------------------------------------------
1 | /**
2 | * covert canvas to image
3 | * and save the image file
4 | */
5 |
6 | var Canvas2Image = (function () {
7 | // check if support sth.
8 | var $support = (function () {
9 | var canvas = document.createElement('canvas'),
10 | ctx = canvas.getContext('2d')
11 |
12 | return {
13 | canvas: !!ctx,
14 | imageData: !!ctx.getImageData,
15 | dataURL: !!canvas.toDataURL,
16 | btoa: !!window.btoa
17 | }
18 | })()
19 |
20 | var downloadMime = 'image/octet-stream'
21 |
22 | function scaleCanvas(canvas, width, height) {
23 | var w = canvas.width,
24 | h = canvas.height
25 | if (width == undefined) {
26 | width = w
27 | }
28 | if (height == undefined) {
29 | height = h
30 | }
31 |
32 | var retCanvas = document.createElement('canvas')
33 | var retCtx = retCanvas.getContext('2d')
34 | retCanvas.width = width
35 | retCanvas.height = height
36 | retCtx.drawImage(canvas, 0, 0, w, h, 0, 0, width, height)
37 | return retCanvas
38 | }
39 |
40 | function getDataURL(canvas, type, width, height) {
41 | canvas = scaleCanvas(canvas, width, height)
42 | return canvas.toDataURL(type)
43 | }
44 |
45 | function saveFile(strData) {
46 | document.location.href = strData
47 | }
48 |
49 | function genImage(strData) {
50 | var img = document.createElement('img')
51 | img.src = strData
52 | return img
53 | }
54 | function fixType(type) {
55 | type = type.toLowerCase().replace(/jpg/i, 'jpeg')
56 | var r = type.match(/png|jpeg|bmp|gif/)[0]
57 | return 'image/' + r
58 | }
59 | function encodeData(data) {
60 | if (!window.btoa) {
61 | throw 'btoa undefined'
62 | }
63 | var str = ''
64 | if (typeof data == 'string') {
65 | str = data
66 | } else {
67 | for (var i = 0; i < data.length; i++) {
68 | str += String.fromCharCode(data[i])
69 | }
70 | }
71 |
72 | return btoa(str)
73 | }
74 | function getImageData(canvas) {
75 | var w = canvas.width,
76 | h = canvas.height
77 | return canvas.getContext('2d').getImageData(0, 0, w, h)
78 | }
79 | function makeURI(strData, type) {
80 | return 'data:' + type + ';base64,' + strData
81 | }
82 |
83 | /**
84 | * create bitmap image
85 | * 按照规则生成图片响应头和响应体
86 | */
87 | var genBitmapImage = function (oData) {
88 | //
89 | // BITMAPFILEHEADER: http://msdn.microsoft.com/en-us/library/windows/desktop/dd183374(v=vs.85).aspx
90 | // BITMAPINFOHEADER: http://msdn.microsoft.com/en-us/library/dd183376.aspx
91 | //
92 |
93 | var biWidth = oData.width
94 | var biHeight = oData.height
95 | var biSizeImage = biWidth * biHeight * 3
96 | var bfSize = biSizeImage + 54 // total header size = 54 bytes
97 |
98 | //
99 | // typedef struct tagBITMAPFILEHEADER {
100 | // WORD bfType;
101 | // DWORD bfSize;
102 | // WORD bfReserved1;
103 | // WORD bfReserved2;
104 | // DWORD bfOffBits;
105 | // } BITMAPFILEHEADER;
106 | //
107 | var BITMAPFILEHEADER = [
108 | // WORD bfType -- The file type signature; must be "BM"
109 | 0x42,
110 | 0x4d,
111 | // DWORD bfSize -- The size, in bytes, of the bitmap file
112 | bfSize & 0xff,
113 | (bfSize >> 8) & 0xff,
114 | (bfSize >> 16) & 0xff,
115 | (bfSize >> 24) & 0xff,
116 | // WORD bfReserved1 -- Reserved; must be zero
117 | 0,
118 | 0,
119 | // WORD bfReserved2 -- Reserved; must be zero
120 | 0,
121 | 0,
122 | // DWORD bfOffBits -- The offset, in bytes, from the beginning of the BITMAPFILEHEADER structure to the bitmap bits.
123 | 54,
124 | 0,
125 | 0,
126 | 0
127 | ]
128 |
129 | //
130 | // typedef struct tagBITMAPINFOHEADER {
131 | // DWORD biSize;
132 | // LONG biWidth;
133 | // LONG biHeight;
134 | // WORD biPlanes;
135 | // WORD biBitCount;
136 | // DWORD biCompression;
137 | // DWORD biSizeImage;
138 | // LONG biXPelsPerMeter;
139 | // LONG biYPelsPerMeter;
140 | // DWORD biClrUsed;
141 | // DWORD biClrImportant;
142 | // } BITMAPINFOHEADER, *PBITMAPINFOHEADER;
143 | //
144 | var BITMAPINFOHEADER = [
145 | // DWORD biSize -- The number of bytes required by the structure
146 | 40,
147 | 0,
148 | 0,
149 | 0,
150 | // LONG biWidth -- The width of the bitmap, in pixels
151 | biWidth & 0xff,
152 | (biWidth >> 8) & 0xff,
153 | (biWidth >> 16) & 0xff,
154 | (biWidth >> 24) & 0xff,
155 | // LONG biHeight -- The height of the bitmap, in pixels
156 | biHeight & 0xff,
157 | (biHeight >> 8) & 0xff,
158 | (biHeight >> 16) & 0xff,
159 | (biHeight >> 24) & 0xff,
160 | // WORD biPlanes -- The number of planes for the target device. This value must be set to 1
161 | 1,
162 | 0,
163 | // WORD biBitCount -- The number of bits-per-pixel, 24 bits-per-pixel -- the bitmap
164 | // has a maximum of 2^24 colors (16777216, Truecolor)
165 | 24,
166 | 0,
167 | // DWORD biCompression -- The type of compression, BI_RGB (code 0) -- uncompressed
168 | 0,
169 | 0,
170 | 0,
171 | 0,
172 | // DWORD biSizeImage -- The size, in bytes, of the image. This may be set to zero for BI_RGB bitmaps
173 | biSizeImage & 0xff,
174 | (biSizeImage >> 8) & 0xff,
175 | (biSizeImage >> 16) & 0xff,
176 | (biSizeImage >> 24) & 0xff,
177 | // LONG biXPelsPerMeter, unused
178 | 0,
179 | 0,
180 | 0,
181 | 0,
182 | // LONG biYPelsPerMeter, unused
183 | 0,
184 | 0,
185 | 0,
186 | 0,
187 | // DWORD biClrUsed, the number of color indexes of palette, unused
188 | 0,
189 | 0,
190 | 0,
191 | 0,
192 | // DWORD biClrImportant, unused
193 | 0,
194 | 0,
195 | 0,
196 | 0
197 | ]
198 |
199 | var iPadding = (4 - ((biWidth * 3) % 4)) % 4
200 |
201 | var aImgData = oData.data
202 |
203 | var strPixelData = ''
204 | var biWidth4 = biWidth << 2
205 | var y = biHeight
206 | var fromCharCode = String.fromCharCode
207 |
208 | do {
209 | var iOffsetY = biWidth4 * (y - 1)
210 | var strPixelRow = ''
211 | for (var x = 0; x < biWidth; x++) {
212 | var iOffsetX = x << 2
213 | strPixelRow +=
214 | fromCharCode(aImgData[iOffsetY + iOffsetX + 2]) +
215 | fromCharCode(aImgData[iOffsetY + iOffsetX + 1]) +
216 | fromCharCode(aImgData[iOffsetY + iOffsetX])
217 | }
218 |
219 | for (var c = 0; c < iPadding; c++) {
220 | strPixelRow += String.fromCharCode(0)
221 | }
222 |
223 | strPixelData += strPixelRow
224 | } while (--y)
225 |
226 | var strEncoded =
227 | encodeData(BITMAPFILEHEADER.concat(BITMAPINFOHEADER)) +
228 | encodeData(strPixelData)
229 |
230 | return strEncoded
231 | }
232 |
233 | /**
234 | * saveAsImage
235 | * @param canvasElement
236 | * @param {String} image type
237 | * @param {Number} [optional] png width
238 | * @param {Number} [optional] png height
239 | */
240 | var saveAsImage = function (canvas, width, height, type) {
241 | if ($support.canvas && $support.dataURL) {
242 | if (typeof canvas == 'string') {
243 | canvas = document.getElementById(canvas)
244 | }
245 | if (type == undefined) {
246 | type = 'png'
247 | }
248 | type = fixType(type)
249 | if (/bmp/.test(type)) {
250 | var data = getImageData(scaleCanvas(canvas, width, height))
251 | var strData = genBitmapImage(data)
252 | saveFile(makeURI(strData, downloadMime))
253 | } else {
254 | var strData = getDataURL(canvas, type, width, height)
255 | saveFile(strData.replace(type, downloadMime))
256 | }
257 | }
258 | }
259 |
260 | var convertToImage = function (canvas, width, height, type) {
261 | if ($support.canvas && $support.dataURL) {
262 | if (typeof canvas == 'string') {
263 | canvas = document.getElementById(canvas)
264 | }
265 | if (type == undefined) {
266 | type = 'png'
267 | }
268 | type = fixType(type)
269 |
270 | if (/bmp/.test(type)) {
271 | var data = getImageData(scaleCanvas(canvas, width, height))
272 | var strData = genBitmapImage(data)
273 | return genImage(makeURI(strData, 'image/bmp'))
274 | } else {
275 | var strData = getDataURL(canvas, type, width, height)
276 | return genImage(strData)
277 | }
278 | }
279 | }
280 |
281 | return {
282 | saveAsImage: saveAsImage,
283 | saveAsPNG: function (canvas, width, height) {
284 | return saveAsImage(canvas, width, height, 'png')
285 | },
286 | saveAsJPEG: function (canvas, width, height) {
287 | return saveAsImage(canvas, width, height, 'jpeg')
288 | },
289 | saveAsGIF: function (canvas, width, height) {
290 | return saveAsImage(canvas, width, height, 'gif')
291 | },
292 | saveAsBMP: function (canvas, width, height) {
293 | return saveAsImage(canvas, width, height, 'bmp')
294 | },
295 |
296 | convertToImage: convertToImage,
297 | convertToPNG: function (canvas, width, height) {
298 | return convertToImage(canvas, width, height, 'png')
299 | },
300 | convertToJPEG: function (canvas, width, height) {
301 | return convertToImage(canvas, width, height, 'jpeg')
302 | },
303 | convertToGIF: function (canvas, width, height) {
304 | return convertToImage(canvas, width, height, 'gif')
305 | },
306 | convertToBMP: function (canvas, width, height) {
307 | return convertToImage(canvas, width, height, 'bmp')
308 | }
309 | }
310 | })()
311 |
--------------------------------------------------------------------------------
/src/CourseReviewContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import {
4 | Container,
5 | Row,
6 | Col,
7 | Alert,
8 | Modal,
9 | ModalHeader,
10 | ModalBody,
11 | ModalFooter,
12 | FormGroup,
13 | Label,
14 | Input,
15 | Button
16 | } from 'reactstrap'
17 | import moment from 'moment'
18 | import cloneDeep from 'lodash.clonedeep'
19 | import styled from 'styled-components'
20 |
21 | import CourseReviewFilter from './CourseReviewFilter.jsx'
22 | import CourseReviewList from './CourseReviewList.jsx'
23 |
24 | const RelativeContainer = styled(Container)`
25 | background: #fff;
26 | position: relative;
27 | `
28 |
29 | const ThePen = styled.div`
30 | background: #db4437;
31 | color: #fff;
32 | padding: 10px 15px 0px 15px;
33 | position: absolute;
34 | right: 10px;
35 | bottom: 10px;
36 | width: 52px;
37 | height: 52px;
38 | border-radius: 30px;
39 | overflow: hidden;
40 | cursor: pointer;
41 | box-shadow: 1px 1px 5px 1px #bbbbbb;
42 | transition: all 0.3s;
43 | &:hover {
44 | width: 180px;
45 | box-shadow: 1px 1px 5px 1px #787878;
46 | }
47 |
48 | i.fa {
49 | padding-bottom: 15px;
50 | font-size: 30px;
51 | margin-right: 10px;
52 | }
53 | `
54 |
55 | const ReviewListContainer = styled(Row)`
56 | overflow: auto;
57 | height: calc(100vh - 202px);
58 | `
59 |
60 | export default class CourseReviewContainer extends React.Component {
61 | constructor(props) {
62 | super(props)
63 | this.state = {
64 | addReviewModalOpen: false,
65 | reviewData: {},
66 | likeData: {},
67 | sortType: 1,
68 | filterTags: []
69 | }
70 |
71 | this.db = window.firebase.database()
72 | this.toggleAddReviewModal = this.toggleAddReviewModal.bind(this)
73 | this.handleAddReview = this.handleAddReview.bind(this)
74 | this.getReviewList = this.getReviewList.bind(this)
75 | this.parseReviewList = this.parseReviewList.bind(this)
76 | this.handleFilter = this.handleFilter.bind(this)
77 | this.handleChangeSortType = this.handleChangeSortType.bind(this)
78 | this.handleDelReview = this.handleDelReview.bind(this)
79 | this.handleLike = this.handleLike.bind(this)
80 |
81 | this.getReviewList()
82 | }
83 |
84 | getReviewList() {
85 | let reviewData
86 | let likeData
87 | this.db
88 | .ref('review')
89 | .once('value')
90 | .then((snapshot) => {
91 | reviewData = snapshot.val() || {}
92 | })
93 | .then(() => {
94 | this.db
95 | .ref('likedReview')
96 | .once('value')
97 | .then((snapshot) => {
98 | likeData = snapshot.val() || {}
99 | this.setState({
100 | reviewData,
101 | likeData
102 | })
103 | })
104 | })
105 | }
106 |
107 | parseReviewList() {
108 | const reviewData = cloneDeep(this.state.reviewData)
109 | const likeData = cloneDeep(this.state.likeData)
110 | const uuid = this.context.user && this.context.user.uuid
111 | let result = []
112 | let likedCount = {}
113 | Object.values(likeData).forEach((likeObj, i) => {
114 | Object.keys(likeObj).forEach((reviewKey, j) => {
115 | if (!likedCount[reviewKey]) likedCount[reviewKey] = {}
116 | if (!likedCount[reviewKey][likeObj[reviewKey]])
117 | likedCount[reviewKey][likeObj[reviewKey]] = 0
118 | likedCount[reviewKey][likeObj[reviewKey]]++
119 | })
120 | })
121 |
122 | for (let uid in reviewData) {
123 | for (let reviewKey in reviewData[uid]) {
124 | let reviewObj = JSON.parse(reviewData[uid][reviewKey])
125 | // filter
126 | let match = false
127 | if (this.state.filterTags.length > 0) {
128 | this.state.filterTags.some((keyword, i) => {
129 | keyword = keyword.toLowerCase()
130 | if (
131 | reviewObj.cid.toLowerCase().includes(keyword) ||
132 | reviewObj.cname.toLowerCase().includes(keyword) ||
133 | reviewObj.teacher.toLowerCase().includes(keyword) ||
134 | reviewObj.content.toLowerCase().includes(keyword)
135 | ) {
136 | match = true
137 | return true
138 | } else {
139 | return false
140 | }
141 | })
142 | } else {
143 | match = true
144 | }
145 |
146 | // like or dislike
147 | if (uuid && likeData[uuid] && likeData[uuid][reviewKey] !== undefined) {
148 | reviewObj.currentUserLike = likeData[uuid][reviewKey]
149 | }
150 |
151 | // current user's post
152 | if (uuid && uid === uuid) {
153 | reviewObj.currentUserPost = true
154 | }
155 |
156 | !!match &&
157 | result.push({
158 | ...reviewObj,
159 | key: reviewKey,
160 | like: likedCount[reviewKey] || {}
161 | })
162 | }
163 | }
164 |
165 | if (this.state.sortType === 1) {
166 | result.sort(this.compareByTime)
167 | } else {
168 | result.sort(this.compareByLike)
169 | }
170 |
171 | return result
172 | }
173 |
174 | compareByTime(a, b) {
175 | if (moment(a.time).isBefore(moment(b.time))) return 1
176 | if (moment(a.time).isBefore(moment(b.time))) return -1
177 | return 0
178 | }
179 |
180 | compareByLike(a, b) {
181 | if ((a.like['1'] || 0) < (b.like['1'] || 0)) return 1
182 | if ((a.like['1'] || 0) > (b.like['1'] || 0)) return -1
183 | return 0
184 | }
185 |
186 | toggleAddReviewModal() {
187 | this.setState((prevState) => ({
188 | addReviewModalOpen: !prevState.addReviewModalOpen
189 | }))
190 | }
191 |
192 | handleAddReview() {
193 | const cname = document.getElementById('ModalAddReview__inputCname').value
194 | const cid = document.getElementById('ModalAddReview__inputCid').value
195 | const teacher = document.getElementById('ModalAddReview__inputTeacher')
196 | .value
197 | const content = document.getElementById('ModalAddReview__inputContent')
198 | .value
199 | if (cname === '' || content === '') {
200 | alert('課程名稱、評論不得為空')
201 | return
202 | }
203 |
204 | const {
205 | uuid: uid,
206 | uid: fbid,
207 | fbLink = null,
208 | fbPicture = '',
209 | displayName: username
210 | } = this.context.user
211 | const time = moment().format()
212 | const randomKey = (
213 | Date.now().toString(32) + Math.random().toString(32)
214 | ).replace('.', '')
215 | this.db
216 | .ref(`review/${uid}/${randomKey}`)
217 | .set(
218 | JSON.stringify({
219 | cname,
220 | cid,
221 | teacher,
222 | content,
223 | fbid,
224 | fbLink,
225 | fbPicture,
226 | username,
227 | time
228 | })
229 | )
230 | .then(() => {
231 | this.toggleAddReviewModal()
232 | this.getReviewList()
233 | })
234 | }
235 |
236 | handleFilter(filterTags) {
237 | this.setState({
238 | filterTags
239 | })
240 | }
241 |
242 | handleChangeSortType(sortType) {
243 | this.setState({
244 | sortType
245 | })
246 | }
247 |
248 | handleDelReview(key) {
249 | if (window.confirm('確定刪除此篇評論?')) {
250 | this.db
251 | .ref(`review/${this.context.user.uuid}/${key}`)
252 | .remove()
253 | .then(() => {
254 | this.getReviewList()
255 | })
256 | }
257 | }
258 |
259 | handleLike(btnType, currentUserLike, key) {
260 | if (btnType === currentUserLike) {
261 | this.db
262 | .ref(`likedReview/${this.context.user.uuid}/${key}`)
263 | .remove()
264 | .then(() => {
265 | this.getReviewList()
266 | })
267 | } else {
268 | this.db
269 | .ref(`likedReview/${this.context.user.uuid}/${key}`)
270 | .set(btnType)
271 | .then(() => {
272 | this.getReviewList()
273 | })
274 | }
275 | }
276 |
277 | render() {
278 | const reviewList = this.parseReviewList()
279 | return (
280 |
380 | )
381 | }
382 | }
383 |
384 | CourseReviewContainer.contextTypes = {
385 | user: PropTypes.object
386 | }
387 |
--------------------------------------------------------------------------------
/src/CourseTableContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Row,
4 | Col,
5 | Modal,
6 | ModalHeader,
7 | ModalBody,
8 | ModalFooter,
9 | Button,
10 | Form,
11 | FormGroup,
12 | Label,
13 | Input
14 | } from 'reactstrap'
15 | import PropTypes from 'prop-types'
16 | import cloneDeep from 'lodash.clonedeep'
17 |
18 | import CourseList from './CourseList.jsx'
19 | import CourseTable from './CourseTable.jsx'
20 | import ToolBar from './ToolBar.jsx'
21 |
22 | export default class CourseTableContainer extends React.Component {
23 | constructor(props) {
24 | super(props)
25 |
26 | this.state = {
27 | deptList: {},
28 | courseList: {},
29 | customTable: {
30 | course: {
31 | 'a/08': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
32 | 'b/09': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
33 | 'c/10': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
34 | 'd/11': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
35 | 'z/12': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
36 | 'e/13': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
37 | 'f/14': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
38 | 'g/15': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
39 | 'h/16': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
40 | 'i/17': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
41 | 'j/18': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
42 | 'k/19': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
43 | 'l/20': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} },
44 | 'm/21': { 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} }
45 | },
46 | sat: false,
47 | sun: false
48 | },
49 | modalEditCourse: {
50 | open: false,
51 | modalTitle: '編輯課程',
52 | title: '',
53 | desc: '',
54 | bg: ''
55 | },
56 | modalCustomCourseOpen: false,
57 | filterConflict: false,
58 | modalFilterCourseOpen: false,
59 | filterCourseForm: {
60 | cname: '',
61 | teacher: '',
62 | time: '',
63 | location: ''
64 | }
65 | }
66 | this.timeMap = {
67 | a: 'a/08',
68 | b: 'b/09',
69 | c: 'c/10',
70 | d: 'd/11',
71 | z: 'z/12',
72 | e: 'e/13',
73 | f: 'f/14',
74 | g: 'g/15',
75 | h: 'h/16',
76 | i: 'i/17',
77 | j: 'j/18',
78 | k: 'k/19',
79 | l: 'l/20',
80 | m: 'm/21'
81 | }
82 | this.timeOrder = [
83 | 'a',
84 | 'b',
85 | 'c',
86 | 'd',
87 | 'z',
88 | 'e',
89 | 'f',
90 | 'g',
91 | 'h',
92 | 'i',
93 | 'j',
94 | 'k',
95 | 'l',
96 | 'm'
97 | ]
98 | this.db = window.firebase.database()
99 | this.getCourseList = this.getCourseList.bind(this)
100 | this.getCourseData = this.getCourseData.bind(this)
101 | this.changeDept = this.changeDept.bind(this)
102 | this.getTableData = this.getTableData.bind(this)
103 | this.checkConflict = this.checkConflict.bind(this)
104 | this.handleDelSatOrSun = this.handleDelSatOrSun.bind(this)
105 | this.handleDelCourse = this.handleDelCourse.bind(this)
106 | this.handleOpenEditModal = this.handleOpenEditModal.bind(this)
107 | this.toggleModalEditCourse = this.toggleModalEditCourse.bind(this)
108 | this.handleEditCourse = this.handleEditCourse.bind(this)
109 | this.handleReColor = this.handleReColor.bind(this)
110 | this.handleReTable = this.handleReTable.bind(this)
111 | this.toggleModalCustomCourse = this.toggleModalCustomCourse.bind(this)
112 | this.handleAddCustomCourse = this.handleAddCustomCourse.bind(this)
113 | this.handleSave = this.handleSave.bind(this)
114 | this.handleShare = this.handleShare.bind(this)
115 | this.filterConflict = this.filterConflict.bind(this)
116 | this.toggleModalFilterCourse = this.toggleModalFilterCourse.bind(this)
117 | this.handleFilterCourse = this.handleFilterCourse.bind(this)
118 | this.handleClearFilterCourse = this.handleClearFilterCourse.bind(this)
119 | this.handleFilterCourseChange = this.handleFilterCourseChange.bind(this)
120 | }
121 |
122 | componentDidMount() {
123 | this.getCourseData()
124 | this.getTableData()
125 | }
126 |
127 | getCourseList(dept = '通識') {
128 | const sessionCourseList = window.sessionStorage.courseList
129 | ? JSON.parse(window.sessionStorage.courseList)
130 | : '{}'
131 | /*
132 | if (sessionCourseList[dept]) {
133 | // console.log(`course in ${dept} exist in sessionStorage`);
134 | this.setState({
135 | courseList: Object.assign({}, sessionCourseList[dept])
136 | });
137 | return;
138 | }
139 | */
140 |
141 | // console.log(`Getting course in ${dept}`);
142 | this.db
143 | .ref(`course/${dept}`)
144 | .once('value')
145 | .then((snapshot) => {
146 | this.setState({
147 | courseList: snapshot.val() || {}
148 | })
149 | window.sessionStorage.courseList = JSON.stringify(
150 | Object.assign({}, sessionCourseList, { [dept]: snapshot.val() })
151 | )
152 | })
153 | }
154 |
155 | getCourseData() {
156 | // console.log('Getting course data...');
157 | if (/* !window.sessionStorage.deptList */ true) {
158 | // console.log('Getting deptList from db...')
159 | this.db
160 | .ref('deptList/')
161 | .once('value')
162 | .then((snapshot) => {
163 | window.sessionStorage.deptList = JSON.stringify(snapshot.val() || {})
164 | this.setState({
165 | deptList: snapshot.val() || {}
166 | })
167 | })
168 | } else {
169 | // console.log('deptList exist in sessionStorage.')
170 | this.setState({
171 | deptList: Object.assign({}, JSON.parse(window.sessionStorage.deptList))
172 | })
173 | }
174 |
175 | this.getCourseList()
176 | }
177 |
178 | changeDept(dept) {
179 | this.getCourseList(dept)
180 | }
181 |
182 | getTableData() {
183 | const user = this.context.user
184 | if (user === null) {
185 | setTimeout(this.getTableData, 100)
186 | return
187 | }
188 |
189 | if (user.uid) {
190 | // logged in, get data from db
191 | // console.log('Getting custom table data');
192 | this.db
193 | .ref(`customTable/${user.uuid}`)
194 | .once('value')
195 | .then((snapshot) => {
196 | const tableData = snapshot.val()
197 | tableData &&
198 | this.setState({
199 | customTable: Object.assign({}, JSON.parse(tableData))
200 | })
201 | })
202 | }
203 | }
204 |
205 | checkConflict(courseData, shouldAddCourse) {
206 | // if !shouldAddCourse => only check course conflict
207 | // validate time
208 | const time = courseData.time.toLowerCase() // '2bc3g'
209 | const sections = this.apartCourseTime(time) // ['abc', '3g']
210 | let timeValid = true
211 | sections.some((t) => {
212 | const valid = this.validateTime(t)
213 | if (!valid.valid) {
214 | shouldAddCourse && alert(valid.msg)
215 | timeValid = false
216 | return true
217 | }
218 |
219 | return false
220 | })
221 |
222 | if (timeValid) {
223 | let conflict = false
224 | const customTable = cloneDeep(this.state.customTable) // prevent call by reference
225 | sections.forEach((t, index) => {
226 | if (conflict) {
227 | return
228 | }
229 |
230 | const time = t // 2ab
231 | const dayOfWeek = time[0] // 2
232 | const startTime = time[1] // a
233 | const rowspan = time.length - 1 // 2
234 | if (
235 | customTable.course[this.timeMap[startTime]][dayOfWeek - 1] === null ||
236 | customTable.course[this.timeMap[startTime]][dayOfWeek - 1].title
237 | ) {
238 | conflict = true
239 | return
240 | }
241 |
242 | if (shouldAddCourse) {
243 | customTable.course[this.timeMap[startTime]][dayOfWeek - 1] = {
244 | // customTable.course['a/08'][1]
245 | rowspan: rowspan,
246 | title: courseData.cname || courseData.title,
247 | desc:
248 | courseData.desc || `${courseData.location} ${courseData.teacher}`,
249 | bg: courseData.bg || ''
250 | }
251 | }
252 |
253 | let nextTimeOrder = this.timeOrder.indexOf(startTime) + 1
254 | let nextTime = this.timeMap[this.timeOrder[nextTimeOrder]]
255 | for (let i = 0; i < rowspan - 1; i++) {
256 | const nextTimeCourse = customTable.course[nextTime][dayOfWeek - 1]
257 | if (nextTimeCourse === null || nextTimeCourse.title) {
258 | conflict = true
259 | return
260 | }
261 |
262 | shouldAddCourse &&
263 | (customTable.course[nextTime][dayOfWeek - 1] = null)
264 | nextTimeOrder++
265 | nextTime = this.timeMap[this.timeOrder[nextTimeOrder]]
266 | }
267 |
268 | if (!shouldAddCourse) return
269 |
270 | if (dayOfWeek === '6' && !customTable.sat) {
271 | customTable.sat = true
272 | }
273 |
274 | if (dayOfWeek === '7' && !customTable.sun) {
275 | customTable.sun = true
276 | }
277 | })
278 |
279 | if (!!conflict && shouldAddCourse) {
280 | alert('!!! 衝堂 !!!')
281 | return
282 | }
283 |
284 | shouldAddCourse &&
285 | this.setState({
286 | customTable: cloneDeep(customTable)
287 | })
288 |
289 | return conflict
290 | }
291 |
292 | return false
293 | }
294 |
295 | validateTime(t) {
296 | if (/^[1-7]{1}[A-MZa-mz]+$/.test(t)) {
297 | let last = -1
298 | let err = 0
299 | for (let i = 1; i < t.length; i++) {
300 | let current = t[i].charCodeAt()
301 | if (last !== -1) {
302 | if (current === 122) {
303 | // z
304 | if (last !== 100) {
305 | err++
306 | break
307 | }
308 |
309 | last = current
310 | continue
311 | }
312 |
313 | if (current === 101) {
314 | // e
315 | if (last !== 122) {
316 | err++
317 | break
318 | }
319 |
320 | last = current
321 | continue
322 | }
323 |
324 | if (current !== +last + 1) {
325 | err++
326 | break
327 | }
328 | }
329 |
330 | last = current
331 | }
332 |
333 | if (err) {
334 | return {
335 | valid: false,
336 | msg: '時段不連續'
337 | }
338 | } else {
339 | return {
340 | valid: true
341 | }
342 | }
343 | } else {
344 | return {
345 | valid: false,
346 | msg: '時間格式錯誤'
347 | }
348 | }
349 | }
350 |
351 | apartCourseTime(t) {
352 | let result = []
353 | if (t === '') {
354 | return ['zzzzz']
355 | }
356 |
357 | if (t.replace(/[1-7]{1}[A-MZa-mz]+/g, '').length > 0) {
358 | result.push(t)
359 | return result
360 | }
361 |
362 | return t.match(/[1-7]{1}[A-MZa-mz]+/g)
363 | }
364 |
365 | handleDelSatOrSun(day) {
366 | const customTable = cloneDeep(this.state.customTable)
367 | customTable[day] = false
368 | const dayOrder = day === 'sat' ? 5 : 6
369 | Object.keys(customTable.course).forEach((key, i) => {
370 | customTable.course[key][dayOrder] = {}
371 | })
372 |
373 | this.setState({
374 | customTable: cloneDeep(customTable)
375 | })
376 | }
377 |
378 | handleDelCourse(time, rowspan, dayOfWeek) {
379 | // 'a/08', '2', '0'
380 | const customTable = cloneDeep(this.state.customTable)
381 | customTable.course[time][dayOfWeek] = {}
382 | let nextTimeOrder = this.timeOrder.indexOf(time[0]) + 1
383 | let nextTime = this.timeMap[this.timeOrder[nextTimeOrder]]
384 | for (let i = 0; i < rowspan - 1; i++) {
385 | customTable.course[nextTime][dayOfWeek] = {}
386 | nextTimeOrder++
387 | nextTime = this.timeMap[this.timeOrder[nextTimeOrder]]
388 | }
389 |
390 | this.setState({
391 | customTable: cloneDeep(customTable)
392 | })
393 | }
394 |
395 | handleOpenEditModal(courseData) {
396 | const modalEditCourse = cloneDeep(this.state.modalEditCourse)
397 | modalEditCourse.open = true
398 | modalEditCourse.modalTitle = `編輯 ${courseData.title}`
399 | modalEditCourse.title = courseData.title
400 | modalEditCourse.desc = courseData.desc
401 | modalEditCourse.bg = courseData.bg
402 | modalEditCourse.time = courseData.time
403 | modalEditCourse.dayOfWeek = courseData.dayOfWeek
404 | this.setState({
405 | modalEditCourse: cloneDeep(modalEditCourse)
406 | })
407 | }
408 |
409 | toggleModalEditCourse() {
410 | const modalEditCourse = cloneDeep(this.state.modalEditCourse)
411 | modalEditCourse.open = !modalEditCourse.open
412 | this.setState({
413 | modalEditCourse: cloneDeep(modalEditCourse)
414 | })
415 | }
416 |
417 | handleEditCourse(time, dayOfWeek) {
418 | // 'a/08', '0'
419 | if (document.getElementById('ModalEditCourse__inputTitle').value === '') {
420 | alert('標題不得為空')
421 | return
422 | }
423 |
424 | const modalEditCourse = cloneDeep(this.state.modalEditCourse)
425 | modalEditCourse.open = false
426 |
427 | const customTable = cloneDeep(this.state.customTable)
428 | customTable.course[time][dayOfWeek].title = document.getElementById(
429 | 'ModalEditCourse__inputTitle'
430 | ).value
431 | customTable.course[time][dayOfWeek].desc = document.getElementById(
432 | 'ModalEditCourse__inputDesc'
433 | ).value
434 | customTable.course[time][dayOfWeek].bg = document.getElementById(
435 | 'ModalEditCourse__inputBg'
436 | ).value
437 |
438 | this.setState({
439 | modalEditCourse: cloneDeep(modalEditCourse),
440 | customTable: cloneDeep(customTable)
441 | })
442 | }
443 |
444 | handleReColor() {
445 | const customTable = cloneDeep(this.state.customTable)
446 | Object.keys(customTable.course).forEach((time) => {
447 | Object.keys(customTable.course[time]).forEach((dayOrder) => {
448 | customTable.course[time][dayOrder] &&
449 | (customTable.course[time][dayOrder].bg = '')
450 | })
451 | })
452 |
453 | this.setState({
454 | customTable: cloneDeep(customTable)
455 | })
456 | }
457 |
458 | handleReTable() {
459 | const customTable = cloneDeep(this.state.customTable)
460 | Object.keys(customTable.course).forEach((time) => {
461 | Object.keys(customTable.course[time]).forEach((dayOrder) => {
462 | customTable.course[time][dayOrder] = {}
463 | })
464 | })
465 |
466 | customTable.sat = false
467 | customTable.sun = false
468 | this.setState({
469 | customTable: cloneDeep(customTable)
470 | })
471 | }
472 |
473 | toggleModalCustomCourse() {
474 | this.setState((prevState) => ({
475 | modalCustomCourseOpen: !prevState.modalCustomCourseOpen
476 | }))
477 | }
478 |
479 | handleAddCustomCourse() {
480 | const time = document.getElementById('ModalCustomCourse__inputTime').value
481 | const title = document.getElementById('ModalCustomCourse__inputTitle').value
482 | if (!time || !title) {
483 | alert('時間、標題不得為空')
484 | return
485 | }
486 |
487 | this.checkConflict(
488 | {
489 | time,
490 | title,
491 | desc:
492 | document.getElementById('ModalCustomCourse__inputDesc').value || ' ',
493 | bg: document.getElementById('ModalCustomCourse__inputBg').value
494 | },
495 | true
496 | )
497 |
498 | this.toggleModalCustomCourse()
499 | }
500 |
501 | handleSave() {
502 | this.db
503 | .ref(`customTable/${this.context.user.uuid}`)
504 | .set(JSON.stringify(this.state.customTable))
505 | .then(() => {
506 | alert('儲存成功!')
507 | })
508 | }
509 |
510 | handleShare() {
511 | const uuid = this.context.user.uuid
512 | const hash = (Date.now() * Math.random() * Math.random())
513 | .toString(16)
514 | .replace('.', '')
515 | .substring(2, 6)
516 | this.db
517 | .ref(`sharedTable/${uuid}`)
518 | .set(JSON.stringify({ [hash]: this.state.customTable }))
519 | .then(() => {
520 | prompt(
521 | '已將當前課表分享於以下連結',
522 | `${process.env.REACT_APP_BASE_URL}/#/share/${uuid}${hash}`
523 | )
524 | })
525 | }
526 |
527 | filterConflict() {
528 | this.setState((prevState) => ({
529 | filterConflict: !prevState.filterConflict
530 | }))
531 | }
532 |
533 | toggleModalFilterCourse() {
534 | this.setState((prevState) => ({
535 | modalFilterCourseOpen: !prevState.modalFilterCourseOpen
536 | }))
537 | }
538 |
539 | handleFilterCourseChange(event, field) {
540 | const value = event.target.value
541 | this.setState((prevState) => ({
542 | filterCourseForm: Object.assign({}, prevState.filterCourseForm, {
543 | [field]: value
544 | })
545 | }))
546 | }
547 |
548 | handleFilterCourse() {
549 | this.setState(() => ({
550 | modalFilterCourseOpen: false
551 | }))
552 | }
553 |
554 | handleClearFilterCourse() {
555 | this.setState(() => ({
556 | filterCourseForm: {
557 | cname: '',
558 | teacher: '',
559 | time: '',
560 | location: ''
561 | },
562 | modalFilterCourseOpen: false
563 | }))
564 | }
565 |
566 | render() {
567 | let courseList = cloneDeep(this.state.courseList)
568 | let isFilterCourse = false
569 | const parsedCourseList = []
570 | const filterCourseForm = this.state.filterCourseForm
571 |
572 | Object.keys(courseList).reduce((result, courseKey) => {
573 | courseList[courseKey].isConflict = this.checkConflict(
574 | courseList[courseKey],
575 | false
576 | )
577 |
578 | const match = Object.keys(filterCourseForm).every((field) => {
579 | if (filterCourseForm[field] === '') {
580 | return true
581 | }
582 |
583 | isFilterCourse = true
584 | if (
585 | courseList[courseKey][field]
586 | .toUpperCase()
587 | .includes(filterCourseForm[field].toUpperCase())
588 | ) {
589 | return true
590 | }
591 |
592 | return false
593 | })
594 |
595 | if (match) {
596 | parsedCourseList.push(courseList[courseKey])
597 | }
598 |
599 | return parsedCourseList
600 | }, [])
601 |
602 | return (
603 |
610 |
611 |
612 | {this.context.user && this.context.user.uid && (
613 |
620 | )}
621 |
622 |
623 |
624 |
625 |
631 |
632 |
633 |
634 |
635 |
636 |
646 |
647 |
648 |
653 |
657 | {this.state.modalEditCourse.modalTitle}
658 |
659 |
660 |
701 |
702 |
703 | {
706 | this.handleEditCourse(
707 | this.state.modalEditCourse.time,
708 | this.state.modalEditCourse.dayOfWeek
709 | )
710 | }}
711 | >
712 | 確定
713 |
714 |
715 |
716 |
721 |
722 | 自訂時段
723 |
724 |
725 |
776 |
777 |
778 |
779 | 確定
780 |
781 |
782 |
783 |
788 |
792 | 篩選課程
793 |
794 |
795 |
861 |
862 |
863 |
864 | 篩選
865 |
866 |
867 | 取消篩選
868 |
869 |
870 |
871 |
872 | )
873 | }
874 | }
875 |
876 | CourseTableContainer.contextTypes = {
877 | user: PropTypes.object
878 | }
879 |
--------------------------------------------------------------------------------