├── .babelrc ├── .env.template ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── TODO.md ├── create-env.js ├── docs └── Screenshot2019-05-05.png ├── netlify-script.js ├── package-lock.json ├── package.json ├── src ├── App.js ├── api │ └── youtube.js ├── assets │ ├── download.svg │ ├── image.png │ ├── notebook.svg │ ├── play.svg │ ├── playlist.svg │ └── settings.svg ├── components │ ├── Heading │ │ ├── Heading.js │ │ └── SettingsModal.js │ ├── Notes │ │ ├── Notes.js │ │ ├── Notetaker.js │ │ └── index.js │ ├── Playlist │ │ ├── Playlist.js │ │ └── index.js │ ├── VideoPlayer │ │ ├── VideoPlayer.js │ │ ├── _Video.js │ │ └── index.js │ └── styledShared │ │ └── index.js ├── constants │ ├── dummy.js │ └── styles.js ├── context │ ├── context.spec.js │ └── index.js ├── helpers │ └── index.js ├── index.html └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "linaria/babel"], 3 | "plugins": [ 4 | "@babel/plugin-syntax-dynamic-import", 5 | "@babel/plugin-proposal-class-properties" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | YOUTUBE_API_KEY = 'YOU_NEED_A_YOUTUBE_KEY' -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier", "prettier/react"], 3 | "plugins": ["prettier", "jest"], 4 | "rules": { 5 | "react/jsx-filename-extension": [ 6 | 1, 7 | { 8 | "extensions": [".js", ".jsx"] 9 | } 10 | ], 11 | "react/prop-types": 0, 12 | "no-underscore-dangle": 0, 13 | "import/imports-first": ["error", "absolute-first"], 14 | "import/newline-after-import": "error", 15 | "react/destructuring-assignment": 0, 16 | "react/sort-comp": [ 17 | 1, 18 | { 19 | "order": ["static-methods", "lifecycle", "everything-else", "render"] 20 | } 21 | ], 22 | "jsx-a11y/media-has-caption": 0 23 | }, 24 | "env": { 25 | "jest/globals": true 26 | }, 27 | "globals": { 28 | "window": true, 29 | "document": true, 30 | "localStorage": true, 31 | "FormData": true, 32 | "FileReader": true, 33 | "Blob": true, 34 | "navigator": true, 35 | "videojs": true 36 | }, 37 | "parser": "babel-eslint" 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .linaria-cache 3 | node_modules 4 | dist 5 | !dist/assets 6 | 7 | # Mac files 8 | .DS_Store 9 | 10 | .env 11 | .linaria-cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `videojs-react-course-assistant` 2 | 3 | A course assistant tool similar to coursera or frontend masters, built using React, Linaria and VideoJS. 4 | 5 | WIP https://react-coursebuilder.netlify.com/ (please don't abuse the API key) 6 | 7 | ## Features 8 | 9 | - Fetches playlists from YouTube API 10 | - Autoplays through tracks 11 | - Takes time stamped notes and bookmarks 12 | - Sets time for note automatically based on start time of note 13 | - Somewhat responsive layout (untested) 14 | - Saves notes and fetched playlists to localStorage 15 | - Allows downloading of notes in JSON format 16 | 17 | ![visualExample](./docs/Screenshot2019-05-05.png) 18 | Uses dotenv, please copy the .env.template and to .env and use a valid API key for youtube. Initial load will not fire a youtube request but uses preloaded data. 19 | 20 | ## Initial Goals 21 | 22 | - [x] Use clean webpack configuration (i.e. no create react app) 23 | - [x] Play with Linaria :D 24 | - [x] VideoJS running okay with context actions 25 | - [x] Use react context for sharing notes and details (probably unnecessary tbh) 26 | - [x] Fetches a youtube playlist with sufficient data (working, needs playlist title) 27 | - [x] Autoplays through playlist 28 | - [x] Takes notes and bookmarks with correct timestamping (context handling added, bookmark = empty note) 29 | - [x] Saves notes relative to a specific playlist to localStorage (should be working once integrated) 30 | - [x] Mobile view (lazy initial effort) 31 | - [x] Download notes in a friendly format (csv? Json? what's good?) 32 | - [ ] Dont allow notes with same timestamp (no 0.000 options?) 33 | 34 | ## Further afield 35 | 36 | See [TODO.md](./TODO.md), contributions are strongly encouraged as I'm unlikely to ever get to adding everything that could be added 37 | 38 | ### Notes 39 | 40 | - This current buid depends on the videojs cdn loading 41 | - core-js handled to temporarily resolve linaria issues 42 | - To minimise API requests, only the playlist ID is known, meaning the playlist is hard to identify 43 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Functionality 2 | 3 | - [x] Update poster for individual videos 4 | - [ ] Playback settings (just speed initially) 5 | - [ ] On click bookmark go to point in video 6 | 7 | Style 8 | 9 | - [ ] Forms standardisation 10 | - [ ] Buttons (hover on delete and that kind of thing, more tactful bookmarking) 11 | - [x] Responsive design (untested, fullscreen issues on iOS) 12 | 13 | Post MVP 14 | 15 | - [ ] Decouple youtube aspects from base code 16 | - [ ] Subtitles 17 | - [ ] More info (e.g. length of playlist, amount already played, resume from point stopped, etc) 18 | - [ ] custom playlists (multiple sources? JSON plugin?) 19 | - [ ] autoscroll through notes (highlight most recent active) 20 | - [ ] edit notes (modal) 21 | - [ ] Control panel to control speed and view 22 | 23 | Post YouTube split 24 | 25 | - [ ] Server/database/something 26 | - [ ] Admin mode 27 | - [ ] Ability to add captions (same format as notes) 28 | - [ ] Electron App Exploration 29 | 30 | Also see numerous @todo comments scattered across project 31 | -------------------------------------------------------------------------------- /create-env.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.writeFileSync('./.env', `YOUTUBE_API_KEY=${process.env.YOUTUBE_API_KEY}\n`); 4 | console.log(process.env.YOUTUBE_API_KEY); 5 | -------------------------------------------------------------------------------- /docs/Screenshot2019-05-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/padraigfl/videojs-react-course-assistant/33d3be55abab31a72a66c95b675786c9706c3094/docs/Screenshot2019-05-05.png -------------------------------------------------------------------------------- /netlify-script.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.writeFileSync('./.env', `API_KEY=${process.env.YOUTUBE_API_KEY}\n`); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "videojs-react-course-assistant", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/.bin/jest", 8 | "test:watch": "./node_modules/.bin/jest --watch", 9 | "lint": "./node_modules/.bin/eslint src/ ", 10 | "pretty-quick": "./node_modules/.bin/pretty-quick --staged", 11 | "build": "webpack --mode production", 12 | "netlify": "node ./create-env.js && npm run build", 13 | "start": "webpack-dev-server --open --mode development" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@babel/core": "^7.4.3", 20 | "@babel/preset-env": "^7.4.3", 21 | "@babel/preset-react": "^7.0.0", 22 | "babel-eslint": "^10.0.1", 23 | "babel-jest": "^24.7.1", 24 | "babel-loader": "^8.0.5", 25 | "css-loader": "^2.1.1", 26 | "dotenv-webpack": "^1.7.0", 27 | "eslint": "^5.16.0", 28 | "eslint-config-airbnb": "^17.1.0", 29 | "eslint-config-prettier": "^4.1.0", 30 | "eslint-plugin-import": "^2.17.2", 31 | "eslint-plugin-jest": "^22.5.1", 32 | "eslint-plugin-jsx-a11y": "^6.2.1", 33 | "eslint-plugin-prettier": "^3.0.1", 34 | "eslint-plugin-react": "^7.12.4", 35 | "html-webpack-plugin": "^3.2.0", 36 | "jest": "^24.7.1", 37 | "mini-css-extract-plugin": "^0.6.0", 38 | "prettier": "^1.17.0", 39 | "react-test-renderer": "^16.8.6", 40 | "url-loader": "^1.1.2", 41 | "video.js": "^7.5.4", 42 | "webpack": "^4.30.0", 43 | "webpack-cli": "^3.3.1", 44 | "webpack-dev-server": "^3.3.1" 45 | }, 46 | "lint-staged": { 47 | "*.{js,jsx}": [ 48 | "npm run pretty-quick", 49 | "npm run lint", 50 | "git add" 51 | ] 52 | }, 53 | "precommit": "NODE_ENV=production ./node_modules/.bin/lint-staged", 54 | "dependencies": { 55 | "@babel/plugin-proposal-class-properties": "^7.4.4", 56 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 57 | "core-js": "^2.6.5", 58 | "dotenv": "^7.0.0", 59 | "linaria": "^1.3.1", 60 | "nanoid": "^2.0.1", 61 | "react": "16.8.6", 62 | "react-dom": "16.8.6", 63 | "videojs-react": "github:padraigfl/videojs-react", 64 | "videojs-youtube": "^2.6.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'linaria'; 3 | import Playlist from './components/Playlist'; 4 | import VideoPlayer from './components/VideoPlayer'; 5 | import { CourseProvider } from './context'; 6 | import { colors, spacings } from './constants/styles'; 7 | import Notes from './components/Notes'; 8 | import Heading from './components/Heading/Heading'; 9 | import notebookIcon from './assets/notebook.svg'; 10 | import playlistIcon from './assets/playlist.svg'; 11 | import playIcon from './assets/play.svg'; 12 | 13 | const banner = css` 14 | display: flex; 15 | background-color: ${colors.dark2}; 16 | color: ${colors.accent}; 17 | align-items: center; 18 | padding: 0px ${spacings.xs / 2}px; 19 | `; 20 | 21 | const bodyStyle = css` 22 | background-color: #111; 23 | color: ${colors.accent}; 24 | display: flex; 25 | flex-grow: 1; 26 | position: relative; 27 | justify-content: space-between; 28 | overflow: hidden; 29 | 30 | @media (min-width: 800px) { 31 | padding: ${spacings.xs / 2}px; 32 | section { 33 | margin: ${spacings.xs / 2}px; 34 | } 35 | } 36 | 37 | :global() { 38 | body { 39 | font-family: Arial; 40 | background-color: ${colors.dark2}; 41 | margin: 0px; 42 | } 43 | #courseBuilder { 44 | display: flex; 45 | flex-direction: column; 46 | height: 100vh; 47 | } 48 | h1, 49 | h2 { 50 | margin: 0px; 51 | } 52 | h1 { 53 | font-size: 1.625rem; 54 | } 55 | h2 { 56 | font-size: 1.375rem; 57 | } 58 | } 59 | @media (max-width: 1000px) and (max-height: 999px) { 60 | height: 100vh; 61 | flex-wrap: wrap; 62 | .Column.Video { 63 | width: 100%; 64 | } 65 | .Column--selected { 66 | position: absolute; 67 | top: 0px; 68 | left: 0px; 69 | height: 100%; 70 | max-width: 100%; 71 | width: 100%; 72 | } 73 | .Column:not(.Column--selected) { 74 | display: none; 75 | } 76 | } 77 | @media (max-width: 1000px) and (min-height: 1000px) { 78 | height: initial; 79 | flex-wrap: wrap; 80 | overflow: auto; 81 | .Column.Video { 82 | order: -1; 83 | width: 100%; 84 | } 85 | .Column:not(.Video) { 86 | max-width: initial; 87 | flex-grow: 1; 88 | } 89 | } 90 | `; 91 | 92 | const viewButtonStyle = css` 93 | display: none; 94 | @media (max-width: 1000px) and (max-height: 999px) { 95 | display: flex; 96 | button { 97 | margin-left: ${spacings.xs}px; 98 | padding: ${spacings.xs}px ${spacings.xs}px; 99 | &:disabled { 100 | background-color: ${colors.brand}; 101 | outline: none; 102 | border: none; 103 | color: ${colors.light}; 104 | font-weight: bold; 105 | } 106 | } 107 | } 108 | `; 109 | 110 | const ViewOption = ({ icon, value = '', update, active }) => ( 111 | 118 | ); 119 | 120 | const Link = props => ( 121 | 122 | {props.text} 123 | 124 | ); 125 | 126 | const keys = [ 127 | { value: 'playlist', icon: playlistIcon }, 128 | { value: 'video', icon: playIcon }, 129 | { value: 'notes', icon: notebookIcon } 130 | ]; 131 | 132 | const App = () => { 133 | const [active, updateActive] = React.useState(keys[0].value); 134 | return ( 135 | 136 | 141 |

Project

142 |

143 | This project is built with React, Linaria and VideoJS, using the 144 | Context API quite messily. More details are available on{' '} 145 | {' '} 149 | (e.g. future goals). Collaboration and feedback is strongly 150 | welcomed. 151 |

152 |
153 |

About Me

154 |

155 | I am a London based frontend developer trying to find fun side 156 | projects to do and collaborate on. Feel free to look at my work 157 | and get in touch via{' '} 158 | or{' '} 159 | 160 |

161 |

162 | 163 | If you like this and would like to support expanding upon it, 164 | I’d really appreciate some support over at{' '} 165 | 169 | 170 |

171 |
172 | {/* eslint-disable-next-line jsx-a11y/no-distracting-elements */} 173 | 174 | Other Projects:{' '} 175 | 179 | ,{' '} 180 | 185 | ,{' '} 186 | 190 | , other half baked things I haven‘t finished yet 191 | 192 | 193 | } 194 | level={1} 195 | > 196 | CourseBuilder 197 |
198 |
199 | {keys.map(k => ( 200 | 207 | ))} 208 |
209 |
210 | 211 | 212 | 213 |
214 |
215 | ); 216 | }; 217 | 218 | export default App; 219 | -------------------------------------------------------------------------------- /src/api/youtube.js: -------------------------------------------------------------------------------- 1 | const fetchYoutubeApiCore = (req, youtubeKey) => 2 | window 3 | .fetch( 4 | `https://www.googleapis.com/youtube/v3/${req}&key=${process.env 5 | .YOUTUBE_API_KEY || youtubeKey}`, 6 | { 7 | method: 'GET', 8 | headers: { 9 | 'Content-Type': 'application/json' 10 | } 11 | } 12 | ) 13 | .then(res => res.json()); 14 | 15 | export const fetchPlaylist = (id, youtubeKey) => { 16 | const x = fetchYoutubeApiCore(`playlists?id=${id}&part=snippet`, youtubeKey); 17 | return x; 18 | }; 19 | 20 | export const fetchPlaylistItems = (id, youtubeKey) => 21 | fetchYoutubeApiCore( 22 | `playlistItems?playlistId=${id}&part=snippet,contentDetails&maxResults=50`, 23 | youtubeKey 24 | ); 25 | 26 | export const fetchVideoDetails = (idList, youtubeKey) => 27 | fetchYoutubeApiCore(`videos?part=contentDetails&id=${idList}`, youtubeKey); 28 | -------------------------------------------------------------------------------- /src/assets/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/padraigfl/videojs-react-course-assistant/33d3be55abab31a72a66c95b675786c9706c3094/src/assets/image.png -------------------------------------------------------------------------------- /src/assets/notebook.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/playlist.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Heading/Heading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | import { Header } from '../styledShared'; 4 | import { colors, spacings } from '../../constants/styles'; 5 | import SettingsModal from './SettingsModal'; 6 | 7 | const SettingsButton = styled('button')` 8 | display: flex; 9 | background-color: transparent; 10 | border: none; 11 | color: ${colors.light}; 12 | padding: 0px ${spacings.xs}px; 13 | align-self: center; 14 | align-content: center; 15 | margin-left: auto; 16 | font-size: 24px; 17 | filter: brightness(50%); 18 | `; 19 | 20 | const Heading = props => { 21 | const [modal, toggleModal] = React.useState(); 22 | const HTag = `h${props.level}`; 23 | return ( 24 |
25 | {props.children} 26 | toggleModal(true)}> 27 | {props.icon || '⚙'} 28 | 29 | toggleModal(false)} display={modal}> 30 | {props.settingsView} 31 | 32 |
33 | ); 34 | }; 35 | 36 | Heading.defaultProps = { 37 | level: 5 38 | }; 39 | 40 | export default Heading; 41 | -------------------------------------------------------------------------------- /src/components/Heading/SettingsModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { styled } from 'linaria/react'; 4 | import { colors, spacings } from '../../constants/styles'; 5 | import { CancelButton } from '../styledShared'; 6 | 7 | const Overlay = styled('div')` 8 | position: absolute; 9 | left: 0px; 10 | top: 0px; 11 | height: 100%; 12 | width: 100%; 13 | align-items: center; 14 | background-color: rgba(0, 0, 0, 0.5); 15 | display: flex; 16 | `; 17 | const Settings = styled('div')` 18 | position: relative; 19 | background-color: ${colors.light}; 20 | margin: auto; 21 | width: 90%; 22 | max-width: 400px; 23 | padding: ${spacings.m}px; 24 | display: block; 25 | `; 26 | 27 | const SettingsModal = props => { 28 | return props.display 29 | ? ReactDOM.createPortal( 30 | 31 | e.stopPropagation()}> 32 | {props.children} 33 | 37 | X 38 | 39 | 40 | , 41 | document.getElementById('settings') 42 | ) 43 | : null; 44 | }; 45 | 46 | export default SettingsModal; 47 | -------------------------------------------------------------------------------- /src/components/Notes/Notes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css, cx } from 'linaria'; 3 | import CourseContext from '../../context'; 4 | import Notetaker from './Notetaker'; 5 | import downloadIcon from '../../assets/download.svg'; 6 | 7 | import { 8 | ListSection, 9 | List, 10 | ListEntry, 11 | EllipsisTextLine, 12 | CancelButton 13 | } from '../styledShared'; 14 | import { formatTime } from '../../helpers'; 15 | import Heading from '../Heading/Heading'; 16 | 17 | const main = css` 18 | display: flex; 19 | flex-direction: column; 20 | `; 21 | 22 | const downloadNotes = (notes, playlist) => { 23 | const a = document.createElement('a'); 24 | a.style.display = 'none'; 25 | document.body.appendChild(a); 26 | 27 | // Set the HREF to a Blob representation of the data to be downloaded 28 | a.href = window.URL.createObjectURL( 29 | new Blob( 30 | [ 31 | JSON.stringify({ 32 | playlist: playlist.id, 33 | notes: playlist.order.map(v => ({ 34 | track: playlist.items[v].title, 35 | notes: notes[v] 36 | })) 37 | }) 38 | ], 39 | { type: 'text/json' } 40 | ) 41 | ); 42 | 43 | // Use download attribute to set set desired file name 44 | a.setAttribute('download', `Notes_${playlist.id}`); 45 | 46 | // Trigger the download by simulating click 47 | a.click(); 48 | 49 | // Cleanup 50 | window.URL.revokeObjectURL(a.href); 51 | document.body.removeChild(a); 52 | }; 53 | 54 | const Notes = props => { 55 | const context = React.useContext(CourseContext); 56 | const [activeNote, selectNote] = React.useState(); 57 | 58 | const dlNotes = () => downloadNotes(context.notes, context.playlist); 59 | 60 | return ( 61 | 67 | 70 | 73 | 74 | } 75 | icon={download} 76 | level={2} 77 | > 78 | Notes & Bookmarks 79 | 80 | 81 | {context.playlist.order.map(vidId => 82 | (context.notes[vidId] || []).map((note, idx) => ( 83 | selectNote(note)} 86 | > 87 |
88 | { 92 | e.preventDefault(); 93 | context.setTrack( 94 | context.playlist.order.findIndex(x => x === vidId), 95 | note.time 96 | ); 97 | }} 98 | > 99 | {context.playlist.items[vidId].title} |{' '} 100 | {formatTime(note.time)} 101 | 102 | {note.text &&
{note.text}
} 103 | context.alterNotes.delete(vidId, idx)} 106 | > 107 | X 108 | 109 |
110 |
111 | )) 112 | )} 113 |
114 |
115 | 122 |
123 |
124 | ); 125 | }; 126 | 127 | export default Notes; 128 | -------------------------------------------------------------------------------- /src/components/Notes/Notetaker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | 4 | import CourseContext from '../../context'; 5 | import { colors, spacings } from '../../constants/styles'; 6 | import { formatTime } from '../../helpers'; 7 | 8 | const NoteSection = styled('div')` 9 | display: flex; 10 | flex-direction: column; 11 | padding: ${spacings.s}px; 12 | background-color: ${colors.brand}; 13 | margin-top: auto; 14 | position: relative; 15 | `; 16 | const TextArea = styled('textarea')` 17 | height: 100px; 18 | background-color: ${colors.accent}; 19 | flex-grow: 1; 20 | `; 21 | const NoteForm = styled('div')` 22 | display: flex; 23 | `; 24 | const NoteActions = styled('div')` 25 | font-weight: bold; 26 | display: flex; 27 | flex-direction: column; 28 | button { 29 | flex-grow: 1; 30 | } 31 | `; 32 | const TimeStamp = styled('div')` 33 | display: block; 34 | position: absolute; 35 | bottom: 0px; 36 | left: ${spacings.s}px; 37 | font-size: 0.75rem; 38 | max-width: calc(100% - ${spacings.s * 2}); 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | `; 42 | 43 | const Notetaker = props => { 44 | const context = React.useContext(CourseContext); 45 | const textEl = React.useRef(); 46 | const propNote = props.note || {}; 47 | const [text, updateNote] = React.useState(propNote.text); 48 | const [time, updateTime] = React.useState(propNote.time || 0); 49 | const [video, updateVideo] = React.useState(propNote.video || 0); 50 | 51 | const submitNote = e => { 52 | if (e.type === 'click' || (!e.shiftKey && e.which === 13)) { 53 | e.preventDefault(); 54 | context.alterNotes.add( 55 | { 56 | time, 57 | text 58 | }, 59 | context.playlist.order[video] 60 | ); 61 | updateTime(null); 62 | updateNote(''); 63 | textEl.current.blur(); 64 | } 65 | }; 66 | 67 | return ( 68 | 69 | 70 |