├── .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 | {' '} 27 | 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 | # ![](https://x3388638.github.io/KeBiau/logo.png) 自己的課表自己排 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 | ![](https://i.imgur.com/1totRc4.png) 26 | 27 | ### 重點功能 28 | - 以 FB/Google 登入做帳號管理 29 | - 原汁原味的教務系統課程資訊 30 | - 多元欄位篩選課程 31 | - 一鍵加入課程 32 | - 自動判斷衝堂 33 | - 一鍵過濾衝堂 34 | - 一鍵分享 35 | - 一鍵匯出 (xls/png) 36 | - 自由配色 37 | - 一鍵清除標記顏色 38 | - 一鍵清除課表 39 | - 搜尋課程 40 | - 自由編輯課程內容 41 | - 自訂時段,客製化加入時間區段與內容 42 | 43 | ## 換課 (已停用) 44 | #### 換課資訊不再散落四處,隨機排序人人平等,輕鬆過濾,換課萬無一失。 45 | ![](https://i.imgur.com/prIfnF8.png) 46 | 47 | ## 課程評價 (已停用) 48 | #### 課程資訊透明化,讓選課不再猶豫,修課不再後悔。 49 | ![](https://i.imgur.com/Ncb5onD.png) 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 |
46 | 47 | 48 | 49 | 68 | 80 | 81 | 82 | 83 | {moment(time).utcOffset(8).format('YYYY/MM/DD HH:mm:ss')} 84 | 85 | 86 | 87 |
50 | e.preventDefault() } 56 | : {})} 57 | > 58 | 66 | 67 | 69 | e.preventDefault() } 75 | : {})} 76 | > 77 | {name} 78 | 79 |
88 |
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 | 81 | 82 | )} 83 | 84 | {this.props.tableData.sun && ( 85 | 86 | 星期日{' '} 87 | 94 | 95 | )} 96 | 97 | 98 | 99 | {timeNo.map((t, i) => { 100 | return ( 101 | 102 | 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 |
星期一星期二星期三星期四星期五
{t}
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 | 73 | 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 | 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 | 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 |
95 | 96 | 97 | 98 | 117 | 138 | 139 | 140 | 147 | 148 | 149 |
99 | e.preventDefault() } 105 | : {})} 106 | > 107 | 115 | 116 | 118 | e.preventDefault() } 124 | : {})} 125 | > 126 | {data.username} 127 | 128 | {!!data.currentUserPost && ( 129 | { 131 | this.props.onDel(data.key) 132 | }} 133 | > 134 | × 135 | 136 | )} 137 |
141 | 142 | {moment(data.time) 143 | .utcOffset(8) 144 | .format('YYYY/MM/DD HH:mm:ss')} 145 | 146 |
150 |
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 | {' '} 151 | {' '} 159 | 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 | 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 | 197 | 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 | 202 | )} 203 | 204 | 212 | 213 | 214 | 215 |
216 |
217 |
過濾換課資訊
218 | 219 | 220 | 227 | 228 | 229 | {' '} 239 | 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 |
281 | {this.context.user && !this.context.user.uid && ( 282 | 283 | 284 | 285 | 請先登入 286 | 287 | 288 | 289 | )} 290 | 291 | {this.context.user && this.context.user.uid && ( 292 | 293 | 294 | 295 | 301 | 302 | 303 |
304 | 305 | 306 | 311 | 312 | 313 | 314 | 留下一則評論... 315 | 316 | 321 | 322 | 留下一則評論 323 | 324 | 325 | 326 | 333 | 334 | 335 | 336 | 337 | 338 | 341 | 342 | 343 | 344 | 345 | 346 | 349 | 350 | 351 | 352 | 353 | 354 | 361 | 362 | 368 | 369 | 370 | 371 | 372 | 375 | 376 | 377 |
378 | )} 379 |
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 |
661 | 662 | 665 | 666 | 672 | 673 | 674 | 675 | 678 | 679 | 685 | 686 | 687 | 688 | 691 | 692 | 698 | 699 | 700 |
701 |
702 | 703 | 714 | 715 |
716 | 721 | 722 | 自訂時段 723 | 724 | 725 |
726 | 727 | 730 | 731 | 736 | 737 | 738 | 739 | 742 | 743 | 748 | 749 | 750 | 751 | 754 | 755 | 760 | 761 | 762 | 763 | 766 | 767 | 773 | 774 | 775 |
776 |
777 | 778 | 781 | 782 |
783 | 788 | 792 | 篩選課程 793 | 794 | 795 |
796 | 797 | 800 | 801 | { 806 | this.handleFilterCourseChange(e, 'cname') 807 | }} 808 | placeholder="課程名稱" 809 | /> 810 | 811 | 812 | 813 | 816 | 817 | { 822 | this.handleFilterCourseChange(e, 'teacher') 823 | }} 824 | placeholder="授課教師" 825 | /> 826 | 827 | 828 | 829 | 832 | 833 | { 838 | this.handleFilterCourseChange(e, 'time') 839 | }} 840 | placeholder="1bcd" 841 | /> 842 | 843 | 844 | 845 | 848 | 849 | { 854 | this.handleFilterCourseChange(e, 'location') 855 | }} 856 | placeholder="管268" 857 | /> 858 | 859 | 860 |
861 |
862 | 863 | 866 | 869 | 870 |
871 |
872 | ) 873 | } 874 | } 875 | 876 | CourseTableContainer.contextTypes = { 877 | user: PropTypes.object 878 | } 879 | --------------------------------------------------------------------------------