├── .gitignore ├── client ├── app.jsx ├── assets │ ├── dropdown.svg │ ├── mountain.svg │ ├── plus.svg │ ├── sign.svg │ ├── star.svg │ └── x.svg ├── components │ ├── confirm-delete-prompt.jsx │ ├── confirm-update-prompt.jsx │ └── entry.jsx ├── containers │ ├── entries.jsx │ ├── form.jsx │ └── header.jsx ├── index.html ├── index.js └── stylesheets │ ├── entries.css │ ├── form-styles.css │ ├── header.css │ ├── styles.css │ └── title.css ├── docs └── schema.png ├── package.json ├── readme.md ├── server ├── controllers │ ├── entry-controller.js │ └── form-controller.js ├── models │ ├── entry-queries.js │ ├── form-queries.js │ └── model.js └── server.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .DS_Store 4 | /client/build/* -------------------------------------------------------------------------------- /client/app.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react'; 2 | import Header from './containers/header.jsx'; 3 | import Form from './containers/form.jsx'; 4 | import Entries from './containers/entries.jsx'; 5 | 6 | const App = () => { 7 | // State of existing entry records 8 | const [ entries, setEntries ] = useState([]); 9 | 10 | // State of form dropdown options 11 | const [ activities, setActivities ] = useState([]); 12 | const [ types, setTypes ] = useState([]); 13 | const [ difficulties, setDifficulties ] = useState([]); 14 | 15 | // State of controlled main form component 16 | const [ selectedActivity, setSelectedActivity ] = useState(''); 17 | const [ selectedType, setSelectedType ] = useState(''); 18 | const [ selectedDifficulty, setSelectedDifficulty ] = useState(''); 19 | const [ currentRoute, setCurrentRoute ] = useState(''); 20 | const [ selectedRating, setSelectedRating ] = useState(0); 21 | const [ currentLocation, setCurrentLocation ] = useState(''); 22 | const [ currentRegion, setCurrentRegion ] = useState(''); 23 | const [ currentCountry, setCurrentCountry ] = useState(''); 24 | const [ selectedStartDate, setSelectedStartDate ] = useState(''); 25 | const [ selectedEndDate, setSelectedEndDate ] = useState(''); 26 | const [ currentNote, setCurrentNote ] = useState(''); 27 | const [ starHover, setStarHover ] = useState(0); 28 | 29 | // State of controlled update form component 30 | const [ selectedActivityForUpdate, setSelectedActivityForUpdate ] = useState(''); 31 | const [ selectedTypeForUpdate, setSelectedTypeForUpdate ] = useState(''); 32 | const [ selectedDifficultyForUpdate, setSelectedDifficultyForUpdate ] = useState(''); 33 | const [ currentRouteForUpdate, setCurrentRouteForUpdate ] = useState(''); 34 | const [ selectedRatingForUpdate, setSelectedRatingForUpdate ] = useState(0); 35 | const [ currentLocationForUpdate, setCurrentLocationForUpdate ] = useState(''); 36 | const [ currentRegionForUpdate, setCurrentRegionForUpdate ] = useState(''); 37 | const [ currentCountryForUpdate, setCurrentCountryForUpdate ] = useState(''); 38 | const [ selectedStartDateForUpdate, setSelectedStartDateForUpdate ] = useState(''); 39 | const [ selectedEndDateForUpdate, setSelectedEndDateForUpdate ] = useState(''); 40 | const [ currentNoteForUpdate, setCurrentNoteForUpdate ] = useState(''); 41 | 42 | // State of popup prompts 43 | const [ confirmDelete, setConfirmDelete ] = useState(false); 44 | const [ confirmUpdate, setConfirmUpdate ] = useState(false); 45 | const [ displayMainForm, setDisplayMainForm ] = useState(false); 46 | 47 | // Handler to update state of controlled main form component values 48 | const handleFormChange = (e) => { 49 | if (e.target.name === 'activity') { 50 | setSelectedActivity(Number(e.target.value)); 51 | 52 | const defaultType = types.filter(type => type.activity_id === Number(e.target.value))[0]._id 53 | setSelectedType(defaultType) 54 | 55 | const defaultDifficulty = difficulties.filter(difficulty => difficulty.type_id === defaultType)[0]._id 56 | setSelectedDifficulty(defaultDifficulty) 57 | } 58 | if (e.target.name === 'type') { 59 | setSelectedType(Number(e.target.value)); 60 | setSelectedDifficulty(difficulties.filter(difficulty => difficulty.type_id === Number(e.target.value))[0]._id) 61 | } 62 | if (e.target.name === 'difficulty') setSelectedDifficulty(Number(e.target.value)); 63 | if (e.target.name === 'route') setCurrentRoute(e.target.value); 64 | if (e.target.name === 'location')setCurrentLocation(e.target.value); 65 | if (e.target.name === 'region') setCurrentRegion(e.target.value); 66 | if (e.target.name === 'country') setCurrentCountry(e.target.value); 67 | if (e.target.name === 'start-date') setSelectedStartDate(e.target.value); 68 | if (e.target.name === 'end-date') setSelectedEndDate(e.target.value); 69 | if (e.target.name === 'note') setCurrentNote(e.target.value); 70 | 71 | if (e.target.id === 'star-1') setSelectedRating(1); 72 | if (e.target.id === 'star-2') setSelectedRating(2); 73 | if (e.target.id === 'star-3') setSelectedRating(3); 74 | if (e.target.id === 'star-4') setSelectedRating(4); 75 | if (e.target.id === 'star-5') setSelectedRating(5); 76 | if (e.target.id === 'star-clear') setSelectedRating(0); 77 | } 78 | 79 | // Handler to submit controlled main form component values and create new entry record 80 | const handleFormSubmit = async (e) => { 81 | e.preventDefault(); 82 | 83 | // Create http request body with controlled form component values 84 | const entryToCreate = { 85 | country: currentCountry, 86 | region: currentRegion, 87 | location: currentLocation, 88 | route: currentRoute, 89 | typeId: selectedType, 90 | difficultyId: selectedDifficulty, 91 | note: currentNote, 92 | startDate: selectedStartDate, 93 | endDate: selectedEndDate === '' ? null : selectedEndDate, 94 | rating: selectedRating 95 | } 96 | 97 | try { 98 | // Send POST request 99 | const response = await fetch('/entries', { 100 | method: 'POST', 101 | headers: { 102 | 'Content-Type': 'application/json' 103 | }, 104 | body: JSON.stringify(entryToCreate) 105 | }); 106 | 107 | if (response.status !== 201) { 108 | // Throw an error if request is not successful 109 | const error = await response.json(); 110 | throw new Error(error.message); 111 | } 112 | else { 113 | // If successful, add the created entry to the state of entry records in the correctly sorted position 114 | const createdEntry = await response.json(); 115 | 116 | setEntries([...entries, ...createdEntry].sort((a, b) => { 117 | return new Date(b.start_date) - new Date(a.start_date) 118 | })); 119 | 120 | // Reset controlled form component values 121 | const defaultActivity = activities[0]._id 122 | setSelectedActivity(defaultActivity); 123 | 124 | const defaultType = types.filter(type => type.activity_id === defaultActivity)[0]._id 125 | setSelectedType(defaultType); 126 | 127 | const defaultDifficulty = difficulties.filter(difficulty => difficulty.type_id === defaultType)[0]._id 128 | setSelectedDifficulty(defaultDifficulty); 129 | 130 | setCurrentRoute(''); 131 | setSelectedRating(0); 132 | setCurrentLocation(''); 133 | setCurrentRegion(''); 134 | setCurrentCountry(''); 135 | setSelectedStartDate(''); 136 | setSelectedEndDate(''); 137 | setCurrentNote(''); 138 | } 139 | } 140 | catch (err) { 141 | // Console log any caught errors 142 | console.log(err); 143 | } 144 | } 145 | 146 | // Handler to display prompt to confirm update 147 | const handleConfirmUpdate = (entryId) => { 148 | if (entryId) { 149 | const selectedEntryForUpdate = entries.filter(entry => entry.entry_id === entryId)[0]; 150 | 151 | setSelectedActivityForUpdate(selectedEntryForUpdate.activity_id); 152 | setSelectedTypeForUpdate(selectedEntryForUpdate.type_id); 153 | setSelectedDifficultyForUpdate(selectedEntryForUpdate.difficulty_id); 154 | setCurrentRouteForUpdate(selectedEntryForUpdate.route); 155 | setSelectedRatingForUpdate(selectedEntryForUpdate.rating); 156 | setCurrentLocationForUpdate(selectedEntryForUpdate.location); 157 | setCurrentRegionForUpdate(selectedEntryForUpdate.region); 158 | setCurrentCountryForUpdate(selectedEntryForUpdate.country); 159 | setSelectedStartDateForUpdate(selectedEntryForUpdate.start_date.substring(0, 10)); 160 | setSelectedEndDateForUpdate(selectedEntryForUpdate.end_date ? selectedEntryForUpdate.end_date.substring(0, 10) : ''); 161 | setCurrentNoteForUpdate(selectedEntryForUpdate.note); 162 | } 163 | 164 | setConfirmUpdate(entryId); 165 | } 166 | 167 | // Handler to update state of controlled update form component values 168 | const handleUpdateFormChange = (e) => { 169 | if (e.target.name === 'activity') { 170 | setSelectedActivityForUpdate(Number(e.target.value)); 171 | 172 | const defaultType = types.filter(type => type.activity_id === Number(e.target.value))[0]._id 173 | setSelectedTypeForUpdate(defaultType) 174 | 175 | const defaultDifficulty = difficulties.filter(difficulty => difficulty.type_id === defaultType)[0]._id 176 | setSelectedDifficultyForUpdate(defaultDifficulty) 177 | } 178 | if (e.target.name === 'type') { 179 | setSelectedTypeForUpdate(Number(e.target.value)); 180 | setSelectedDifficultyForUpdate(difficulties.filter(difficulty => difficulty.type_id === Number(e.target.value))[0]._id) 181 | } 182 | if (e.target.name === 'difficulty') setSelectedDifficultyForUpdate(Number(e.target.value)); 183 | if (e.target.name === 'route') setCurrentRouteForUpdate(e.target.value); 184 | if (e.target.name === 'location')setCurrentLocationForUpdate(e.target.value); 185 | if (e.target.name === 'region') setCurrentRegionForUpdate(e.target.value); 186 | if (e.target.name === 'country') setCurrentCountryForUpdate(e.target.value); 187 | if (e.target.name === 'start-date') setSelectedStartDateForUpdate(e.target.value); 188 | if (e.target.name === 'end-date') setSelectedEndDateForUpdate(e.target.value); 189 | if (e.target.name === 'note') setCurrentNoteForUpdate(e.target.value); 190 | 191 | if (e.target.id === 'star-1') setSelectedRatingForUpdate(1); 192 | if (e.target.id === 'star-2') setSelectedRatingForUpdate(2); 193 | if (e.target.id === 'star-3') setSelectedRatingForUpdate(3); 194 | if (e.target.id === 'star-4') setSelectedRatingForUpdate(4); 195 | if (e.target.id === 'star-5') setSelectedRatingForUpdate(5); 196 | if (e.target.id === 'star-clear') setSelectedRatingForUpdate(0); 197 | } 198 | 199 | // Handler to update an entry 200 | const handleEntryUpdate = async (e) => { 201 | e.preventDefault(); 202 | 203 | const entryToUpdate = { 204 | entryId: confirmUpdate, 205 | country: currentCountryForUpdate, 206 | region: currentRegionForUpdate, 207 | location: currentLocationForUpdate, 208 | route: currentRouteForUpdate, 209 | typeId: selectedTypeForUpdate, 210 | difficultyId: selectedDifficultyForUpdate, 211 | note: currentNoteForUpdate, 212 | startDate: selectedStartDateForUpdate, 213 | endDate: selectedEndDateForUpdate === '' ? null : selectedEndDateForUpdate, 214 | rating: selectedRatingForUpdate 215 | }; 216 | 217 | try { 218 | const response = await fetch('/entries', { 219 | method: 'PUT', 220 | headers: { 221 | 'Content-Type': 'application/json' 222 | }, 223 | body: JSON.stringify(entryToUpdate) 224 | }) 225 | 226 | if (response.status !== 200) { 227 | // Throw an error if request is not successful 228 | const error = await response.json(); 229 | throw new Error(error.message); 230 | } 231 | else { 232 | const updatedEntry = await response.json(); 233 | 234 | const updatedEntries = [ 235 | ...entries.filter(entry => entry.entry_id !== confirmUpdate), 236 | ...updatedEntry 237 | ] 238 | 239 | setEntries(updatedEntries.sort((a, b) => { 240 | return new Date(b.start_date) - new Date(a.start_date) 241 | })); 242 | 243 | setConfirmUpdate(false); 244 | } 245 | } 246 | catch (err) { 247 | console.log(err) 248 | } 249 | } 250 | 251 | // Handler to display prompt to confirm deletion 252 | const handleConfirmDelete = (entryId) => { 253 | setConfirmDelete(entryId); 254 | } 255 | 256 | // Handler to delete an entry 257 | const handleEntryDelete = async (entryId) => { 258 | // Create request body with passed in id of the entry 259 | const entryToDelete = { entryId } 260 | 261 | try { 262 | // Send DELETE request 263 | const response = await fetch('/entries', { 264 | method: 'DELETE', 265 | headers: { 266 | 'Content-Type': 'application/json' 267 | }, 268 | body: JSON.stringify(entryToDelete) 269 | }) 270 | 271 | if (response.status !== 200) { 272 | // Throw an error if request is not successful 273 | const error = await response.json(); 274 | throw new Error(error); 275 | } 276 | else { 277 | // If successful, remove deleted entry from rendered list of entries 278 | setEntries(entries.filter((entry => entry.entry_id !== entryId))); 279 | 280 | // Remove confirm prompt 281 | setConfirmDelete(false); 282 | } 283 | } 284 | catch (err) { 285 | console.log(err); 286 | } 287 | } 288 | 289 | // Toggle handler for main form 290 | const toggleMainForm = () => { 291 | setDisplayMainForm(!displayMainForm); 292 | } 293 | 294 | const handleStarHover = (e) => { 295 | if (e.type === 'mouseenter') { 296 | if (e.target.id === 'star-1') setStarHover(1); 297 | if (e.target.id === 'star-2') setStarHover(2); 298 | if (e.target.id === 'star-3') setStarHover(3); 299 | if (e.target.id === 'star-4') setStarHover(4); 300 | if (e.target.id === 'star-5') setStarHover(5); 301 | } 302 | if (e.type === 'mouseleave') setStarHover(0); 303 | } 304 | 305 | // Fetch and update entry records state on mount 306 | useEffect(() => { 307 | const fetchEntries = async () => { 308 | const response = await fetch('/entries'); 309 | const newEntries = await response.json(); 310 | 311 | setEntries(newEntries); 312 | } 313 | 314 | fetchEntries(); 315 | }, []); 316 | 317 | // Fetch and update form dropdowns state and set initial dropdown selections on mount 318 | useEffect(() => { 319 | const fetchDropdowns = async () => { 320 | const response = await fetch('/form'); 321 | const formDropdowns = await response.json(); 322 | 323 | setActivities(formDropdowns.activities); 324 | setTypes(formDropdowns.types); 325 | setDifficulties(formDropdowns.difficulties.sort((a, b) => a.sort - b.sort)); 326 | 327 | const defaultActivity = formDropdowns.activities[0]._id 328 | setSelectedActivity(defaultActivity); 329 | 330 | const defaultType = formDropdowns.types.filter(type => type.activity_id === defaultActivity)[0]._id 331 | setSelectedType(defaultType); 332 | 333 | const defaultDifficulty = formDropdowns.difficulties.filter(difficulty => difficulty.type_id === defaultType)[0]._id 334 | setSelectedDifficulty(defaultDifficulty); 335 | } 336 | 337 | fetchDropdowns(); 338 | }, []); 339 | 340 | // render Header, Form, and Entries containers 341 | return( 342 |
343 |
344 |
345 |

What mountains did you climb today?

346 | 347 |
348 |
370 |
371 | 397 |
398 |
399 | ); 400 | }; 401 | 402 | export default App; 403 | -------------------------------------------------------------------------------- /client/assets/dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/assets/mountain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 12 | 14 | 16 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /client/assets/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/assets/sign.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 736 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /client/assets/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /client/assets/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | cancel2 5 | 6 | -------------------------------------------------------------------------------- /client/components/confirm-delete-prompt.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ConfirmDeletePrompt = (props) => { 4 | const { confirmDelete, 5 | handleEntryDelete, 6 | handleConfirmDelete 7 | } = props; 8 | 9 | const displayStyle = { 10 | display: confirmDelete ? 'flex' : 'none' 11 | } 12 | 13 | return ( 14 |
15 |
16 |

Are you sure you want to delete this entry?

17 |
18 | 19 | 20 |
21 |
22 |
23 | ); 24 | } 25 | 26 | export default ConfirmDeletePrompt; 27 | -------------------------------------------------------------------------------- /client/components/confirm-update-prompt.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Form from '../containers/form.jsx' 3 | 4 | const ConfirmUpdatePrompt = (props) => { 5 | const { 6 | confirmUpdate, 7 | handleConfirmUpdate, 8 | activities, 9 | types, 10 | difficulties, 11 | selectedActivityForUpdate, 12 | selectedTypeForUpdate, 13 | selectedDifficultyForUpdate, 14 | currentRouteForUpdate, 15 | selectedRatingForUpdate, 16 | currentLocationForUpdate, 17 | currentRegionForUpdate, 18 | currentCountryForUpdate, 19 | selectedStartDateForUpdate, 20 | selectedEndDateForUpdate, 21 | currentNoteForUpdate, 22 | handleUpdateFormChange, 23 | handleEntryUpdate, 24 | handleStarHover, 25 | starHover 26 | } = props; 27 | 28 | const displayStyle = { 29 | display: confirmUpdate ? 'flex' : 'none' 30 | } 31 | 32 | const form = confirmUpdate 33 | ? 55 | : null 56 | 57 | return ( 58 |
59 |
60 | handleConfirmUpdate(false)} width="40px" height="40" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg"> 61 | 62 | 63 | {/* handleConfirmUpdate(false)} /> */} 64 | {form} 65 |
66 |
67 | ); 68 | } 69 | 70 | export default ConfirmUpdatePrompt; 71 | -------------------------------------------------------------------------------- /client/components/entry.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* -- ENTRY COMPONENT -- */ 4 | const Entry= (props) => { 5 | const { 6 | id, 7 | startYear, 8 | startMonth, 9 | startDay, 10 | endYear, 11 | endMonth, 12 | endDay, 13 | activity, 14 | type, 15 | route, 16 | difficulty, 17 | rating, 18 | location, 19 | region, 20 | country, 21 | note, 22 | timestamp, 23 | displayFirstMonth, 24 | displayLastMonth, 25 | handleConfirmUpdate, 26 | handleConfirmDelete 27 | } = props; 28 | 29 | let starRating = []; 30 | 31 | for (let i = 0; i < rating; i++) { 32 | starRating.push( 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | return ( 40 |
41 | 42 |
43 | { 44 | displayFirstMonth 45 | ? endMonth 46 | ? endMonth 47 | : startMonth 48 | : null 49 | } 50 | { 51 | displayFirstMonth 52 | ? endYear 53 | ? endYear 54 | : startYear 55 | : null 56 | } 57 |
58 | 59 |
60 | {endDay ? endDay : startDay} 61 |
62 | 63 |
64 |

{`${location} - ${region}, ${country}`}

65 |

{activity}

66 |

{`${route} - ${type} (${difficulty})`}

67 | {starRating} 68 |
69 | 70 |
71 | {displayLastMonth ? startMonth : null} 72 | {displayLastMonth ? startYear : null} 73 |
74 | 75 |
76 | {endDay ? startDay : endDay} 77 |
78 | 79 |
80 |

{note}

81 | {`Posted on ${timestamp}`} 82 |
83 | 84 | 92 | 93 | 100 |
101 | ); 102 | } 103 | 104 | export default Entry; 105 | -------------------------------------------------------------------------------- /client/containers/entries.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Entry from '../components/entry.jsx'; 3 | import ConfirmDeletePrompt from '../components/confirm-delete-prompt.jsx'; 4 | import ConfirmUpdatePrompt from '../components/confirm-update-prompt.jsx'; 5 | 6 | const Entries = (props) => { 7 | // Destructure entries from state 8 | const { 9 | entries, 10 | confirmDelete, 11 | handleConfirmDelete, 12 | handleEntryDelete, 13 | confirmUpdate, 14 | handleConfirmUpdate, 15 | activities, 16 | types, 17 | difficulties, 18 | selectedActivityForUpdate, 19 | selectedTypeForUpdate, 20 | selectedDifficultyForUpdate, 21 | currentRouteForUpdate, 22 | selectedRatingForUpdate, 23 | currentLocationForUpdate, 24 | currentRegionForUpdate, 25 | currentCountryForUpdate, 26 | selectedStartDateForUpdate, 27 | selectedEndDateForUpdate, 28 | currentNoteForUpdate, 29 | handleUpdateFormChange, 30 | handleEntryUpdate, 31 | handleStarHover, 32 | starHover 33 | } = props; 34 | 35 | let previousStartMonth; 36 | let previousEndMonth; 37 | 38 | const entryItemList = entries.map(entry => { 39 | const startDate = new Date(entry.start_date); 40 | const endDate = entry.end_date !== null ? new Date(entry.end_date) : null; 41 | const timestamp = new Date(entry.post_date).toLocaleString(); 42 | 43 | let displayFirstMonth; 44 | let displayLastMonth; 45 | const currentStartMonth = startDate.getMonth() 46 | const currentEndMonth = endDate !== null ? endDate.getMonth() : null 47 | 48 | if (currentEndMonth === null) { 49 | if (previousStartMonth === undefined || previousStartMonth !== currentStartMonth) displayFirstMonth = true; 50 | else displayFirstMonth = false; 51 | displayLastMonth = false; 52 | } 53 | if (currentEndMonth !== null) { 54 | if (currentStartMonth !== currentEndMonth) displayLastMonth = true 55 | else displayLastMonth = false; 56 | 57 | if (previousStartMonth === undefined || currentEndMonth !== previousStartMonth) displayFirstMonth = true; 58 | else displayFirstMonth = false; 59 | } 60 | 61 | previousStartMonth = currentStartMonth; 62 | previousEndMonth = currentEndMonth; 63 | 64 | return ( 65 | 89 | ); 90 | }); 91 | 92 | // render entry items 93 | return( 94 |
95 | {entryItemList} 96 | 118 | 123 |
124 | ); 125 | } 126 | 127 | export default Entries; 128 | -------------------------------------------------------------------------------- /client/containers/form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* -- FORM CONTAINER -- */ 4 | const Form = (props) => { 5 | // Destructure drilled props: dropdown options, controlled form component values, handlers 6 | const { 7 | activities, 8 | types, 9 | difficulties, 10 | selectedActivity, 11 | selectedType, 12 | selectedDifficulty, 13 | currentRoute, 14 | selectedRating, 15 | currentLocation, 16 | currentRegion, 17 | currentCountry, 18 | selectedStartDate, 19 | selectedEndDate, 20 | currentNote, 21 | handleFormChange, 22 | handleFormSubmit, 23 | displayMainForm, 24 | purpose, 25 | handleStarHover, 26 | starHover 27 | } = props; 28 | 29 | // Create dropdown options for activities, types and difficulties based on state values 30 | const activitiesList = activities.map(activity => { 31 | return ( 32 | 33 | ); 34 | }) 35 | 36 | const typesList = types.filter(type => type.activity_id === Number(selectedActivity)) 37 | .map(type => { 38 | return ( 39 | 40 | ); 41 | }); 42 | 43 | const difficultiesList = difficulties.filter(difficulty => difficulty.type_id === Number(selectedType)) 44 | .map(difficulty => { 45 | return ( 46 | 47 | ); 48 | }) 49 | 50 | // Set placeholder value for route field of form based on the currently selected type 51 | let convention; 52 | if (types[0]) convention = types.filter(type => type._id === Number(selectedType))[0].convention; 53 | 54 | const displayStyle = { 55 | maxHeight: displayMainForm ? '475px' : '0px', 56 | backgroundColor: purpose === 'main' ? '#e8e0d5' : 'rgb(244,242,237)' 57 | } 58 | 59 | const submitText = purpose === 'main' ? 'LOG' : 'FIX'; 60 | 61 | // set up star ratings for form 62 | let starRating = []; 63 | for (let i = 1; i <= 5; i++) { 64 | starRating.push( 65 | = i || selectedRating >= i ? "rgb(31,81,63)" : "rgb(150,150,150)"} 76 | > 77 | 78 | 79 | ); 80 | } 81 | 82 | if (selectedRating !== 0) starRating.push( 83 | Clear 84 | ); 85 | 86 | // Render component with dropdown lists, controlled form component values and handlers 87 | return ( 88 |
89 | 90 | 91 |
92 | 93 | 94 | 95 | 96 | 105 | 106 | 115 | 116 | 125 |
126 | 127 |
128 | 129 |
130 |
131 | 132 | 133 | 134 | 135 | 138 | 139 | 142 | 143 | 152 |
153 | 154 |
155 | 158 | 159 | {starRating} 160 |
161 |
162 | 163 |
164 | 165 |
166 | 167 | 174 | 175 | 176 | 183 |
184 | 185 | 192 | {/* */} 193 |
194 | 198 |
199 | 200 | 201 |
202 | ); 203 | } 204 | 205 | export default Form; 206 | -------------------------------------------------------------------------------- /client/containers/header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* -- HEADER CONTAINER -- */ 4 | const Header = (props) => { 5 | return( 6 | 12 | ); 13 | } 14 | 15 | export default Header; 16 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Out & About 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './app.jsx' 4 | 5 | const root = createRoot(document.getElementById('root')); 6 | root.render() 7 | -------------------------------------------------------------------------------- /client/stylesheets/entries.css: -------------------------------------------------------------------------------- 1 | /* entry item grid layout */ 2 | #entry-container { 3 | width: 900px; 4 | margin: auto; 5 | } 6 | 7 | .entry { 8 | display: grid; 9 | grid-template-columns: 80px 60px 660px 50px 50px; 10 | border-top: 2px solid #e8e0d5; 11 | } 12 | 13 | /* start month/year */ 14 | .month-banner-start { 15 | background: url(../assets/sign.svg); 16 | background-size: 100px; 17 | background-repeat: no-repeat; 18 | } 19 | 20 | .month-banner-end { 21 | background: url(../assets/sign.svg); 22 | background-size: 100px; 23 | background-repeat: no-repeat; 24 | background-position: bottom left; 25 | } 26 | 27 | .entry-start-container { 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: start; 31 | align-items: center; 32 | grid-column: 1; 33 | } 34 | 35 | .entry-start-container span { 36 | position: relative; 37 | top: 42px; 38 | left: 1px; 39 | display: block; 40 | font-family: Arial, Helvetica, sans-serif; 41 | color: rgb(120,120,120); 42 | } 43 | 44 | .entry-start-month { 45 | font-size: 18px; 46 | } 47 | 48 | .entry-start-year { 49 | font-size: 11px; 50 | } 51 | 52 | /* start day */ 53 | .entry-start-day { 54 | display: flex; 55 | flex-direction: column; 56 | justify-content: start; 57 | align-items: center; 58 | grid-column: 2; 59 | grid-row: 1; 60 | font-weight: bold; 61 | color: rgb(44,44,44); 62 | } 63 | 64 | .entry-start-day span { 65 | position: relative; 66 | top: 43px; 67 | display: block; 68 | font-family: Arial, Helvetica, sans-serif; 69 | font-size: 28px; 70 | } 71 | 72 | /* end month/year */ 73 | .entry-end-container { 74 | display: flex; 75 | flex-direction: column; 76 | justify-content: end; 77 | align-items: center; 78 | grid-column: 1; 79 | grid-row: 2; 80 | min-height: 100px; 81 | } 82 | 83 | .entry-end-container span { 84 | position: relative; 85 | bottom: 25px; 86 | left: 1px; 87 | display: block; 88 | font-family: Arial, Helvetica, sans-serif; 89 | color: rgb(120,120,120); 90 | } 91 | 92 | .entry-end-month { 93 | font-size: 18px; 94 | } 95 | 96 | .entry-end-year { 97 | font-size: 11px; 98 | } 99 | 100 | /* end day */ 101 | .entry-end-day { 102 | display: flex; 103 | flex-direction: column; 104 | justify-content: end; 105 | align-items: center; 106 | grid-column: 2; 107 | grid-row: 2; 108 | font-weight: bold; 109 | color: rgb(44,44,44); 110 | } 111 | 112 | .entry-end-day span { 113 | position: relative; 114 | bottom: 23px; 115 | display: block; 116 | font-family: Arial, Helvetica, sans-serif; 117 | font-size: 28px; 118 | } 119 | 120 | /* row 1 */ 121 | .entry-header { 122 | grid-column: 3; 123 | grid-row: 1; 124 | font-family: Arial, Helvetica, sans-serif; 125 | color: rgb(82,79,77); 126 | margin-left: 10px; 127 | margin-right: 20px; 128 | } 129 | 130 | .entry-header h1 { 131 | margin-top: 10px; 132 | font-size: 22px; 133 | color: rgb(54,54,54) 134 | } 135 | 136 | .entry-header h2 { 137 | margin-bottom: 5px; 138 | font-size: 20px; 139 | color: rgb(64,64,64) 140 | } 141 | 142 | .entry-header h3 { 143 | margin-top: 5px; 144 | margin-bottom: 5px; 145 | margin-right: 10px; 146 | font-size: 16px; 147 | margin-left: 15px; 148 | color: rgb(74,74,74); 149 | display: inline; 150 | } 151 | 152 | .entry-header svg { 153 | position: relative; 154 | top: 2px; 155 | } 156 | 157 | /* row 2 */ 158 | .entry-content { 159 | grid-column: 3; 160 | grid-row: 2; 161 | font-family: Arial, Helvetica, sans-serif; 162 | color: rgb(82,79,77); 163 | } 164 | 165 | .entry-content p { 166 | margin-top: 10px; 167 | color: rgb(44,44,44); 168 | margin-right: 20px; 169 | margin-left: 10px; 170 | } 171 | 172 | .entry-content small { 173 | font-size: 10px; 174 | display: block; 175 | text-align: end; 176 | margin: 5px; 177 | color: rgb(104,104,104) 178 | } 179 | 180 | /* entry buttons */ 181 | .entry-buttons { 182 | grid-row: 3; 183 | grid-row: 1 / 5; 184 | background-color: #e8e0d5; 185 | border: none; 186 | transition: background-color 0.3s; 187 | cursor: pointer; 188 | } 189 | 190 | .entry-buttons:hover { 191 | background-color: #e7d7c1; 192 | } 193 | 194 | .entry-buttons:hover svg { 195 | transition: fill 0.3s; 196 | } 197 | 198 | .entry-buttons:hover svg { 199 | fill: rgb(44,44,44); 200 | } 201 | 202 | .entry-edit { 203 | grid-column: 4; 204 | } 205 | 206 | .entry-delete { 207 | grid-column: 5; 208 | } 209 | 210 | /* confirm delete prompt */ 211 | .prompt-bg { 212 | position: fixed; 213 | top: 0px; 214 | left: 0px; 215 | width: 100%; 216 | height: 100%; 217 | justify-content: center; 218 | align-items: center; 219 | background-color: rgba(0,0,0,0.8); 220 | } 221 | 222 | .prompt { 223 | display: flex; 224 | flex-direction: column; 225 | align-items: end; 226 | background-color: rgb(244,242,237); 227 | border-radius: 5px; 228 | padding: 10px; 229 | } 230 | 231 | .cancel { 232 | position: relative; 233 | right: 25px; 234 | top: 10px; 235 | fill: rgb(175,175,175); 236 | transition: fill 0.2s; 237 | } 238 | 239 | .cancel:hover { 240 | fill: rgb(100,100,100); 241 | cursor: pointer; 242 | } 243 | 244 | .delete-buttons { 245 | display: flex; 246 | } 247 | 248 | .delete-buttons button { 249 | padding: 7px 15px; 250 | margin: 5px 5px; 251 | background-color: transparent; 252 | font-size: 16px; 253 | border: 1px solid black; 254 | border-radius: 5px; 255 | cursor: pointer; 256 | transition: background-color 0.3s; 257 | } 258 | 259 | .delete-buttons button:hover { 260 | background-color: #e8e0d5; 261 | } 262 | 263 | .prompt p { 264 | font-family: Arial, Helvetica, sans-serif; 265 | font-size: 16px; 266 | } -------------------------------------------------------------------------------- /client/stylesheets/form-styles.css: -------------------------------------------------------------------------------- 1 | /* general styles */ 2 | label { 3 | margin-right: 10px; 4 | font-size: 16px; 5 | color: rgb(41,41,41); 6 | } 7 | 8 | .form-container { 9 | width: 900px; 10 | margin: 0px auto; 11 | padding: 0px 15px 0px 15px; 12 | background-color: #e8e0d5; 13 | font-family: Arial, Helvetica, sans-serif; 14 | overflow: hidden; 15 | transition: max-height 0.3s ease-out; 16 | } 17 | 18 | .form-row { 19 | margin-top: 10px; 20 | padding: 1px; 21 | } 22 | 23 | .form-row-rating { 24 | margin-left: 27px; 25 | } 26 | 27 | .form-input { 28 | padding: 7px; 29 | font-size: 16px; 30 | border-radius: 5px; 31 | background-color: rgba(253,253,252); 32 | color: rgb(41,41,41); 33 | border: none; 34 | box-shadow: 0px 0px 2px rgb(114,108,99); 35 | margin-right: 10px; 36 | } 37 | 38 | .form-input:focus { 39 | outline: none; 40 | } 41 | 42 | .small-gap { 43 | height: 10px; 44 | } 45 | 46 | .big-gap { 47 | height: 25px; 48 | } 49 | 50 | /* specific styles */ 51 | 52 | .form-location { 53 | width: 300px; 54 | } 55 | 56 | .form-region { 57 | width: 200px; 58 | } 59 | 60 | .form-country { 61 | width: 200px; 62 | } 63 | 64 | .form-activity { 65 | width: 150px; 66 | } 67 | 68 | .form-type { 69 | width: 150px; 70 | } 71 | 72 | .form-route { 73 | width: 300px; 74 | } 75 | 76 | .form-difficulty { 77 | width: 125px; 78 | } 79 | 80 | .form-rating { 81 | width: 125px; 82 | } 83 | 84 | .form-start-date { 85 | width: 150px; 86 | font-family: Arial, Helvetica, sans-serif; 87 | } 88 | 89 | .form-end-date { 90 | width: 150px; 91 | font-family: Arial, Helvetica, sans-serif; 92 | } 93 | 94 | .form-note { 95 | margin-left: 1px; 96 | width: 868px; 97 | height: 150px; 98 | resize: none; 99 | margin-right: 0px; 100 | padding: 15px; 101 | font-family: Arial, Helvetica, sans-serif; 102 | font-size: 16px; 103 | } 104 | 105 | .form-submit-container { 106 | display: flex; 107 | justify-content: end; 108 | } 109 | 110 | .form-submit { 111 | padding: 5px 15px 5px 10px; 112 | background-color: rgb(31, 125, 63); 113 | color: rgb(244,242,237); 114 | font-size: 18px; 115 | font-weight: bold; 116 | border: none; 117 | border-radius: 5px; 118 | box-shadow: 0px 0px 1px rgb(44,44,44); 119 | transition: background-color 0.3s; 120 | margin-bottom: 15px; 121 | margin-top: 10px; 122 | } 123 | 124 | .form-submit img { 125 | position: relative; 126 | top: 2px; 127 | padding-right: 5px; 128 | rotate: 0deg; 129 | translate: 0px; 130 | transition: rotate 0.3s, translate 0.3s; 131 | } 132 | 133 | .form-submit:hover img { 134 | cursor: pointer; 135 | background-color: rgb(31,100,63); 136 | rotate: 0.5turn; 137 | translate: -5px; 138 | } 139 | 140 | .form-submit:hover { 141 | cursor: pointer; 142 | background-color: rgb(31,100,63); 143 | } 144 | 145 | .route-row { 146 | margin-left: 25px; 147 | } 148 | 149 | .route-row path { 150 | pointer-events: none; 151 | } 152 | 153 | .form-star { 154 | position: relative; 155 | top: 7px; 156 | } 157 | 158 | .form-star-clear { 159 | color: rgb(125,125,125); 160 | margin-left: 10px; 161 | cursor: pointer; 162 | transition: color 0.2s; 163 | } 164 | 165 | .form-star-clear:hover { 166 | color: rgb(100,100,100); 167 | } 168 | 169 | .addRowButton { 170 | position: relative; 171 | top: 4px; 172 | margin-right: 7px; 173 | } 174 | 175 | /* .test { 176 | width: 100px; 177 | height: 100px; 178 | background-color: red; 179 | } */ -------------------------------------------------------------------------------- /client/stylesheets/header.css: -------------------------------------------------------------------------------- 1 | #header { 2 | background-color: rgb(41,41,41); 3 | box-shadow: 0px -40px 100px black; 4 | margin-bottom: 50px; 5 | } 6 | 7 | #header-container { 8 | display: flex; 9 | width: 900px; 10 | margin: auto; 11 | } 12 | 13 | #header-container h1 { 14 | padding-left: 2px; 15 | margin-bottom: 15px; 16 | margin-top: 15px; 17 | color: rgb(244,242,237); 18 | font-family:Arial, Helvetica, sans-serif; 19 | } -------------------------------------------------------------------------------- /client/stylesheets/styles.css: -------------------------------------------------------------------------------- 1 | @import url('./header.css'); 2 | @import url('./title.css'); 3 | @import url('./form-styles.css'); 4 | @import url('./entries.css'); 5 | 6 | body { 7 | margin: 0px; 8 | background-color: rgb(244,242,237); 9 | } 10 | 11 | .footer { 12 | height: 400px; 13 | margin-top: 100px; 14 | background-color: rgb(213, 211, 206); 15 | } -------------------------------------------------------------------------------- /client/stylesheets/title.css: -------------------------------------------------------------------------------- 1 | .title { 2 | display: flex; 3 | justify-content: space-between; 4 | font-family: Arial, Helvetica, sans-serif; 5 | width: 900px; 6 | margin: 0px auto 0px auto; 7 | background-color: rgb(84,84,84); 8 | border-radius: 5px 5px 0px 0px; 9 | padding: 15px 15px 0px 15px; 10 | transition: background-color 0.3s; 11 | box-shadow: 0px 0px 1px black; 12 | } 13 | 14 | .bottom-title { 15 | height: 20px; 16 | width: 930px; 17 | margin: 0px auto 50px auto; 18 | border-radius: 0px 0px 5px 5px; 19 | background-color: rgb(84,84,84); 20 | box-shadow: 0px 0px 1px black; 21 | transition: background-color 0.3s; 22 | } 23 | 24 | .title:hover { 25 | cursor: pointer; 26 | background-color: rgb(64,64,64); 27 | } 28 | 29 | .title:hover ~ .bottom-title { 30 | background-color: rgb(64,64,64); 31 | } 32 | 33 | .title h1 { 34 | margin: 0px; 35 | color: #f0e9e1; 36 | font-size: 26px; 37 | } 38 | -------------------------------------------------------------------------------- /docs/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliendevlin/out-and-about/7d9de7564aa261bd40b90a2a874fc2766fc4dfc5/docs/schema.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "out-and-about", 3 | "version": "1.0.0", 4 | "description": "A mountain adventure journal", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production node server/server.js", 8 | "build": "cross-env NODE_ENV=production webpack", 9 | "dev": "cross-env NODE_ENV=development webpack serve & nodemon server/server.js" 10 | }, 11 | "author": "Julien Devlin", 12 | "license": "ISC", 13 | "dependencies": { 14 | "express": "^4.18.2", 15 | "pg": "^8.9.0", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.20.12", 21 | "@babel/preset-env": "^7.20.2", 22 | "@babel/preset-react": "^7.18.6", 23 | "babel-loader": "^9.1.2", 24 | "cross-env": "^7.0.3", 25 | "html-webpack-plugin": "^5.5.0", 26 | "nodemon": "^2.0.20", 27 | "webpack": "^5.75.0", 28 | "webpack-cli": "^5.0.1", 29 | "webpack-dev-server": "^4.11.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Out & About 2 | ### A journaling app for ski bums, dirt bags, mountaineers, or all of the above. 3 | #### Future Work 4 | * submit several locations/routes as part of one entry 5 | * image uploads 6 | * auto complete for existing entries 7 | * 3rd party API to populate db with routes/trails/etc 8 | * authentication 9 | * Maps/stats tabs 10 | * pagination 11 | * 3rd party integrations (lighterpack) 12 | * user feeds 13 | * expanded user-facing error handling 14 | #### React Component Layout 15 | |-- Index 16 |  |-- App 17 |   |-- Form 18 |   |-- Entries Container 19 |    |-- Entry Items 20 |    |-- Update Prompt 21 |    |-- Delete Prompt 22 | #### Server Layout 23 | |-- server 24 |  |-- entries 25 |   |-- GET 26 |   |-- POST 27 |   |-- PUT 28 |   |-- DELETE 29 |  |-- form 30 |   |-- GET 31 | #### Database Schema 32 | ![schema](./docs/schema.png) 33 | -------------------------------------------------------------------------------- /server/controllers/entry-controller.js: -------------------------------------------------------------------------------- 1 | const db = require('../models/model'); 2 | const queries = require('../models/entry-queries') 3 | 4 | const entryController = {} 5 | 6 | // Get entry info from all columns in database, ordered by entry start date 7 | entryController.getEntries = (req, res, next) => { 8 | // Check if entry id stored in locals, if so add a where condition to query for that entry, if not query for all entries 9 | let selectQuery = queries.selectAll; 10 | const queryParams = []; 11 | 12 | if (Object.hasOwn(res.locals, 'entryId')) { 13 | selectQuery += 'WHERE entry_routes.entry_id = ($1) '; 14 | queryParams.push(res.locals.entryId); 15 | } 16 | 17 | selectQuery += 'ORDER BY entries.start_date DESC'; 18 | 19 | // Query for all columns in all tables and save to locals 20 | db.query(selectQuery, queryParams) 21 | .then(data => { 22 | res.locals.entries = data.rows; 23 | return next(); 24 | }) 25 | .catch(err => { 26 | return next({ 27 | log: `ERROR - entryController.getEntries: ${err}`, 28 | message: {err: 'Failed to retrieve entries from database. Check server log for details.'} 29 | }); 30 | }); 31 | } 32 | 33 | entryController.setEntry = (req, res, next) => { 34 | // Initialize variables for storage during transaction 35 | let regionId; 36 | let countryId; 37 | let locationId; 38 | let routeId; 39 | let entryId; 40 | 41 | // Destrcture request body properties to use for transaction 42 | const { country, region, location, route, typeId, difficultyId, note, startDate, endDate, rating } = req.body; 43 | 44 | // Begin transaction 45 | db.query('BEGIN') 46 | // Search for country, insert if not found 47 | .then(() => db.query(queries.selectCountry, [country])) 48 | .then(countryQuery => { 49 | if (countryQuery.rows[0]) return countryQuery; 50 | return db.query(queries.insertCountry, [country]); 51 | }) 52 | // Search for region, insert if not found 53 | .then(countryQuery => { 54 | countryId = countryQuery.rows[0]._id; 55 | return db.query(queries.selectRegion, [region]); 56 | }) 57 | .then(regionQuery => { 58 | if (regionQuery.rows[0]) return regionQuery; 59 | return db.query(queries.insertRegion, [region]); 60 | }) 61 | // Search for location, insert if not found 62 | .then(regionQuery => { 63 | regionId = regionQuery.rows[0]._id; 64 | return db.query(queries.selectLocation, [location, regionId, countryId]); 65 | }) 66 | .then(locationQuery => { 67 | if (locationQuery.rows[0]) return locationQuery; 68 | return db.query(queries.insertLocation, [location, regionId, countryId]); 69 | }) 70 | // Search for route, insert if not found 71 | .then(locationQuery => { 72 | locationId = locationQuery.rows[0]._id; 73 | return db.query(queries.selectRoute, [route, typeId, difficultyId, locationId]); 74 | }) 75 | .then(routeQuery => { 76 | if (routeQuery.rows[0]) return routeQuery; 77 | return db.query(queries.insertRoute, [route, typeId, difficultyId, locationId]); 78 | }) 79 | // Insert entry 80 | .then(routeQuery => { 81 | routeId = routeQuery.rows[0]._id; 82 | return db.query(queries.insertEntry, [note, startDate, endDate, 1]); 83 | }) 84 | // Insert entry route 85 | .then(entryQuery => { 86 | entryId = entryQuery.rows[0]._id; 87 | return db.query(queries.insertEntryRoute, [entryId, routeId, rating]); 88 | }) 89 | // Commit the transaction 90 | .then(() => { 91 | return db.query('COMMIT') 92 | }) 93 | // Store the entry id 94 | .then(() => { 95 | res.locals.entryId = entryId; 96 | return next(); 97 | }) 98 | // Catch any errors from the transaction 99 | .catch(err => { 100 | db.query('ROLLBACK'); 101 | 102 | return next({ 103 | log: `ERROR - entryController.setEntries: ${err}`, 104 | message: {err: 'Failed to create entry in database. Check server log for details.'} 105 | }); 106 | }); 107 | 108 | } 109 | 110 | // Deletes an entry from database 111 | entryController.deleteEntry = (req, res, next) => { 112 | // Destructure entry ID for entry to delete 113 | const { entryId } = req.body; 114 | 115 | // Delete database records of the entry from entry_routes and the entry record from entries 116 | db.query('BEGIN') 117 | .then(() => db.query(queries.removeEntryRoutes, [entryId])) 118 | .then(() => db.query(queries.removeEntry, [entryId])) 119 | .then(() => db.query('COMMIT')) 120 | .then(() => next()) 121 | .catch((err) => { 122 | db.query('ROLLBACK'); 123 | 124 | return next({ 125 | log: `ERROR - entryController.deleteEntries: ${err}`, 126 | message: {err: 'Failed to delete entry in database. Check server log for details.'} 127 | }); 128 | }); 129 | } 130 | 131 | module.exports = entryController; 132 | -------------------------------------------------------------------------------- /server/controllers/form-controller.js: -------------------------------------------------------------------------------- 1 | const db = require('../models/model'); 2 | const queries = require('../models/form-queries'); 3 | 4 | const formController = {} 5 | 6 | // Queries database for form dropdown options 7 | formController.getFormOptions = (req, res, next) => { 8 | db.query(queries.selectActivities) 9 | .then(activities => { 10 | res.locals.formOptions = {}; 11 | res.locals.formOptions.activities = activities.rows; 12 | 13 | return db.query(queries.selectTypes); 14 | }) 15 | .then(types => { 16 | res.locals.formOptions.types = types.rows; 17 | 18 | return db.query(queries.selectDifficulties); 19 | }) 20 | .then(difficulties => { 21 | res.locals.formOptions.difficulties = difficulties.rows; 22 | 23 | return next(); 24 | }) 25 | .catch(err => { 26 | next({ 27 | log: `ERROR - formController.getFormOptions: ${err}`, 28 | message: {err: 'Failed to retrieve form options from database. Check server log for details.'} 29 | }); 30 | }); 31 | } 32 | 33 | module.exports = formController -------------------------------------------------------------------------------- /server/models/entry-queries.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // select all from all tables, ordered by entry PK 3 | selectAll: ` 4 | SELECT 5 | entry_routes._id AS entry_routes_id, 6 | entry_routes.entry_id, 7 | entries.start_date, 8 | entries.end_date, 9 | types.activity_id, 10 | activities.activity, 11 | routes.type_id, 12 | types.type, 13 | types.convention, 14 | entry_routes.route_id, 15 | routes.route, 16 | routes.difficulty_id, 17 | difficulties.difficulty, 18 | routes.location_id, 19 | locations.location, 20 | locations.region_id, 21 | regions.region, 22 | locations.country_id, 23 | countries.country, 24 | entry_routes.rating, 25 | entries.note, 26 | entries.post_date 27 | 28 | FROM entry_routes 29 | INNER JOIN entries ON entries._id = entry_routes.entry_id 30 | INNER JOIN routes ON routes._id = entry_routes.route_id 31 | INNER JOIN types ON types._id = routes.type_id 32 | INNER JOIN activities ON activities._id = types.activity_id 33 | INNER JOIN locations ON locations._id = routes.location_id 34 | INNER JOIN regions ON regions._id = locations.region_id 35 | INNER JOIN countries ON countries._id = locations.country_id 36 | INNER JOIN difficulties ON difficulties._id = routes.difficulty_id 37 | `, 38 | // select a country 39 | selectCountry: ` 40 | SELECT * 41 | FROM countries 42 | 43 | WHERE country = ($1) 44 | `, 45 | // select a region 46 | selectRegion: ` 47 | SELECT * 48 | FROM regions 49 | 50 | WHERE region = ($1) 51 | `, 52 | // select a location 53 | selectLocation: ` 54 | SELECT * 55 | FROM locations 56 | 57 | WHERE location = ($1) 58 | AND region_id = ($2) 59 | AND country_id = ($3) 60 | `, 61 | // select a route 62 | selectRoute: ` 63 | SELECT * 64 | FROM routes 65 | 66 | WHERE route = ($1) 67 | AND type_id = ($2) 68 | AND difficulty_id = ($3) 69 | AND location_id = ($4) 70 | `, 71 | // insert a country 72 | insertCountry: ` 73 | INSERT INTO countries (country) 74 | VALUES ($1) 75 | 76 | RETURNING _id 77 | `, 78 | // insert a region 79 | insertRegion: ` 80 | INSERT INTO regions (region) 81 | VALUES ($1) 82 | 83 | RETURNING _id 84 | `, 85 | // insert a location 86 | insertLocation: ` 87 | INSERT INTO locations (location, region_id, country_id) 88 | VALUES ($1, $2, $3) 89 | 90 | RETURNING _id 91 | `, 92 | // insert a route 93 | insertRoute: ` 94 | INSERT INTO routes (route, type_id, difficulty_id, location_id) 95 | VALUES ($1, $2, $3, $4) 96 | 97 | RETURNING _id 98 | `, 99 | // insert an entry 100 | insertEntry: ` 101 | INSERT INTO entries (note, start_date, end_date, user_id) 102 | VALUES ($1, $2, $3, $4) 103 | 104 | RETURNING _id 105 | `, 106 | // insert an entry route 107 | insertEntryRoute: ` 108 | INSERT INTO entry_routes (entry_id, route_id, rating) 109 | VALUES ($1, $2, $3) 110 | `, 111 | // remove entry routes 112 | removeEntryRoutes:` 113 | DELETE FROM entry_routes 114 | WHERE entry_routes.entry_id = ($1) 115 | `, 116 | // remove entry 117 | removeEntry:` 118 | DELETE FROM entries 119 | WHERE entries._id = ($1) 120 | ` 121 | } -------------------------------------------------------------------------------- /server/models/form-queries.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | selectActivities:` 3 | SELECT * 4 | FROM activities 5 | `, 6 | 7 | selectTypes:` 8 | SELECT * 9 | FROM types 10 | `, 11 | 12 | selectDifficulties:` 13 | SELECT * 14 | FROM difficulties 15 | ` 16 | } -------------------------------------------------------------------------------- /server/models/model.js: -------------------------------------------------------------------------------- 1 | // connect to db and export pool 2 | const { Pool } = require('pg'); 3 | 4 | // const PG_URI = [insert postgresql uri] 5 | 6 | const pool = new Pool({ 7 | connectionString: PG_URI 8 | }); 9 | 10 | module.exports = { 11 | query: (text, params, callback) => { 12 | console.log('--QUERY EXECUTED--\n', text, '\n'); 13 | return pool.query(text, params, callback); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | 4 | const app = express(); 5 | const PORT = 3000; 6 | 7 | const entryController = require('./controllers/entry-controller'); 8 | const formController = require('./controllers/form-controller'); 9 | 10 | // parse all request bodies 11 | app.use(express.json()); 12 | 13 | // Get all entries route 14 | app.get('/entries', 15 | entryController.getEntries, 16 | (req, res) => { 17 | return res.status(200).json(res.locals.entries); 18 | }); 19 | 20 | // Create new entry route 21 | app.post('/entries', 22 | entryController.setEntry, 23 | entryController.getEntries, 24 | (req, res) => { 25 | return res.status(201).json(res.locals.entries); 26 | }); 27 | 28 | // Update entry route 29 | app.put('/entries', 30 | entryController.deleteEntry, 31 | entryController.setEntry, 32 | entryController.getEntries, 33 | (req, res) => { 34 | return res.status(200).json(res.locals.entries); 35 | }); 36 | 37 | // Delete entry route 38 | app.delete('/entries', 39 | entryController.deleteEntry, 40 | (req, res) => { 41 | return res.sendStatus(200); 42 | }); 43 | 44 | // Gets form values route 45 | app.get('/form', 46 | formController.getFormOptions, 47 | (req, res) => { 48 | return res.status(200).json(res.locals.formOptions) 49 | }) 50 | 51 | // Serve app and static files for production environments 52 | if (process.env.NODE_ENV === 'production') { 53 | app.use('/client', express.static(path.resolve(__dirname, '..', 'client'))); 54 | app.get('/', (req, res) => res.status(200).sendFile(path.resolve(__dirname, '..', 'client', 'index.html'))); 55 | } 56 | 57 | // Catch-all route 58 | app.use ('/', (req,res, next) =>{ 59 | return next({ 60 | log: 'Express catch all handler caught unknown route', 61 | status: 404, 62 | message: {err: 'Route not found'} 63 | }); 64 | }); 65 | 66 | // Default global error object & global error handler 67 | const defaultErr = { 68 | log: 'Express error handler caught an unknown middleware error', 69 | status: 400, 70 | message: { err: 'An error occurred' }, 71 | }; 72 | 73 | app.use((err, req, res, next) => { 74 | const errorObj = Object.assign(defaultErr, err); 75 | console.log(errorObj.log); 76 | return res.status(errorObj.status).json(errorObj.message); 77 | }); 78 | 79 | // Start server 80 | app.listen(PORT, () => { 81 | console.log(`Server listening on port: ${PORT}`); 82 | }); 83 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: process.env.NODE_ENV, 6 | entry: { 7 | src: path.join(__dirname, 'client', 'index.js') 8 | }, 9 | output: { 10 | filename: 'bundle.js', 11 | path: path.join(__dirname, 'client', 'build') 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx?/, 17 | exclude: /node_modules/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: ['@babel/preset-env', '@babel/preset-react'] 22 | } 23 | } 24 | }, 25 | ] 26 | }, 27 | plugins: [ 28 | new HtmlWebpackPlugin({ 29 | template: path.join(__dirname, 'client', 'index.html') 30 | }) 31 | ], 32 | devServer: { 33 | static: { 34 | directory: path.resolve(__dirname, 'client') 35 | }, 36 | proxy: { 37 | '/entries': 'http://localhost:3000', 38 | '/form': 'http://localhost:3000' 39 | } 40 | } 41 | } 42 | --------------------------------------------------------------------------------