diff --git a/public/Hacker News Clone/css/nav.css b/public/Hacker News Clone/css/nav.css new file mode 100644 index 0000000..7f9ec27 --- /dev/null +++ b/public/Hacker News Clone/css/nav.css @@ -0,0 +1,24 @@ +/* Navigation bar */ + +nav { + display: flex; + background-color: #ff6600; + align-items: center; + padding: 25px 20px; + border-radius: 3px 3px 0 0; +} + +.navbar-brand { + font-weight: bold; + margin: 0 1em; +} + +.nav-link { + font-size: 0.85rem; + margin: 0 3px; +} + +.nav-right { + margin-left: auto; + text-align: right; +} diff --git a/public/Hacker News Clone/css/site.css b/public/Hacker News Clone/css/site.css new file mode 100644 index 0000000..ee5b408 --- /dev/null +++ b/public/Hacker News Clone/css/site.css @@ -0,0 +1,128 @@ +/* General typography */ + +html{ + margin: 0; +} + +body { + font-family: Arimo, sans-serif; + margin: 8vh 7.5vw; + background: radial-gradient(orchid, #95bae8); +} + +h1 { + font-size: 1.1rem; + margin: 0; +} + +h4 { + font-size: 1rem; + margin: 0; +} + +h5 { + font-size: 0.9rem; + font-weight: lighter; +} + +a { + text-decoration: none; + color: inherit; +} + +a:hover { + text-decoration: underline; +} + + +/* Site layout */ + +/* This is the basic box that the main part of the page goes into */ +.container { + display: flex; + flex-direction: column; + align-self: center; + background-color: #f6f6ef; +} + +.hidden { + display: none; +} + + +/* Forms */ + +form { + display: flex; + flex-direction: column; + margin: 8px 18px 0; +} + +form > * { + margin: 10px 0; +} + +form label { + font-size: 0.9rem; + font-weight: 700; + display: inline-block; + width: 3.5rem; + text-align: right; + margin-right: 5px; +} + +form input { + font-size: 0.8rem; + border: none; + border-radius: 2px; + padding: 8px; + width: 300px; + box-shadow: 0 0 3px 1px lightgray; +} + +form input:focus { + outline: none; + box-shadow: 0 0 4px 1px darkgray; +} + +form > button { + width: 4rem; + margin: 5px 0 15px 65px; + border: none; + border-radius: 4px; + padding: 8px; + font-size: 0.85rem; + background-color: lightslategray; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +form > button:hover { + background-color: dimgray; +} + +form > hr { + margin: 0; + border: 1px solid lightgray; +} + +.login-input label { + width: 70px; +} + + +/* responsive queries for tightening things up for mobile. */ + +@media screen and (max-width: 576px) { + body { + margin: 0; + } +} + +@media screen and (min-width: 992px) { + body { + max-width: 900px; + margin: 8px auto; + } +} diff --git a/public/Hacker News Clone/css/stories.css b/public/Hacker News Clone/css/stories.css new file mode 100644 index 0000000..d93a1cc --- /dev/null +++ b/public/Hacker News Clone/css/stories.css @@ -0,0 +1,48 @@ +/* Lists of stories */ + +.stories-list { + margin: 20px 5px; +} + +.stories-list > li { + color: gray; + font-size: 0.8rem; + margin: 10px 0; + border-bottom: 1px solid lightgray; +} + +#stories-loading-msg { + font-weight: bold; + font-size: 150%; + margin: 20px 30px; +} + + +/* Individual stories */ + +.story-link { + color: black; + text-transform:capitalize; + font-size: 0.85rem; + font-weight: bold; + margin: 18px 0; +} + +.story-link:hover { + text-decoration: none; + color: #444; +} + +.story-author { + display:block; + text-transform:capitalize; + margin:.5em 2.5em; + color: green; +} + +.story-user { + display: block; + text-transform:capitalize; + margin:.5em 2.5em; + color: darkorange; +} diff --git a/public/Hacker News Clone/css/user.css b/public/Hacker News Clone/css/user.css new file mode 100644 index 0000000..f69c701 --- /dev/null +++ b/public/Hacker News Clone/css/user.css @@ -0,0 +1,14 @@ +/* Login and signup forms */ + +.account-form button { + width: 4rem; + margin-left: 80px; +} + +#signup-form button { + width: 8rem; +} + +.account-forms-container { + padding-left: 20px; +} diff --git a/public/Hacker News Clone/hackerhoodie.png b/public/Hacker News Clone/hackerhoodie.png new file mode 100644 index 0000000..9f1ce1a Binary files /dev/null and b/public/Hacker News Clone/hackerhoodie.png differ diff --git a/public/Hacker News Clone/index.html b/public/Hacker News Clone/index.html new file mode 100644 index 0000000..98f7df5 --- /dev/null +++ b/public/Hacker News Clone/index.html @@ -0,0 +1,138 @@ + + + + + + + + + + + + Hack or Snooze + + + + + + + + + + + + + +
+ + +
Loading…
+ + +
    + +
    + + +
    + + + + + + + + + + +
    + + + + + + + + + + + + + diff --git a/public/Hacker News Clone/js/main.js b/public/Hacker News Clone/js/main.js new file mode 100644 index 0000000..77f0306 --- /dev/null +++ b/public/Hacker News Clone/js/main.js @@ -0,0 +1,56 @@ +"use strict"; + +// So we don't have to keep re-finding things on page, find DOM elements once: + +const $body = $("body"); + +const $storiesLoadingMsg = $("#stories-loading-msg"); +const $allStoriesList = $("#all-stories-list"); + +const $loginForm = $("#login-form"); +const $signupForm = $("#signup-form"); +const $submissionForm = $("#submit-form"); + +const $navLogin = $("#nav-login"); +const $navUserProfile = $("#nav-user-profile"); +const $navLogOut = $("#nav-logout"); +const $navSubmit = $("#nav-submit"); +const $navFavorites = $("#nav-favorites"); +const $navOwned = $("#nav-owned"); + +/** To make it easier for individual components to show just themselves, this + * is a useful function that hides pretty much everything on the page. After + * calling this, individual components can re-show just what they want. + */ + +function hidePageComponents() { + const components = [ + $allStoriesList, + $loginForm, + $signupForm, + $submissionForm, + ]; + components.forEach(c => c.hide()); +} + +/** Overall function to kick off the app. */ + +async function start() { + console.debug("start"); + + // "Remember logged-in user" and log in, if credentials in localStorage + await checkForRememberedUser(); + await getAndShowStoriesOnStart(); + + // if we got a logged-in user + if (currentUser) updateUIOnUserLogin(); +} + +// Once the DOM is entirely loaded, begin the app + +console.warn("HEY STUDENT: This program sends many debug messages to" + + " the console. If you don't see the message 'start' below this, you're not" + + " seeing those helpful debug messages. In your browser console, click on" + + " menu 'Default Levels' and add Verbose"); +$(start); + diff --git a/public/Hacker News Clone/js/models.js b/public/Hacker News Clone/js/models.js new file mode 100644 index 0000000..a5a8c4d --- /dev/null +++ b/public/Hacker News Clone/js/models.js @@ -0,0 +1,261 @@ +"use strict"; + +const BASE_URL = "https://hack-or-snooze-v3.herokuapp.com"; + +/****************************************************************************** + * Story: a single story in the system + */ + +class Story { + + /** Make instance of Story from data object about story: + * - {title, author, url, username, storyId, createdAt} + */ + + constructor({ storyId, title, author, url, username, createdAt }) { + this.storyId = storyId; + this.title = title; + this.author = author; + this.url = url; + this.username = username; + this.createdAt = createdAt; + } + + /** Parses hostname out of URL and returns it. */ + + getHostName() { + //It works. Trust me. + return this.url.match(/(\w+\.\w+)(:\w+)?(?=(\/)|$)/i)[0]; + } +} + + +/****************************************************************************** + * List of Story instances: used by UI to show story lists in DOM. + */ + +class StoryList { + constructor(stories) { + this.stories = stories; + } + + /** Generate a new StoryList. It: + * + * - calls the API + * - builds an array of Story instances + * - makes a single StoryList instance out of that + * - returns the StoryList instance. + */ + + static async getStories() { + + // query the /stories endpoint (no auth required) + const response = await axios({ + url: `${BASE_URL}/stories`, + method: "GET", + }); + + // turn plain old story objects from API into instances of Story class + const stories = response.data.stories.map(story => new Story(story)); + + // build an instance of our own class using the new array of stories + return new StoryList(stories); + } + + /** get one SINGULAR story from API using a story ID */ + + static async getStory(id){ + return axios({ + url: `${BASE_URL}/stories/${id}`, + method: 'get', + }) + } + + /** Adds story data to API, makes a Story instance, adds it to story list. + * - user - the current instance of User who will post the story + * - obj of {title, author, url} + * + * Returns the new Story instance + */ + + static async addStory( user, {author, title, url}) { + const token = user.loginToken; + + const config = { + url: `${BASE_URL}/stories`, + method: 'post', + data: {token, + story: {author, title, url}, + } + } + + const response = await axios(config); + const addedStory = new Story(response.data.story); + + + storyList.stories.unshift(addedStory); + user.ownStories.unshift(addedStory); + + return addedStory; + } + + /** Sends story data to API for deletion, makes a Story instance, removes it from the story list. + * - user - the current instance of User who will delete the story. + * - id - the id of the story for deletion. + * + * Returns the new Story instance + */ + + static async delStory(user, id){ + const token = user.loginToken; + + const config = { + url: `${BASE_URL}/stories/${id}`, + method: 'delete', + data: {token}, + } + + const response = await axios(config); + const removedStory = new Story(response.data.story); + + storyList.stories = storyList.stories.filter(e=> e.storyId !== id); + user.ownStories = user.ownStories.filter(e=> e.storyId !== id); + + return removedStory; + } +} + + +/****************************************************************************** + * User: a user in the system (only used to represent the current user) + */ + +class User { + /** Make user instance from obj of user data and a token: + * - {username, name, createdAt, favorites[], ownStories[]} + * - token + */ + + constructor({ + username, + name, + createdAt, + favorites = [], + ownStories = [] + }, + token) { + this.username = username; + this.name = name; + this.createdAt = createdAt; + + // instantiate Story instances for the user's favorites and ownStories + this.favorites = favorites.map(s => new Story(s)); + this.ownStories = ownStories.map(s => new Story(s)); + + // store the login token on the user so it's easy to find for API calls. + this.loginToken = token; + } + + /** Register new user in API, make User instance & return it. + * + * - username: a new username + * - password: a new password + * - name: the user's full name + */ + + static async signup(username, password, name) { + const response = await axios({ + url: `${BASE_URL}/signup`, + method: "POST", + data: { user: { username, password, name } }, + }); + + let { user } = response.data + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + response.data.token + ); + } + + /** Login in user with API, make User instance & return it. + + * - username: an existing user's username + * - password: an existing user's password + */ + + static async login(username, password) { + const response = await axios({ + url: `${BASE_URL}/login`, + method: "POST", + data: { user: { username, password } }, + }); + + let { user } = response.data; + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + response.data.token + ); + } + + /** When we already have credentials (token & username) for a user, + * we can log them in automatically. This function does that. + */ + + static async loginViaStoredCredentials(token, username) { + try { + const response = await axios({ + url: `${BASE_URL}/users/${username}`, + method: "GET", + params: { token }, + }); + + let { user } = response.data; + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + token + ); + } catch (err) { + console.error("loginViaStoredCredentials failed", err); + return null; + } + } + + /** Get/set/delete favorited stories of a user. + * -method: string specifying the API method. + * -user: User Object. + * -storyid: string of the story id. + */ + + static async doFavorite(method, user, storyid){ + const username = user.username; + const token = user.loginToken; + return axios({ + url: `${BASE_URL}/users/${username}/favorites/${storyid}`, + method: method, + data:{ + token, + }, + }); + } +} diff --git a/public/Hacker News Clone/js/nav.js b/public/Hacker News Clone/js/nav.js new file mode 100644 index 0000000..ec9c4d5 --- /dev/null +++ b/public/Hacker News Clone/js/nav.js @@ -0,0 +1,87 @@ +"use strict"; + +/****************************************************************************** + * Handling navbar clicks and updating navbar + */ + +/** Show main list of all stories when click site name */ + +function navAllStories(evt) { + console.debug("navAllStories", evt); + hidePageComponents(); + putStoriesOnPage(); +} + +$body.on("click", "#nav-all", navAllStories); + +/** Show login/signup on click on "login" */ + +function navLoginClick(evt) { + console.debug("navLoginClick", evt); + hidePageComponents(); + $loginForm.show(); + $signupForm.show(); +} + +$navLogin.on("click", navLoginClick); + +/** Show submission form on click "Submit" */ + +function navSubmitClick(evt) { + console.debug("navSubmitClick", evt); + hidePageComponents(); + $submissionForm.show(); +} + +$navSubmit.on("click", navSubmitClick); + +/** When a User clicks Favorites, clears $allStoriesList and renders favorites only. */ + +async function showFavoriteStories(evt) { + console.debug("showFavorites", evt) + const favorites = await currentUser.favorites.map(e=> new Story(e)); + const favoriteMarkupsArr = favorites.map(e=> generateStoryMarkup(e)); + + //clear $allStoriesList and append favorites to the page. + $allStoriesList.empty() + favoriteMarkupsArr.map(story => { + $allStoriesList.append(story) + }); + + $("input.favorite-box").prop("checked", true); +} + +$navFavorites.on("click", showFavoriteStories); + +/** When "owned" is clicked in the navbar, it shows the user all stories that they have created.*/ + +async function showOwnedStories(evt) { + console.debug("showOwned", evt) + const ownedStories = await currentUser.ownStories.map(e=> new Story(e)); + const ownedStoriesMarkup = ownedStories.map(e=> generateStoryMarkup(e)); + + //clear $allStoriesList and append favorites to the page. + $allStoriesList.empty() + ownedStoriesMarkup.map(story => { + $allStoriesList.append(story) + }); + + $(".story-author").after(""); + $("input.favorite-box").remove(); + $(".delete-button").on("click", removeOwnedStories); +} + +$navOwned.on("click", showOwnedStories); + +/** When a user first logins in, update the navbar to reflect that. */ + +function updateNavOnLogin() { + console.debug("updateNavOnLogin"); + $(".main-nav-links").show(); + $navLogin.hide(); + $navLogOut.show(); + $navSubmit.show(); + $navFavorites.show(); + $navOwned.show(); + $navUserProfile.text(`${currentUser.username}`).show(); +} diff --git a/public/Hacker News Clone/js/stories.js b/public/Hacker News Clone/js/stories.js new file mode 100644 index 0000000..18cbe0b --- /dev/null +++ b/public/Hacker News Clone/js/stories.js @@ -0,0 +1,151 @@ +"use strict"; + +// This is the global list of the stories, an instance of StoryList +let storyList; + +/** Get and show stories when site first loads. */ + +async function getAndShowStoriesOnStart() { + storyList = await StoryList.getStories(); + $storiesLoadingMsg.remove(); + + putStoriesOnPage(); +} + +/* A render method to render HTML for an individual Story instance + * - story: an instance of Story + * + * Returns the markup for the story. + */ + +function generateStoryMarkup(story) { + + const hostName = story.getHostName(); + return $(` +
  1. + + + ${story.title} + + (${hostName}) + by ${story.author} + posted by ${story.username} +
  2. + `); +} + +/** Sets or removes favorites depending on the event. */ + +async function modifyFavoriteStories(evt){ + let response; + + if (evt.target.checked){ + try{ + response = + await User.doFavorite( + 'post', + currentUser, + evt.target.parentElement.id + ); + } + catch{ + $('nav').after("

    Couldn't add Favorite. Please, try again.

    ") + setTimeout(()=> $("p.error-msg").remove(), 5000); + } + } + else{ + try{ + response = + await User.doFavorite( + 'delete', + currentUser, + evt.target.parentElement.id + ) + } + catch{ + $('nav').after("

    Couldn't remove Favorite. Please, try again.

    ") + setTimeout(()=> $("p.error-msg").remove(), 5000); + } + } + const favoritesArrResponse = response.data.user.favorites + currentUser.favorites = favoritesArrResponse; + console.debug("favorites modified", currentUser.favorites); + +} + +/** Gets list of stories from server, generates their HTML, and puts on page. + * - default param is $allStoriesList */ + +function putStoriesOnPage() { + console.debug("putStoriesOnPage"); + + $allStoriesList.empty(); + + // loop through all of our stories and generate HTML for them + for (let story of storyList.stories) { + const $story = generateStoryMarkup(story); + $allStoriesList.append($story); + + if (currentUser) { + markFavoriteStories(story); + } + } + + $allStoriesList.show(); + + $("input.favorite-box").on("change", modifyFavoriteStories); +} + +/** Takes user input and sends it to backend. */ + +async function submitStoriesToAPI(evt){ + console.debug("submit story", evt); + evt.preventDefault(); + + //grab title, url, and author + const $title = $("#submit-title"); + const $url = $("#submit-url"); + const $author = $("#submit-author"); + + try{ + await StoryList.addStory( + currentUser, { + author: $author.val(), + title: $title.val(), + url: $url.val(), + }) + } + catch({name, message}){ + $submissionForm.after(`

    Unssuccesful Connection to database. Try Again. | Error: ${message} |`) + setTimeout(()=> $("p#error-msg").remove(), 5000); + return; + } + + $submissionForm.trigger("reset"); + $submissionForm.hide(); + + putStoriesOnPage(); +} + +$submissionForm.on("submit", submitStoriesToAPI); + +/** Checks stories to determine if any of them are favorites. + * Marks them if necessary. */ + +function markFavoriteStories(story){ + const storyId = story.storyId; + const favoritesIds = currentUser.favorites.map(f=> f.storyId); + + if(favoritesIds.includes(storyId)) $(`#${storyId}`).children("input").prop("checked", true); +} + +/** handles event for removing stories */ +async function removeOwnedStories(evt){ + console.debug("remove story", evt); + const storyId = evt.target.parentElement.id; + + const removedStory = await StoryList.delStory(currentUser, storyId); + + $(`#${removedStory.storyId}`).remove(); + +} diff --git a/public/Hacker News Clone/js/user.js b/public/Hacker News Clone/js/user.js new file mode 100644 index 0000000..4255947 --- /dev/null +++ b/public/Hacker News Clone/js/user.js @@ -0,0 +1,130 @@ +"use strict"; + +// global to hold the User instance of the currently-logged-in user +let currentUser; + +/****************************************************************************** + * User login/signup/login + */ + +/** Handle login form submission. If login ok, sets up the user instance */ + +async function login(evt) { + console.debug("login", evt); + evt.preventDefault(); + + // grab the username and password + const username = $("#login-username").val(); + const password = $("#login-password").val(); + + // User.login retrieves user info from API and returns User instance + // which we'll make the globally-available, logged-in user. + currentUser = await User.login(username, password); + + $loginForm.trigger("reset"); + + saveUserCredentialsInLocalStorage(); + updateUIOnUserLogin(); + + $loginForm.hide(); + $signupForm.hide( + ); +} + +$loginForm.on("submit", login); + +/** Handle signup form submission. */ + +async function signup(evt) { + console.debug("signup", evt); + evt.preventDefault(); + + const name = $("#signup-name").val(); + const username = $("#signup-username").val(); + const password = $("#signup-password").val(); + + // User.signup retrieves user info from API and returns User instance + // which we'll make the globally-available, logged-in user. + try{ + currentUser = await User.signup(username, password, name); + } + catch{ + $signupForm.trigger("reset"); + $('nav').after("

    Unable to create user. Check your connection and perhaps try a different username.

    ") + setTimeout(()=> $("p.error-msg").remove(), 5000); + return; + } + + saveUserCredentialsInLocalStorage(); + updateUIOnUserLogin(); + + $signupForm.trigger("reset"); + $loginForm.hide(); + $signupForm.hide(); +} + +$signupForm.on("submit", signup); + +/** Handle click of logout button + * + * Remove their credentials from localStorage and refresh page + */ + +function logout(evt) { + console.debug("logout", evt); + localStorage.clear(); + location.reload(); +} + +$navLogOut.on("click", logout); + +/****************************************************************************** + * Storing/recalling previously-logged-in-user with localStorage + */ + +/** If there are user credentials in local storage, use those to log in + * that user. This is meant to be called on page load, just once. + */ + +async function checkForRememberedUser() { + console.debug("checkForRememberedUser"); + const token = localStorage.getItem("token"); + const username = localStorage.getItem("username"); + if (!token || !username) return false; + + // try to log in with these credentials (will be null if login failed) + currentUser = await User.loginViaStoredCredentials(token, username); +} + +/** Sync current user information to localStorage. + * + * We store the username/token in localStorage so when the page is refreshed + * (or the user revisits the site later), they will still be logged in. + */ + +function saveUserCredentialsInLocalStorage() { + console.debug("saveUserCredentialsInLocalStorage"); + if (currentUser) { + localStorage.setItem("token", currentUser.loginToken); + localStorage.setItem("username", currentUser.username); + } +} + +/****************************************************************************** + * General UI stuff about users + */ + +/** When a user signs up or registers, we want to set up the UI for them: + * + * - show the stories list + * - update nav bar options for logged-in user + * - generate the user profile part of the page + */ + +function updateUIOnUserLogin() { + console.debug("updateUIOnUserLogin"); + + $allStoriesList.show(); + + updateNavOnLogin(); +} diff --git a/public/Hacker News Clone/solution/css/nav.css b/public/Hacker News Clone/solution/css/nav.css new file mode 100755 index 0000000..96c898f --- /dev/null +++ b/public/Hacker News Clone/solution/css/nav.css @@ -0,0 +1,30 @@ +/* Navigation bar */ + +nav { + display: flex; + background-color: #ff6600; + align-items: center; + padding: 25px 20px; + border-radius: 3px 3px 0 0; +} + +.navbar-brand { + font-weight: bold; +} + +.main-nav-links { + margin: 0 10px; + font-size: 20px; + justify-content: center; + align-items: center; +} + +.nav-link { + font-size: 0.85rem; + margin: 0 3px; +} + +.nav-right { + margin-left: auto; + text-align: right; +} diff --git a/public/Hacker News Clone/solution/css/site.css b/public/Hacker News Clone/solution/css/site.css new file mode 100755 index 0000000..06bb4fc --- /dev/null +++ b/public/Hacker News Clone/solution/css/site.css @@ -0,0 +1,131 @@ +/* General typography */ + +body { + font-family: Arimo, sans-serif; + margin: 8px 7.5vw; + height: 100vh; + background-image: radial-gradient(circle, rgb(238, 174, 202) 0%, rgb(148, 187, 233) 100%); +} + +h1 { + font-size: 1.1rem; + margin: 0; +} + +h4 { + font-size: 1rem; + margin: 0; +} + +h5 { + font-size: 0.9rem; + font-weight: lighter; +} + +a { + text-decoration: none; + color: inherit; +} + +a:hover { + text-decoration: underline; +} + + +/* Site layout */ + +/* This is the basic box that the main part of the page goes into */ +.container { + display: flex; + flex-direction: column; + align-self: center; + background-color: #f6f6ef; + +} + +.stories-container{ + height: calc(100vh - 100px); + overflow: scroll; +} + +.hidden { + display: none; +} + + +/* Forms */ + +form { + display: flex; + flex-direction: column; + margin: 8px 18px 0; +} + +form > * { + margin: 10px 0; +} + +form label { + font-size: 0.9rem; + font-weight: 700; + display: inline-block; + width: 3.5rem; + text-align: right; + margin-right: 5px; +} + +form input { + font-size: 0.8rem; + border: none; + border-radius: 2px; + padding: 8px; + width: 300px; + box-shadow: 0 0 3px 1px lightgray; +} + +form input:focus { + outline: none; + box-shadow: 0 0 4px 1px darkgray; +} + +form > button { + width: 4rem; + margin: 5px 0 15px 65px; + border: none; + border-radius: 4px; + padding: 8px; + font-size: 0.85rem; + background-color: lightslategray; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +form > button:hover { + background-color: dimgray; +} + +form > hr { + margin: 0; + border: 1px solid lightgray; +} + +.login-input label { + width: 70px; +} + + +/* responsive queries for tightening things up for mobile. */ + +@media screen and (max-width: 576px) { + body { + margin: 0; + } +} + +@media screen and (min-width: 992px) { + body { + max-width: 900px; + margin: 8px auto; + } +} diff --git a/public/Hacker News Clone/solution/css/stories.css b/public/Hacker News Clone/solution/css/stories.css new file mode 100755 index 0000000..fee3ffb --- /dev/null +++ b/public/Hacker News Clone/solution/css/stories.css @@ -0,0 +1,64 @@ +/* Lists of stories */ + +.stories-list { + margin: 20px 5px; +} + +.stories-list > li { + color: gray; + font-size: 0.8rem; + margin: 10px 0; + border-bottom: 1px solid #e5e5e5; + padding-bottom: 10px; +} + +#favorited-stories, +#my-stories { + list-style: none; + padding-left: 20px; +} + +#stories-loading-msg { + font-weight: bold; + font-size: 150%; + margin: 20px 30px; +} + + +/* Individual stories */ + +.star, +.trash-can { + font-size: 0.75rem; + margin: 0 5px; + cursor: pointer; +} + +.trash-can:hover { + color: crimson; +} + +.story-link { + color: black; + font-size: 0.85rem; + font-weight: normal; + margin: 18px 0; +} + +.story-link:hover { + text-decoration: none; +} + +.story-author { + margin-left: 2em; + font-size: 0.85rem; + color: green; + padding: 6px 0; +} + +.story-user { + display: block; + margin-left: 2em; + font-size: 0.85rem; + color: orange; +} diff --git a/public/Hacker News Clone/solution/css/user.css b/public/Hacker News Clone/solution/css/user.css new file mode 100755 index 0000000..b382f00 --- /dev/null +++ b/public/Hacker News Clone/solution/css/user.css @@ -0,0 +1,26 @@ +/* Login and signup forms */ + +.account-form button { + width: 4rem; + margin-left: 80px; +} + +#signup-form button { + width: 8rem; +} + +.account-forms-container { + padding-left: 20px; +} + + +/* User profile */ + +.user-profile-box { + padding: 10px 20px 20px; + font-size: 0.9rem; +} + +.user-profile-box > * { + margin: 10px 0; +} diff --git a/public/Hacker News Clone/solution/index.html b/public/Hacker News Clone/solution/index.html new file mode 100755 index 0000000..0d4e495 --- /dev/null +++ b/public/Hacker News Clone/solution/index.html @@ -0,0 +1,154 @@ + + + + + + + + + + + Hack or Snooze + + + + + + + + + + + + + +
    + + + + + +
    Loading…
    + + +
      + + + + + + + +
      + + +
      + + + + + + +
      + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/Hacker News Clone/solution/js/main.js b/public/Hacker News Clone/solution/js/main.js new file mode 100755 index 0000000..72e92e9 --- /dev/null +++ b/public/Hacker News Clone/solution/js/main.js @@ -0,0 +1,64 @@ +"use strict"; + +// So we don't have to keep re-finding things on page, find DOM elements once: + +const $body = $("body"); + +const $storiesLoadingMsg = $("#stories-loading-msg"); +const $allStoriesList = $("#all-stories-list"); +const $favoritedStories = $("#favorited-stories"); +const $ownStories = $("#my-stories"); +const $storiesContainer = $("#stories-container") + + +// selector that finds all three story lists +const $storiesLists = $(".stories-list"); + +const $loginForm = $("#login-form"); +const $signupForm = $("#signup-form"); + +const $submitForm = $("#submit-form"); + +const $navSubmitStory = $("#nav-submit-story"); +const $navLogin = $("#nav-login"); +const $navUserProfile = $("#nav-user-profile"); +const $navLogOut = $("#nav-logout"); + +const $userProfile = $("#user-profile"); + +/** To make it easier for individual components to show just themselves, this + * is a useful function that hides pretty much everything on the page. After + * calling this, individual components can re-show just what they want. + */ + +function hidePageComponents() { + const components = [ + $storiesLists, + $submitForm, + $loginForm, + $signupForm, + $userProfile + ]; + components.forEach(c => c.hide()); +} + +/** Overall function to kick off the app. */ + +async function start() { + console.debug("start"); + + // "Remember logged-in user" and log in, if credentials in localStorage + await checkForRememberedUser(); + await getAndShowStoriesOnStart(); + + // if we got a logged-in user + if (currentUser) updateUIOnUserLogin(); +} + +// Once the DOM is entirely loaded, begin the app + +console.warn("HEY STUDENT: This program sends many debug messages to" + + " the console. If you don't see the message 'start' below this, you're not" + + " seeing those helpful debug messages. In your browser console, click on" + + " menu 'Default Levels' and add Verbose"); +$(start); diff --git a/public/Hacker News Clone/solution/js/models.js b/public/Hacker News Clone/solution/js/models.js new file mode 100755 index 0000000..097e29c --- /dev/null +++ b/public/Hacker News Clone/solution/js/models.js @@ -0,0 +1,267 @@ +"use strict"; + +const BASE_URL = "https://hack-or-snooze-v3.herokuapp.com"; + +/****************************************************************************** + * Story: a single story in the system + */ + +class Story { + + /** Make instance of Story from data object about story: + * - {storyId, title, author, url, username, createdAt} + */ + + constructor({ storyId, title, author, url, username, createdAt }) { + this.storyId = storyId; + this.title = title; + this.author = author; + this.url = url; + this.username = username; + this.createdAt = createdAt; + } + + /** Parses hostname out of URL and returns it. */ + + getHostName() { + return new URL(this.url).host; + } +} + + +/****************************************************************************** + * List of Story instances: used by UI to show story lists in DOM. + */ + +class StoryList { + constructor(stories) { + this.stories = stories; + } + + /** Generate a new StoryList. It: + * + * - calls the API + * - builds an array of Story instances + * - makes a single StoryList instance out of that + * - returns the StoryList instance. + */ + + static async getStories() { + // Note presence of `static` keyword: this indicates that getStories is + // **not** an instance method. Rather, it is a method that is called on the + // class directly. Why doesn't it make sense for getStories to be an + // instance method? + + // query the /stories endpoint (no auth required) + const response = await axios({ + url: `${BASE_URL}/stories`, + method: "GET", + }); + + // turn plain old story objects from API into instances of Story class + const stories = response.data.stories.map(story => new Story(story)); + + // build an instance of our own class using the new array of stories + return new StoryList(stories); + } + + /** Adds story data to API, makes a Story instance, adds it to story list. + * - user - the current instance of User who will post the story + * - obj of {title, author, url} + * + * Returns the new Story instance + */ + + async addStory(user, { title, author, url }) { + const token = user.loginToken; + const response = await axios({ + method: "POST", + url: `${BASE_URL}/stories`, + data: { token, story: { title, author, url } }, + }); + + const story = new Story(response.data.story); + this.stories.unshift(story); + user.ownStories.unshift(story); + + return story; + } + + /** Delete story from API and remove from the story lists. + * + * - user: the current User instance + * - storyId: the ID of the story you want to remove + */ + + async removeStory(user, storyId) { + const token = user.loginToken; + await axios({ + url: `${BASE_URL}/stories/${storyId}`, + method: "DELETE", + data: { token: user.loginToken } + }); + + // filter out the story whose ID we are removing + this.stories = this.stories.filter(story => story.storyId !== storyId); + + // do the same thing for the user's list of stories & their favorites + user.ownStories = user.ownStories.filter(s => s.storyId !== storyId); + user.favorites = user.favorites.filter(s => s.storyId !== storyId); + } +} + + +/****************************************************************************** + * User: a user in the system (only used to represent the current user) + */ + +class User { + /** Make user instance from obj of user data and a token: + * - {username, name, createdAt, favorites[], ownStories[]} + * - token + */ + + constructor({ + username, + name, + createdAt, + favorites = [], + ownStories = [] + }, + token) { + this.username = username; + this.name = name; + this.createdAt = createdAt; + + // instantiate Story instances for the user's favorites and ownStories + this.favorites = favorites.map(s => new Story(s)); + this.ownStories = ownStories.map(s => new Story(s)); + + // store the login token on the user so it's easy to find for API calls. + this.loginToken = token; + } + + /** Register new user in API, make User instance & return it. + * + * - username: a new username + * - password: a new password + * - name: the user's full name + */ + + static async signup(username, password, name) { + const response = await axios({ + url: `${BASE_URL}/signup`, + method: "POST", + data: { user: { username, password, name } }, + }); + + let { user } = response.data; + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + response.data.token + ); + } + + /** Login in user with API, make User instance & return it. + + * - username: an existing user's username + * - password: an existing user's password + */ + + static async login(username, password) { + const response = await axios({ + url: `${BASE_URL}/login`, + method: "POST", + data: { user: { username, password } }, + }); + + let { user } = response.data; + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + response.data.token + ); + } + + /** When we already have credentials (token & username) for a user, + * we can log them in automatically. This function does that. + */ + + static async loginViaStoredCredentials(token, username) { + try { + const response = await axios({ + url: `${BASE_URL}/users/${username}`, + method: "GET", + params: { token }, + }); + + let { user } = response.data; + + return new User( + { + username: user.username, + name: user.name, + createdAt: user.createdAt, + favorites: user.favorites, + ownStories: user.stories + }, + token + ); + } catch (err) { + console.error("loginViaStoredCredentials failed", err); + return null; + } + } + + /** Add a story to the list of user favorites and update the API + * - story: a Story instance to add to favorites + */ + + async addFavorite(story) { + this.favorites.push(story); + await this._addOrRemoveFavorite("add", story) + } + + /** Remove a story to the list of user favorites and update the API + * - story: the Story instance to remove from favorites + */ + + async removeFavorite(story) { + this.favorites = this.favorites.filter(s => s.storyId !== story.storyId); + await this._addOrRemoveFavorite("remove", story); + } + + /** Update API with favorite/not-favorite. + * - newState: "add" or "remove" + * - story: Story instance to make favorite / not favorite + * */ + + async _addOrRemoveFavorite(newState, story) { + const method = newState === "add" ? "POST" : "DELETE"; + const token = this.loginToken; + await axios({ + url: `${BASE_URL}/users/${this.username}/favorites/${story.storyId}`, + method: method, + data: { token }, + }); + } + + /** Return true/false if given Story instance is a favorite of this user. */ + + isFavorite(story) { + return this.favorites.some(s => (s.storyId === story.storyId)); + } +} diff --git a/public/Hacker News Clone/solution/js/nav.js b/public/Hacker News Clone/solution/js/nav.js new file mode 100755 index 0000000..4ce352e --- /dev/null +++ b/public/Hacker News Clone/solution/js/nav.js @@ -0,0 +1,79 @@ +"use strict"; + +/****************************************************************************** + * Handling navbar clicks and updating navbar + */ + +/** Show main list of all stories when click site name */ + +function navAllStories(evt) { + console.debug("navAllStories", evt); + hidePageComponents(); + putStoriesOnPage(); +} + +$body.on("click", "#nav-all", navAllStories); + +/** Show story submit form on clicking story "submit" */ + +function navSubmitStoryClick(evt) { + console.debug("navSubmitStoryClick", evt); + hidePageComponents(); + $allStoriesList.show(); + $submitForm.show(); +} + +$navSubmitStory.on("click", navSubmitStoryClick); + +/** Show favorite stories on click on "favorites" */ + +function navFavoritesClick(evt) { + console.debug("navFavoritesClick", evt); + hidePageComponents(); + putFavoritesListOnPage(); +} + +$body.on("click", "#nav-favorites", navFavoritesClick); + +/** Show My Stories on clicking "my stories" */ + +function navMyStories(evt) { + console.debug("navMyStories", evt); + hidePageComponents(); + putUserStoriesOnPage(); + $ownStories.show(); +} + +$body.on("click", "#nav-my-stories", navMyStories); + +/** Show login/signup on click on "login" */ + +function navLoginClick(evt) { + console.debug("navLoginClick", evt); + hidePageComponents(); + $loginForm.show(); + $signupForm.show(); + $storiesContainer.hide() +} + +$navLogin.on("click", navLoginClick); + +/** Hide everything but profile on click on "profile" */ + +function navProfileClick(evt) { + console.debug("navProfileClick", evt); + hidePageComponents(); + $userProfile.show(); +} + +$navUserProfile.on("click", navProfileClick); + +/** When a user first logins in, update the navbar to reflect that. */ + +function updateNavOnLogin() { + console.debug("updateNavOnLogin"); + $(".main-nav-links").css('display', 'flex');; + $navLogin.hide(); + $navLogOut.show(); + $navUserProfile.text(`${currentUser.username}`).show(); +} diff --git a/public/Hacker News Clone/solution/js/stories.js b/public/Hacker News Clone/solution/js/stories.js new file mode 100755 index 0000000..c9dc252 --- /dev/null +++ b/public/Hacker News Clone/solution/js/stories.js @@ -0,0 +1,192 @@ +"use strict"; + +// This is the global list of the stories, an instance of StoryList +let storyList; + +/** Get and show stories when site first loads. */ + +async function getAndShowStoriesOnStart() { + storyList = await StoryList.getStories(); + $storiesLoadingMsg.remove(); + + putStoriesOnPage(); +} + +/** + * A render method to render HTML for an individual Story instance + * - story: an instance of Story + * - showDeleteBtn: show delete button? + * + * Returns the markup for the story. + */ + +function generateStoryMarkup(story, showDeleteBtn = false) { + // console.debug("generateStoryMarkup", story); + + const hostName = story.getHostName(); + + // if a user is logged in, show favorite/not-favorite star + const showStar = Boolean(currentUser); + + return $(` +
    1. +
      + ${showDeleteBtn ? getDeleteBtnHTML() : ""} + ${showStar ? getStarHTML(story, currentUser) : ""} + + ${story.title} + + (${hostName}) + +
      posted by ${story.username}
      +
      +
    2. + `); +} + +/** Make delete button HTML for story */ + +function getDeleteBtnHTML() { + return ` + + + `; +} + +/** Make favorite/not-favorite star for story */ + +function getStarHTML(story, user) { + const isFavorite = user.isFavorite(story); + const starType = isFavorite ? "fas" : "far"; + return ` + + + `; +} + +/** Gets list of stories from server, generates their HTML, and puts on page. */ + +function putStoriesOnPage() { + console.debug("putStoriesOnPage"); + + $allStoriesList.empty(); + + // loop through all of our stories and generate HTML for them + for (let story of storyList.stories) { + const $story = generateStoryMarkup(story); + $allStoriesList.append($story); + } + + $allStoriesList.show(); +} + +/** Handle deleting a story. */ + +async function deleteStory(evt) { + console.debug("deleteStory"); + + const $closestLi = $(evt.target).closest("li"); + const storyId = $closestLi.attr("id"); + + await storyList.removeStory(currentUser, storyId); + + // re-generate story list + await putUserStoriesOnPage(); +} + +$ownStories.on("click", ".trash-can", deleteStory); + +/** Handle submitting new story form. */ + +async function submitNewStory(evt) { + console.debug("submitNewStory"); + evt.preventDefault(); + + // grab all info from form + const title = $("#create-title").val(); + const url = $("#create-url").val(); + const author = $("#create-author").val(); + const username = currentUser.username + const storyData = { title, url, author, username }; + + const story = await storyList.addStory(currentUser, storyData); + + const $story = generateStoryMarkup(story); + $allStoriesList.prepend($story); + + // hide the form and reset it + $submitForm.slideUp("slow"); + $submitForm.trigger("reset"); +} + +$submitForm.on("submit", submitNewStory); + +/****************************************************************************** + * Functionality for list of user's own stories + */ + +function putUserStoriesOnPage() { + console.debug("putUserStoriesOnPage"); + + $ownStories.empty(); + + if (currentUser.ownStories.length === 0) { + $ownStories.append("
      No stories added by user yet!
      "); + } else { + // loop through all of users stories and generate HTML for them + for (let story of currentUser.ownStories) { + let $story = generateStoryMarkup(story, true); + $ownStories.append($story); + } + } + + $ownStories.show(); +} + +/****************************************************************************** + * Functionality for favorites list and starr/un-starr a story + */ + +/** Put favorites list on page. */ + +function putFavoritesListOnPage() { + console.debug("putFavoritesListOnPage"); + + $favoritedStories.empty(); + + if (currentUser.favorites.length === 0) { + $favoritedStories.append("
      No favorites added!
      "); + } else { + // loop through all of users favorites and generate HTML for them + for (let story of currentUser.favorites) { + const $story = generateStoryMarkup(story); + $favoritedStories.append($story); + } + } + + $favoritedStories.show(); +} + +/** Handle favorite/un-favorite a story */ + +async function toggleStoryFavorite(evt) { + console.debug("toggleStoryFavorite"); + + const $tgt = $(evt.target); + const $closestLi = $tgt.closest("li"); + const storyId = $closestLi.attr("id"); + const story = storyList.stories.find(s => s.storyId === storyId); + + // see if the item is already favorited (checking by presence of star) + if ($tgt.hasClass("fas")) { + // currently a favorite: remove from user's fav list and change star + await currentUser.removeFavorite(story); + $tgt.closest("i").toggleClass("fas far"); + } else { + // currently not a favorite: do the opposite + await currentUser.addFavorite(story); + $tgt.closest("i").toggleClass("fas far"); + } +} + +$storiesLists.on("click", ".star", toggleStoryFavorite); diff --git a/public/Hacker News Clone/solution/js/user.js b/public/Hacker News Clone/solution/js/user.js new file mode 100755 index 0000000..f03896a --- /dev/null +++ b/public/Hacker News Clone/solution/js/user.js @@ -0,0 +1,132 @@ +"use strict"; + +// global to hold the User instance of the currently-logged-in user +let currentUser; + +/****************************************************************************** + * User login/signup/login + */ + +/** Handle login form submission. If login ok, sets up the user instance */ + +async function login(evt) { + console.debug("login", evt); + evt.preventDefault(); + + // grab the username and password + const username = $("#login-username").val(); + const password = $("#login-password").val(); + + // User.login retrieves user info from API and returns User instance + // which we'll make the globally-available, logged-in user. + currentUser = await User.login(username, password); + + $loginForm.trigger("reset"); + + saveUserCredentialsInLocalStorage(); + updateUIOnUserLogin(); +} + +$loginForm.on("submit", login); + +/** Handle signup form submission. */ + +async function signup(evt) { + console.debug("signup", evt); + evt.preventDefault(); + + const name = $("#signup-name").val(); + const username = $("#signup-username").val(); + const password = $("#signup-password").val(); + + // User.signup retrieves user info from API and returns User instance + // which we'll make the globally-available, logged-in user. + currentUser = await User.signup(username, password, name); + + saveUserCredentialsInLocalStorage(); + updateUIOnUserLogin(); + + $signupForm.trigger("reset"); +} + +$signupForm.on("submit", signup); + +/** Handle click of logout button + * + * Remove their credentials from localStorage and refresh page + */ + +function logout(evt) { + console.debug("logout", evt); + localStorage.clear(); + location.reload(); +} + +$navLogOut.on("click", logout); + +/****************************************************************************** + * Storing/recalling previously-logged-in-user with localStorage + */ + +/** If there are user credentials in local storage, use those to log in + * that user. This is meant to be called on page load, just once. + */ + +async function checkForRememberedUser() { + console.debug("checkForRememberedUser"); + const token = localStorage.getItem("token"); + const username = localStorage.getItem("username"); + if (!token || !username) return false; + + // try to log in with these credentials (will be null if login failed) + currentUser = await User.loginViaStoredCredentials(token, username); +} + +/** Sync current user information to localStorage. + * + * We store the username/token in localStorage so when the page is refreshed + * (or the user revisits the site later), they will still be logged in. + */ + +function saveUserCredentialsInLocalStorage() { + console.debug("saveUserCredentialsInLocalStorage"); + if (currentUser) { + localStorage.setItem("token", currentUser.loginToken); + localStorage.setItem("username", currentUser.username); + } +} + +/****************************************************************************** + * General UI stuff about users & profiles + */ + +/** When a user signs up or registers, we want to set up the UI for them: + * + * - show the stories list + * - update nav bar options for logged-in user + * - generate the user profile part of the page + */ + +async function updateUIOnUserLogin() { + console.debug("updateUIOnUserLogin"); + + hidePageComponents(); + + // re-display stories (so that "favorite" stars can appear) + putStoriesOnPage(); + $allStoriesList.show(); + + updateNavOnLogin(); + generateUserProfile(); + $storiesContainer.show() +} + +/** Show a "user profile" part of page built from the current user's info. */ + +function generateUserProfile() { + console.debug("generateUserProfile"); + + $("#profile-name").text(currentUser.name); + $("#profile-username").text(currentUser.username); + $("#profile-account-date").text(currentUser.createdAt.slice(0, 10)); +} diff --git a/src/assets/nav.jsx b/src/assets/nav.jsx index e518def..2528019 100644 --- a/src/assets/nav.jsx +++ b/src/assets/nav.jsx @@ -66,6 +66,7 @@ function Menu({ menuState }) {
    3. Home
    4. About The Author
    5. The Code
    6. +
    7. Hacker News Clone
    8. Fruit Search
    9. Giphy Search
    10. Memory Game