├── README.md ├── favicon.png ├── github-mark.svg ├── index.html ├── script.js └── style.css /README.md: -------------------------------------------------------------------------------- 1 | # Library 2 | 3 | Library created with HTML, CSS, JS and Firebase. 4 | 5 | [Live Demo](https://michalosman.github.io/library/) :point_left: 6 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalosman/library/1d33f2275140bbc46e42c84b488142f54c1639a8/favicon.png -------------------------------------------------------------------------------- /github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Library 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |

library

21 | 31 |
32 |
33 |
34 | 35 |
36 |
37 | 49 | 84 | 85 |
86 | 87 | 88 | 89 | 90 | 91 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // Data Structures 2 | 3 | class Book { 4 | constructor( 5 | title = 'Unknown', 6 | author = 'Unknown', 7 | pages = '0', 8 | isRead = false 9 | ) { 10 | this.title = title 11 | this.author = author 12 | this.pages = pages 13 | this.isRead = isRead 14 | } 15 | } 16 | 17 | class Library { 18 | constructor() { 19 | this.books = [] 20 | } 21 | 22 | addBook(newBook) { 23 | if (!this.isInLibrary(newBook)) { 24 | this.books.push(newBook) 25 | } 26 | } 27 | 28 | removeBook(title) { 29 | this.books = this.books.filter((book) => book.title !== title) 30 | } 31 | 32 | getBook(title) { 33 | return this.books.find((book) => book.title === title) 34 | } 35 | 36 | isInLibrary(newBook) { 37 | return this.books.some((book) => book.title === newBook.title) 38 | } 39 | } 40 | 41 | const library = new Library() 42 | 43 | // User Interface 44 | 45 | const accountBtn = document.getElementById('accountBtn') 46 | const accountModal = document.getElementById('accountModal') 47 | const addBookBtn = document.getElementById('addBookBtn') 48 | const addBookModal = document.getElementById('addBookModal') 49 | const errorMsg = document.getElementById('errorMsg') 50 | const overlay = document.getElementById('overlay') 51 | const addBookForm = document.getElementById('addBookForm') 52 | const booksGrid = document.getElementById('booksGrid') 53 | const loggedIn = document.getElementById('loggedIn') 54 | const loggedOut = document.getElementById('loggedOut') 55 | const loadingRing = document.getElementById('loadingRing') 56 | 57 | const setupNavbar = (user) => { 58 | if (user) { 59 | loggedIn.classList.add('active') 60 | loggedOut.classList.remove('active') 61 | } else { 62 | loggedIn.classList.remove('active') 63 | loggedOut.classList.add('active') 64 | } 65 | loadingRing.classList.remove('active') 66 | } 67 | 68 | const setupAccountModal = (user) => { 69 | if (user) { 70 | accountModal.innerHTML = ` 71 |

Logged in as

72 |

${user.email.split('@')[0]}

` 73 | } else { 74 | accountModal.innerHTML = '' 75 | } 76 | } 77 | 78 | const openAddBookModal = () => { 79 | addBookForm.reset() 80 | addBookModal.classList.add('active') 81 | overlay.classList.add('active') 82 | } 83 | 84 | const closeAddBookModal = () => { 85 | addBookModal.classList.remove('active') 86 | overlay.classList.remove('active') 87 | errorMsg.classList.remove('active') 88 | errorMsg.textContent = '' 89 | } 90 | 91 | const openAccountModal = () => { 92 | accountModal.classList.add('active') 93 | overlay.classList.add('active') 94 | } 95 | 96 | const closeAccountModal = () => { 97 | accountModal.classList.remove('active') 98 | overlay.classList.remove('active') 99 | } 100 | 101 | const closeAllModals = () => { 102 | closeAddBookModal() 103 | closeAccountModal() 104 | } 105 | 106 | const handleKeyboardInput = (e) => { 107 | if (e.key === 'Escape') closeAllModals() 108 | } 109 | 110 | const updateBooksGrid = () => { 111 | resetBooksGrid() 112 | for (let book of library.books) { 113 | createBookCard(book) 114 | } 115 | } 116 | 117 | const resetBooksGrid = () => { 118 | booksGrid.innerHTML = '' 119 | } 120 | 121 | const createBookCard = (book) => { 122 | const bookCard = document.createElement('div') 123 | const title = document.createElement('p') 124 | const author = document.createElement('p') 125 | const pages = document.createElement('p') 126 | const buttonGroup = document.createElement('div') 127 | const readBtn = document.createElement('button') 128 | const removeBtn = document.createElement('button') 129 | 130 | bookCard.classList.add('book-card') 131 | buttonGroup.classList.add('button-group') 132 | readBtn.classList.add('btn') 133 | removeBtn.classList.add('btn') 134 | readBtn.onclick = toggleRead 135 | removeBtn.onclick = removeBook 136 | 137 | title.textContent = `"${book.title}"` 138 | author.textContent = book.author 139 | pages.textContent = `${book.pages} pages` 140 | removeBtn.textContent = 'Remove' 141 | 142 | if (book.isRead) { 143 | readBtn.textContent = 'Read' 144 | readBtn.classList.add('btn-light-green') 145 | } else { 146 | readBtn.textContent = 'Not read' 147 | readBtn.classList.add('btn-light-red') 148 | } 149 | 150 | bookCard.appendChild(title) 151 | bookCard.appendChild(author) 152 | bookCard.appendChild(pages) 153 | buttonGroup.appendChild(readBtn) 154 | buttonGroup.appendChild(removeBtn) 155 | bookCard.appendChild(buttonGroup) 156 | booksGrid.appendChild(bookCard) 157 | } 158 | 159 | const getBookFromInput = () => { 160 | const title = document.getElementById('title').value 161 | const author = document.getElementById('author').value 162 | const pages = document.getElementById('pages').value 163 | const isRead = document.getElementById('isRead').checked 164 | return new Book(title, author, pages, isRead) 165 | } 166 | 167 | const addBook = (e) => { 168 | e.preventDefault() 169 | const newBook = getBookFromInput() 170 | 171 | if (library.isInLibrary(newBook)) { 172 | errorMsg.textContent = 'This book already exists in your library' 173 | errorMsg.classList.add('active') 174 | return 175 | } 176 | 177 | if (auth.currentUser) { 178 | addBookDB(newBook) 179 | } else { 180 | library.addBook(newBook) 181 | saveLocal() 182 | updateBooksGrid() 183 | } 184 | 185 | closeAddBookModal() 186 | } 187 | 188 | const removeBook = (e) => { 189 | const title = e.target.parentNode.parentNode.firstChild.innerHTML.replaceAll( 190 | '"', 191 | '' 192 | ) 193 | 194 | if (auth.currentUser) { 195 | removeBookDB(title) 196 | } else { 197 | library.removeBook(title) 198 | saveLocal() 199 | updateBooksGrid() 200 | } 201 | } 202 | 203 | const toggleRead = (e) => { 204 | const title = e.target.parentNode.parentNode.firstChild.innerHTML.replaceAll( 205 | '"', 206 | '' 207 | ) 208 | const book = library.getBook(title) 209 | 210 | if (auth.currentUser) { 211 | toggleBookIsReadDB(book) 212 | } else { 213 | book.isRead = !book.isRead 214 | saveLocal() 215 | updateBooksGrid() 216 | } 217 | } 218 | 219 | accountBtn.onclick = openAccountModal 220 | addBookBtn.onclick = openAddBookModal 221 | overlay.onclick = closeAllModals 222 | addBookForm.onsubmit = addBook 223 | window.onkeydown = handleKeyboardInput 224 | 225 | // Local Storage 226 | 227 | const saveLocal = () => { 228 | localStorage.setItem('library', JSON.stringify(library.books)) 229 | } 230 | 231 | const restoreLocal = () => { 232 | const books = JSON.parse(localStorage.getItem('library')) 233 | if (books) { 234 | library.books = books.map((book) => JSONToBook(book)) 235 | } else { 236 | library.books = [] 237 | } 238 | } 239 | 240 | // Auth 241 | 242 | const auth = firebase.auth() 243 | const logInBtn = document.getElementById('logInBtn') 244 | const logOutBtn = document.getElementById('logOutBtn') 245 | 246 | auth.onAuthStateChanged(async (user) => { 247 | if (user) { 248 | setupRealTimeListener() 249 | } else { 250 | if (unsubscribe) unsubscribe() 251 | restoreLocal() 252 | updateBooksGrid() 253 | } 254 | setupAccountModal(user) 255 | setupNavbar(user) 256 | }) 257 | 258 | const signIn = () => { 259 | const provider = new firebase.auth.GoogleAuthProvider() 260 | auth.signInWithPopup(provider) 261 | } 262 | 263 | const signOut = () => { 264 | auth.signOut() 265 | } 266 | 267 | logInBtn.onclick = signIn 268 | logOutBtn.onclick = signOut 269 | 270 | // Firestore 271 | 272 | const db = firebase.firestore() 273 | let unsubscribe 274 | 275 | const setupRealTimeListener = () => { 276 | unsubscribe = db 277 | .collection('books') 278 | .where('ownerId', '==', auth.currentUser.uid) 279 | .orderBy('createdAt') 280 | .onSnapshot((snapshot) => { 281 | library.books = docsToBooks(snapshot.docs) 282 | updateBooksGrid() 283 | }) 284 | } 285 | 286 | const addBookDB = (newBook) => { 287 | db.collection('books').add(bookToDoc(newBook)) 288 | } 289 | 290 | const removeBookDB = async (title) => { 291 | db.collection('books') 292 | .doc(await getBookIdDB(title)) 293 | .delete() 294 | } 295 | 296 | const toggleBookIsReadDB = async (book) => { 297 | db.collection('books') 298 | .doc(await getBookIdDB(book.title)) 299 | .update({ isRead: !book.isRead }) 300 | } 301 | 302 | const getBookIdDB = async (title) => { 303 | const snapshot = await db 304 | .collection('books') 305 | .where('ownerId', '==', auth.currentUser.uid) 306 | .where('title', '==', title) 307 | .get() 308 | const bookId = snapshot.docs.map((doc) => doc.id).join('') 309 | return bookId 310 | } 311 | 312 | // Utils 313 | 314 | const docsToBooks = (docs) => { 315 | return docs.map((doc) => { 316 | return new Book( 317 | doc.data().title, 318 | doc.data().author, 319 | doc.data().pages, 320 | doc.data().isRead 321 | ) 322 | }) 323 | } 324 | 325 | const JSONToBook = (book) => { 326 | return new Book(book.title, book.author, book.pages, book.isRead) 327 | } 328 | 329 | const bookToDoc = (book) => { 330 | return { 331 | ownerId: auth.currentUser.uid, 332 | title: book.title, 333 | author: book.author, 334 | pages: book.pages, 335 | isRead: book.isRead, 336 | createdAt: firebase.firestore.FieldValue.serverTimestamp(), 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --white: #fffbfb; 3 | --grey: #f0eef1; 4 | --black: #050505; 5 | --red: #ff7070; 6 | --light-green: #9fff9c; 7 | --light-red: #ff9c9c; 8 | --border-radius: 8px; 9 | --spacing-xs: 5px; 10 | --spacing-sm: 10px; 11 | --spacing-md: 15px; 12 | --spacing-lg: 20px; 13 | --spacing-xl: 40px; 14 | --container-width: 1200px; 15 | --shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; 16 | } 17 | 18 | /* CSS RESET */ 19 | 20 | *, 21 | *::before, 22 | *::after { 23 | box-sizing: border-box; 24 | padding: 0; 25 | margin: 0; 26 | } 27 | 28 | html { 29 | /* footer support */ 30 | position: relative; 31 | min-height: 100%; 32 | } 33 | 34 | body { 35 | background-color: var(--grey); 36 | color: var(--black); 37 | font-family: "Poppins", sans-serif; 38 | font-size: 18px; 39 | font-weight: 500; 40 | word-wrap: break-word; 41 | /* footer support */ 42 | margin-bottom: 100px; 43 | } 44 | 45 | button, 46 | input { 47 | border: none; 48 | color: inherit; 49 | font-family: inherit; 50 | font-size: inherit; 51 | font-weight: inherit; 52 | cursor: pointer; 53 | outline: none; 54 | } 55 | 56 | input[type="text"], 57 | input[type="number"] { 58 | cursor: text; 59 | } 60 | 61 | /* UTILS */ 62 | 63 | .container { 64 | max-width: var(--container-width); 65 | padding: var(--spacing-lg); 66 | margin: 0 auto; 67 | } 68 | 69 | .btn { 70 | padding: var(--spacing-sm) var(--spacing-md); 71 | border-radius: var(--border-radius); 72 | transition: filter 0.15s ease-in-out; 73 | } 74 | 75 | .btn:hover { 76 | filter: brightness(90%); 77 | } 78 | 79 | .btn-add { 80 | font-size: 24px; 81 | font-weight: 600; 82 | } 83 | 84 | .btn-light-green { 85 | background-color: var(--light-green); 86 | } 87 | 88 | .btn-light-red { 89 | background-color: var(--light-red); 90 | } 91 | 92 | /* HEADER */ 93 | 94 | .header { 95 | background-color: var(--white); 96 | box-shadow: var(--shadow); 97 | } 98 | 99 | .header .container { 100 | display: flex; 101 | align-items: center; 102 | justify-content: space-between; 103 | } 104 | 105 | .logged-in, 106 | .logged-out { 107 | display: none; 108 | } 109 | 110 | .logged-in.active, 111 | .logged-out.active { 112 | display: flex; 113 | gap: 20px; 114 | } 115 | 116 | /* MAIN */ 117 | 118 | .main { 119 | margin-top: var(--spacing-lg); 120 | text-align: center; 121 | } 122 | 123 | .books-grid { 124 | display: grid; 125 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 126 | gap: var(--spacing-xl); 127 | margin-top: var(--spacing-xl); 128 | } 129 | 130 | .book-card { 131 | display: flex; 132 | flex-direction: column; 133 | justify-content: space-between; 134 | font-size: 20px; 135 | gap: var(--spacing-lg); 136 | padding: var(--spacing-lg); 137 | border-radius: var(--border-radius); 138 | background-color: var(--white); 139 | box-shadow: var(--shadow); 140 | line-height: 1.2; 141 | } 142 | 143 | .button-group { 144 | display: flex; 145 | flex-direction: column; 146 | gap: var(--spacing-lg); 147 | } 148 | 149 | /* FOOTER */ 150 | 151 | .footer { 152 | position: absolute; 153 | bottom: 0; 154 | left: 0; 155 | display: flex; 156 | align-items: center; 157 | justify-content: center; 158 | gap: 10px; 159 | width: 100%; 160 | padding: var(--spacing-sm); 161 | } 162 | 163 | .github-logo { 164 | display: block; 165 | max-width: 28px; 166 | max-height: 28px; 167 | color: var(--black); 168 | transition: transform 0.3s ease-in-out; 169 | } 170 | 171 | .github-logo:hover { 172 | transform: rotate(360deg) scale(1.2); 173 | } 174 | 175 | /* MODALS */ 176 | 177 | .modal { 178 | position: fixed; 179 | z-index: 1; 180 | top: 50%; 181 | left: 50%; 182 | width: 300px; 183 | padding: var(--spacing-lg); 184 | border-radius: var(--border-radius); 185 | background-color: var(--grey); 186 | text-align: center; 187 | transform: translate(-50%, -50%) scale(0); 188 | transition: 0.2s ease-in-out; 189 | } 190 | 191 | .modal.active { 192 | transform: translate(-50%, -50%) scale(1); 193 | } 194 | 195 | .add-book-form { 196 | display: flex; 197 | flex-direction: column; 198 | align-items: center; 199 | gap: var(--spacing-lg); 200 | } 201 | 202 | .add-book-form button { 203 | width: 100%; 204 | } 205 | 206 | .error-msg { 207 | display: none; 208 | color: red; 209 | } 210 | 211 | .error-msg.active { 212 | display: block; 213 | margin-top: -15px; 214 | } 215 | 216 | .input { 217 | width: 100%; 218 | padding: 10px; 219 | border-radius: var(--border-radius); 220 | } 221 | 222 | .is-read { 223 | display: flex; 224 | gap: var(--spacing-md); 225 | } 226 | 227 | .checkbox { 228 | width: 20px; 229 | height: 20px; 230 | margin-top: 4px; 231 | accent-color: white; 232 | } 233 | 234 | .overlay { 235 | position: fixed; 236 | top: 0; 237 | left: 0; 238 | display: none; 239 | width: 100%; 240 | height: 100%; 241 | background-color: rgba(0, 0, 0, 0.5); 242 | } 243 | 244 | .overlay.active { 245 | display: block; 246 | } 247 | 248 | /* LOADING RING */ 249 | 250 | .lds-dual-ring { 251 | display: none; 252 | width: 38px; 253 | height: 38px; 254 | } 255 | 256 | .lds-dual-ring:after { 257 | content: " "; 258 | display: block; 259 | width: 30px; 260 | height: 30px; 261 | margin: 8px; 262 | border-radius: 50%; 263 | border: 3px solid var(--black); 264 | border-color: var(--black) transparent var(--black) transparent; 265 | animation: lds-dual-ring 1.2s linear infinite; 266 | } 267 | 268 | .lds-dual-ring.active { 269 | display: block; 270 | } 271 | 272 | @keyframes lds-dual-ring { 273 | 0% { 274 | transform: rotate(0deg); 275 | } 276 | 100% { 277 | transform: rotate(360deg); 278 | } 279 | } 280 | 281 | /* MEDIA QUERIES */ 282 | 283 | @media (max-width: 400px) { 284 | .header .container { 285 | flex-direction: column; 286 | gap: var(--spacing-md); 287 | padding-top: var(--spacing-sm); 288 | } 289 | 290 | .main { 291 | margin-top: 0; 292 | } 293 | 294 | :root { 295 | --spacing-xl: 20px; 296 | } 297 | } 298 | --------------------------------------------------------------------------------