From 0f15a32e5988e41bffae9a6c74dfa5abe45de1da Mon Sep 17 00:00:00 2001 From: thetyster Date: Mon, 30 Oct 2023 15:07:02 -0700 Subject: [PATCH] Add Hacker News to public portfolio. --- public/Hacker News Clone/css/nav.css | 24 ++ public/Hacker News Clone/css/site.css | 128 +++++++++ public/Hacker News Clone/css/stories.css | 48 ++++ public/Hacker News Clone/css/user.css | 14 + public/Hacker News Clone/hackerhoodie.png | Bin 0 -> 24445 bytes public/Hacker News Clone/index.html | 138 +++++++++ public/Hacker News Clone/js/main.js | 56 ++++ public/Hacker News Clone/js/models.js | 261 +++++++++++++++++ public/Hacker News Clone/js/nav.js | 87 ++++++ public/Hacker News Clone/js/stories.js | 151 ++++++++++ public/Hacker News Clone/js/user.js | 130 +++++++++ public/Hacker News Clone/solution/css/nav.css | 30 ++ .../Hacker News Clone/solution/css/site.css | 131 +++++++++ .../solution/css/stories.css | 64 +++++ .../Hacker News Clone/solution/css/user.css | 26 ++ public/Hacker News Clone/solution/index.html | 154 ++++++++++ public/Hacker News Clone/solution/js/main.js | 64 +++++ .../Hacker News Clone/solution/js/models.js | 267 ++++++++++++++++++ public/Hacker News Clone/solution/js/nav.js | 79 ++++++ .../Hacker News Clone/solution/js/stories.js | 192 +++++++++++++ public/Hacker News Clone/solution/js/user.js | 132 +++++++++ src/assets/nav.jsx | 1 + 22 files changed, 2177 insertions(+) create mode 100644 public/Hacker News Clone/css/nav.css create mode 100644 public/Hacker News Clone/css/site.css create mode 100644 public/Hacker News Clone/css/stories.css create mode 100644 public/Hacker News Clone/css/user.css create mode 100644 public/Hacker News Clone/hackerhoodie.png create mode 100644 public/Hacker News Clone/index.html create mode 100644 public/Hacker News Clone/js/main.js create mode 100644 public/Hacker News Clone/js/models.js create mode 100644 public/Hacker News Clone/js/nav.js create mode 100644 public/Hacker News Clone/js/stories.js create mode 100644 public/Hacker News Clone/js/user.js create mode 100755 public/Hacker News Clone/solution/css/nav.css create mode 100755 public/Hacker News Clone/solution/css/site.css create mode 100755 public/Hacker News Clone/solution/css/stories.css create mode 100755 public/Hacker News Clone/solution/css/user.css create mode 100755 public/Hacker News Clone/solution/index.html create mode 100755 public/Hacker News Clone/solution/js/main.js create mode 100755 public/Hacker News Clone/solution/js/models.js create mode 100755 public/Hacker News Clone/solution/js/nav.js create mode 100755 public/Hacker News Clone/solution/js/stories.js create mode 100755 public/Hacker News Clone/solution/js/user.js 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 0000000000000000000000000000000000000000..9f1ce1a648bd306e687f4773d7beeda8c9f23bc3 GIT binary patch literal 24445 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 odUa|JLa=gp4*#E24aV621CO~Y + + + + + + + + + + + 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}) + + 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