diff --git a/README.md b/README.md index 5cec1213c..05ee8a32a 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,29 @@ # Hello! -## _CSS Frameworks CA_ +## _JS 2 CA_ -![A screenshot of the portofolio page.](/src/assets/css-frameworks-screenshot.png) +![A screenshot of the portofolio page.](/src/assets/social-media-screenshot.png) ## Description -This is my delivery for the CSS Frameworks CA. It is a social media platform consisting of three pages so far: +This is my delivery for the JS2 CA. It is a social media platform consisting of: - Authentication page +- Log in form +- Register form - Feed page +- Create post form +- Update post form +- Post page + +Also has a non interactive: + - Profile page _Built With_ +- JavaScript +- JSDocs - HTML5 - Bootstrap - SASS/SCSS diff --git a/feed/index.html b/feed/index.html index 7c84aca35..e539d8612 100644 --- a/feed/index.html +++ b/feed/index.html @@ -10,9 +10,9 @@ +
@@ -20,7 +20,7 @@ class="navbar navbar-expand-md bg-light d-md-flex flex-column align-items-start sticky-md-top" >
- + Hello!
-
-
- Profile photo for Tigerlily. A photo of a young woman with long and curly brown hair. -
-
-

@tigerlily

-
-
-
- +
-
    -
  • -
    -
    -
    - Profile photo for cutedogs. A picture of a golden retriever sitting down and smiling to the camera. -
    -

    cutedogs

    -
    -
    - Corgi walking on railroads smiling happily in the rain. -
    -
    - - -
    -

    - Caught this corgi in action as he was out on a walk today. He - looks so happy and so adorable! - #happy #dogs #cuteness #corgi -

    -

    - 1 hour ago -

    -
    -
  • - -
  • -
    -
    -
    - Profile photo for girlwithapencil. A drawing of a blonde woman with eyes closed. -
    -

    girlwithapencil

    -
    -
    - Drawing of flowers in soft pastel colours and a white background. -
    -
    - - -
    -

    - Just some nighttime doodles.. Summers over and I can't wait for - spring to arrive again. What du you think? - #art #pastels #drawing #inspiration #flowers #spring -

    -

    - 2 days ago -

    -
    -
  • -
  • -
    -
    -
    - Profile photo for cutedogs. A picture of a golden retriever sitting down and smiling to the camera. -
    -

    cutedogs

    -
    -
    - Close up of a puppy lying down looking up with huge brown eyes. -
    -
    - - -
    -

    - How can I resist this cuteness overload? The rain was pouring - down today, but with those big brown eyes looking at me I was - easily pursuaded to go for a walk. - #happy #dogs #cuteness #cockerspaniel -

    -

    - 6 days ago -

    -
    -
  • - -
  • -
    -
    -
    - Profile photo for yogaemilia. A woman sitting down and meditating facing a lake. -
    -

    yogaemeilia

    -
    -
    - A woman standing in the sunset by the ocean reaching her hands out to both sides. -
    -
    - - -
    -

    - Just came back from a wonderful place. I have shot a 10 days - series with videos for you that will be released in a month. So - excited to share this experience with you guys! - #healthy #freedom #spirit #mindfullness -

    -

    - 3 weeks ago -

    -
    -
  • - -
  • -
    -
    -
    - Profile photo for yogaemilia. A woman sitting down and meditating facing a lake. -
    -

    yogaemeilia

    -
    -
    - A woman doing yoga on a mountain next to the ocean. She is standing on the hands. -
    -
    - - -
    -

    - I'm working on something... Will share more about this soon. - #healthy #yoga #spirit #mindfullness -

    -

    - 1 month ago -

    -
    -
  • -
+
    @@ -351,51 +165,5 @@

    Trending tags

    - - diff --git a/feed/post/index.html b/feed/post/index.html new file mode 100644 index 000000000..0b6f94b5f --- /dev/null +++ b/feed/post/index.html @@ -0,0 +1,131 @@ + + + + + + + + + + + +
    + +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +

    Trending tags

    + +
    +
    + + diff --git a/index.html b/index.html index 06409846a..19f9f86c3 100644 --- a/index.html +++ b/index.html @@ -1,21 +1,21 @@ - Sign In | Hello! + Welcome | Hello! + -
    +
    -
    +

    Everything begins at
    Hello!
    @@ -23,174 +23,33 @@

    Sign in to say hello to your friends and to make new friends!

    +
    -

    Not a member yet?

    +

    Not a member yet?

    - +

    - -
    -
    -

    Sign in

    -
    -
    -
    - - -
    -
    - Please fill in a valid email address. -
    -
    - -
    - - -
    -
    - The password needs to contain 8 characters. -
    -
    - -
    - -
    -
    -
    - - diff --git a/post/create/index.html b/post/create/index.html new file mode 100644 index 000000000..b5c8ea4c7 --- /dev/null +++ b/post/create/index.html @@ -0,0 +1,100 @@ + + + My profile | Hello! + + + + + + + + + +
    + +
    + +
    +
    +
    +
    +

    Create post

    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    +
    + + diff --git a/post/edit/index.html b/post/edit/index.html new file mode 100644 index 000000000..3c55d116e --- /dev/null +++ b/post/edit/index.html @@ -0,0 +1,104 @@ + + + + + + + + + + + + +
    + +
    + +
    +
    +
    +
    +

    Edit post

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    + +
    +
    +
    + + diff --git a/post/index.html b/post/index.html new file mode 100644 index 000000000..1b96c7240 --- /dev/null +++ b/post/index.html @@ -0,0 +1,221 @@ + + + Feed | Hello! + + + + + + + + +
    + +
    + +
    +
    +
    + Profile photo for Tigerlily. A photo of a young woman with long and curly brown hair. +
    +
    +

    @tigerlily

    +
    +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + + + diff --git a/profile/edit/index.html b/profile/edit/index.html new file mode 100644 index 000000000..7abb73d30 --- /dev/null +++ b/profile/edit/index.html @@ -0,0 +1,107 @@ + + + Edit Profile | Hello! + + + + + + + + + +
    + + +
    + +
    +
    +
    +

    Edit Profile

    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    +
    +
    + + diff --git a/profile/index.html b/profile/index.html index 8d4ec49ab..a4178a530 100644 --- a/profile/index.html +++ b/profile/index.html @@ -1,6 +1,6 @@ - My profile | Hello! + Register a profile @ Hello! @@ -18,7 +18,7 @@ class="navbar navbar-expand-md bg-light d-md-flex flex-column align-items-start sticky-md-top" >
    - + Hello! +
    + +
    +

    Not a member yet?

    + +
    + + +
    + + + diff --git a/profile/register/index.html b/profile/register/index.html new file mode 100644 index 000000000..bb2ebc946 --- /dev/null +++ b/profile/register/index.html @@ -0,0 +1,116 @@ + + + Sign In | Hello! + + + + + + + + +
    + + +
    +
    +
    +
    +

    Register user

    +
    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    +

    Already a member?

    + +
    +
    +
    +
    +
    + + diff --git a/src/assets/css-frameworks-screenshot.png b/src/assets/css-frameworks-screenshot.png deleted file mode 100644 index 3cd5c7c6a..000000000 Binary files a/src/assets/css-frameworks-screenshot.png and /dev/null differ diff --git a/src/assets/images/profile-placeholder.png b/src/assets/images/profile-placeholder.png new file mode 100644 index 000000000..b63b554e4 Binary files /dev/null and b/src/assets/images/profile-placeholder.png differ diff --git a/src/assets/social-media-screenshot.png b/src/assets/social-media-screenshot.png new file mode 100644 index 000000000..c2388d501 Binary files /dev/null and b/src/assets/social-media-screenshot.png differ diff --git a/src/js/api/auth/login.mjs b/src/js/api/auth/login.mjs new file mode 100644 index 000000000..4632efa73 --- /dev/null +++ b/src/js/api/auth/login.mjs @@ -0,0 +1,58 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import * as storage from "../../storage/index.mjs"; + +const action = "/auth/login"; +const method = "post"; + +/** + * Logs in with email and password by sending a request to the login endpoint. + * It checks if the email and password is connected to a registered user. + * If the email and password matches a profile with a token the user is logged in and redirected to the home page. + * If not the user gets an alert that the user is not registered and stays on the page. + * + * @param {object} profile - The user profile information. + * @param {string} profile.email - The user's email address. + * @param {string} profile.password - The user's password. + * + * @throws {Error} If there is an issue with the login process. + * + * @returns {Promise} A promise when the login is successful + * + * @example + * const profile = { + * email: "user@example.com", + * password: "password1234", + * }; + * try { + * await login(profile); + * } catch (error) { + * console.error(error.message)} + * + */ + +export async function login(profile) { + try { + const loginURL = API_SOCIAL_URL + action; + const body = JSON.stringify(profile); + + const response = await fetch(loginURL, { + headers: { + "Content-Type": "application/json", + }, + method, + body, + }); + + const { accessToken, ...profileUser } = await response.json(); + + if (profileUser && accessToken) { + storage.save("token", accessToken); + storage.save("profile", profileUser); + window.location.href = "/feed"; + } else { + alert("User is not registered."); + } + } catch (error) { + console.error(error); + } +} diff --git a/src/js/api/auth/register.mjs b/src/js/api/auth/register.mjs new file mode 100644 index 000000000..7d45e7a6d --- /dev/null +++ b/src/js/api/auth/register.mjs @@ -0,0 +1,55 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; + +const action = "/auth/register"; +const method = "post"; + +/** + * Registers a new user with username, email and password by sending a request to the register endpoint. + * If registration requirements are met the user get registrated and redirected to home page. + * If not and alert gets thrown that the registration failed. + * + * @param {Object} profile - The user profile information. + * @param {string} profile.email - The user's email address. + * @param {string} profile.password - The user's password. + * @param {string} profile.name - The username of the user. + * + * @throws {Error} If there is an issue with the registration process. + * + * @returns {Promise} A promise returns an object when the registration is successful. + * + * @example + * const profile = { + * email: "user@example.com", + * password: "password1234", + * username: "exampleUser" + * }; + * try { + * const registeredUser = await register(profile); + * console.log(registeredUser); + * } catch (error) { + * console.error(error.message) + * } + */ +export async function register(profile) { + try { + const registerURL = API_SOCIAL_URL + action; + const body = JSON.stringify(profile); + + const response = await fetch(registerURL, { + headers: { + "Content-Type": "application/json", + }, + method, + body, + }); + + const registerUser = await response.json(); + window.location.href = "/feed/ "; + return registerUser; + } catch (error) { + console.error(error); + alert( + "Failed to register user, please check that the requirements are met." + ); + } +} diff --git a/src/js/api/authFetch.mjs b/src/js/api/authFetch.mjs new file mode 100644 index 000000000..9dd3d3103 --- /dev/null +++ b/src/js/api/authFetch.mjs @@ -0,0 +1,38 @@ +import { load } from "../storage/index.mjs"; + +/** + * Generate the headers for an authenticated request. + * + * @returns {Object} An object containing the required headers. + */ +export function headers() { + const token = load("token"); + + return { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; +} + +/** + * Performs a fetch request with the authentication headers. + * + * @param {string} url - The URL for the fetch request. + * @param {Object} [options={}] - Optional configuration for the fetch. + * + * @returns {Promise} A promise that returns a response object. + * + * @throws {Error} Trown if the fetch request fails. + * + + */ +export async function authFetch(url, options = {}) { + try { + return fetch(url, { + ...options, + headers: headers(), + }); + } catch (error) { + console.error(error, "Failed to fetch API"); + } +} diff --git a/src/js/api/constants.mjs b/src/js/api/constants.mjs new file mode 100644 index 000000000..dfe3040b7 --- /dev/null +++ b/src/js/api/constants.mjs @@ -0,0 +1,4 @@ +export const API_HOST_URL = "https://api.noroff.dev"; +export const API_BASE = "/api/v1"; +export const API_SOCIAL_BASE = "/social"; +export const API_SOCIAL_URL = `${API_HOST_URL}${API_BASE}${API_SOCIAL_BASE}`; diff --git a/src/js/api/posts/create.mjs b/src/js/api/posts/create.mjs new file mode 100644 index 000000000..2f7d1e154 --- /dev/null +++ b/src/js/api/posts/create.mjs @@ -0,0 +1,41 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/posts"; +const method = "post"; + +/** + * Creates a new post with post data that is provided by sending it to the "create post"-endpoint. + * + * @param {Object} postData - The data for the new post. + * @param {string} postData.title - The title of the post + * @param {string} postData.body - The content of the post. + * @param {string[]} postData.tags - An array of tags that are stringified. + * @param {string} postData.media - The media for the post. + * + * @throws {Error} - If there is an issue with creating the post. + * + * @returns {Promise} A promise is returned with a new object containing the post information. + * + * @example + * const postData = { + * title: "New Post", + * body: "This is the content of the post", + * tags: ["tag1", "tag2", "tag3"], + * media: "www.example-media-url.example" + * } catch (error) { + * console.error(error.message) + * } + */ +export async function createPost(postData) { + const createPostURL = API_SOCIAL_URL + action; + + const response = await authFetch(createPostURL, { + method, + body: JSON.stringify(postData), + }); + + const newPost = await response.json(); + + return newPost; +} diff --git a/src/js/api/posts/delete.mjs b/src/js/api/posts/delete.mjs new file mode 100644 index 000000000..4e116be87 --- /dev/null +++ b/src/js/api/posts/delete.mjs @@ -0,0 +1,37 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/posts"; +const method = "delete"; + +/** + * Removes a post with a specified post ID by sending a request to the "delete post" api endpoint. + * + * @param {string} id - The id of the post that is going to be removed. + * + * @throws {Error} If the ID is not found. + * + * @returns {Promise} A promise that removes the object from the server if the response is successful. + * + * @example + * const postID = "1234"; + * try { + * const response = await removePost(postId); + * console.log(response); + * } catch (error) { + * console.error(error.message) + * } + */ +export async function removePost(id) { + if (!id) { + throw new Error("Delete requires a postID"); + } + + const removePostURL = `${API_SOCIAL_URL}${action}/${id}`; + + const response = await authFetch(removePostURL, { + method, + }); + + return await response.json(); +} diff --git a/src/js/api/posts/get.mjs b/src/js/api/posts/get.mjs new file mode 100644 index 000000000..29b0cc84f --- /dev/null +++ b/src/js/api/posts/get.mjs @@ -0,0 +1,55 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/posts"; +/** + * Fetches a list of post by doing a request to the social posts API. + * + * @returns {Promise>} A promise is returned with an array of object if the fetch is successful. + * + * @example + * try { + * const posts = await getPosts(); + * console.log(posts) + * } catch (error) { + * console.error(error.message)} + */ +export async function getPosts() { + const getPostsURL = `${API_SOCIAL_URL}${action}?_author=true`; + + const response = await authFetch(getPostsURL); + + const posts = await response.json(); + + return posts; +} + +/** + * Fetches a post object with a specified ID from the social posts API. + * + * @param {string} id - The ID of the post to fetch. + * + * @returns {Promise} A promise is returned with an object if the fetch is successful. + * + * @throws {Error} If the provided ID is falsy. + * + * @example + * try { + * const postID = "1234" + * const post = await getPost(postId) + * console.log(post) + * } catch (error) { + * console.error(error.message)} + */ +export async function getPost(id) { + if (!id) { + throw new Error("Get requires a postID"); + } + + const getPostURL = `${API_SOCIAL_URL}${action}/${id}?_author=true`; + + const response = await authFetch(getPostURL); + + const postByID = await response.json(); + return postByID; +} diff --git a/src/js/api/posts/index.mjs b/src/js/api/posts/index.mjs new file mode 100644 index 000000000..49d6a6182 --- /dev/null +++ b/src/js/api/posts/index.mjs @@ -0,0 +1,4 @@ +export * from "./create.mjs"; +export * from "./get.mjs"; +export * from "./update.mjs"; +export * from "./delete.mjs"; diff --git a/src/js/api/posts/update.mjs b/src/js/api/posts/update.mjs new file mode 100644 index 000000000..a518aea3c --- /dev/null +++ b/src/js/api/posts/update.mjs @@ -0,0 +1,45 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/posts"; +const method = "put"; + +/** + * Updates an existing post with post data that is provided by sending it to the "update post"-endpoint. + * + * @param {Object} postData - The updated data for the new post. + * @param {string} postData.id - The id of the post to update. + * @param {string} postData.title - The updated title of the post + * @param {string} postData.body - The updated content of the post. + * @param {string[]} postData.tags - An updated array of tags that are stringified. + * @param {string} postData.media - The updated media for the post. + * + * @throws {Error} - If the post is missing an valid ID. + * + * @returns {Promise} A promise is returned with a updated object containing the new post information, but same ID. + * + * @example + * const updatedPost = { + * id: "1234", + * title: "Updated Post", + * body: "This is the updated content of the post", + * tags: ["update1", "update2", "update3"], + * media: "www.updated-example-media-url.example" + * } catch (error) { + * console.error(error.message) + * } + */ +export async function updatePost(postData) { + if (!postData.id) { + throw new Error("Update requires a postID"); + } + + const updatePostURL = `${API_SOCIAL_URL}${action}/${postData.id}`; + + const response = await authFetch(updatePostURL, { + method, + body: JSON.stringify(postData), + }); + + return await response.json(); +} diff --git a/src/js/api/profiles/get.mjs b/src/js/api/profiles/get.mjs new file mode 100644 index 000000000..24d5cb87d --- /dev/null +++ b/src/js/api/profiles/get.mjs @@ -0,0 +1,59 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/profiles"; + +/** + * Fetches profiles from the social profiles endpoint. + * + * @returns {Promise} A promise with an array of objects is returned. + * + * @throws {Error} IF there is an issue fetching the profiles. + * + * @example + * try { + * const profiles = await getProfiles(); + * console.log(profiles) + * }catch(error) { + * console.error(error.message)} + */ +export async function getProfiles() { + const getProfilesURL = `${API_SOCIAL_URL}${action}`; + + const response = await authFetch(getProfilesURL); + + const profiles = await response.json(); + + return profiles; +} + +/** + * Fetches a profile by name from the social profiles endpoint. + * + * @param {string} name - The name for the profile to fetch. + * + * @returns {Promise} A promise returns with the profile object. + * + * @throws {Error} If the profile name is not provided or isn't possible to fetch. + * + * @example + * try { + * const profileName = "exampleProfile"; + * const profile = await getProfile(profileName) + * console.log(profile) + * } catch (error) { + * console.error(error.message) + * } + */ +export async function getProfile(name) { + if (!name) { + throw new Error("Get requires a profile name"); + } + + const getProfileURL = `${API_SOCIAL_URL}${action}/${name}`; + + const response = await authFetch(getProfileURL); + + const profileByName = await response.json(); + return profileByName; +} diff --git a/src/js/api/profiles/index.mjs b/src/js/api/profiles/index.mjs new file mode 100644 index 000000000..2753ec82e --- /dev/null +++ b/src/js/api/profiles/index.mjs @@ -0,0 +1,2 @@ +export * from "./get.mjs"; +export * from "./update.mjs"; diff --git a/src/js/api/profiles/update.mjs b/src/js/api/profiles/update.mjs new file mode 100644 index 000000000..adeaf75f9 --- /dev/null +++ b/src/js/api/profiles/update.mjs @@ -0,0 +1,42 @@ +import { API_SOCIAL_URL } from "../constants.mjs"; +import { authFetch } from "../authFetch.mjs"; + +const action = "/profiles"; +const method = "put"; + +/** + * Updates the user profile with provided data by sending a request to the social profiles endpoint. + * + * @param {Object} profileData - The data to update the user profile. + * @param {string} profileData.avatar - The updated avatar for the user profile. + * @param {string} profileData.banner - The updated banner for the user profile. + * + * @returns {Promise} A promise is returned if the update is successfull with the new data. + * + * @throws {Error} If the profileData is unable to fetch or is not provided. + * + * @example + * try { + * const newProfileData = { + * avatar: "https://example.com/avatar.jpeg" + * banner: "https://example.com/banner.jpeg" + * const updatedProfile = await updateProfile(newProfileData); + * console.log(updatedProfile); + * } catch(error) { + * console.error(error.message) + * } + */ +export async function updateProfile(profileData) { + if (!profileData.name) { + throw new Error("Update requires a name"); + } + + const updateProfileURL = `${API_SOCIAL_URL}${action}/${profileData.name}/media`; + + const response = await authFetch(updateProfileURL, { + method, + body: JSON.stringify(profileData), + }); + + return await response.json(); +} diff --git a/src/js/handlers/components/formatting.mjs b/src/js/handlers/components/formatting.mjs new file mode 100644 index 000000000..b1dde64eb --- /dev/null +++ b/src/js/handlers/components/formatting.mjs @@ -0,0 +1,29 @@ +/** + * Formats the date string into a readable string with date and time. + * + * @param {string} dateString - The date string to be formatted. + * + * @returns {string} A formatted date string with year, month, day, hour and minute. + * + * @example + * const originalDateString = "2023-12-14T15:30:00Z"; + * const formattedDate = formatDateString(originalDateString); + * console.log(formattedDate); + * Output: "Desember 14, 2023, 3:30PM" + */ +export function formatDateString(dateString) { + const date = new Date(dateString); + + const options = { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }; + + const formattedDate = date.toLocaleString("en-US", options); + + return formattedDate; +} diff --git a/src/js/handlers/components/getParams.mjs b/src/js/handlers/components/getParams.mjs new file mode 100644 index 000000000..86eca9030 --- /dev/null +++ b/src/js/handlers/components/getParams.mjs @@ -0,0 +1,17 @@ +/** + * Retrieves a value of the querystring parameter from the current url. + * + * @param {string} param - The name of the query string parameter. + * + * @returns {string} The value of the specified query string param. + * + * @example + * Example URL : "https://example.com/page?id=1234" + * const value = getQueryStringParam ("id"); + * console.log(value); + * Output: "1234" + */ +export function getQueryStringParam(param) { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get(param); +} diff --git a/src/js/handlers/index.mjs b/src/js/handlers/index.mjs new file mode 100644 index 000000000..5d72f5e79 --- /dev/null +++ b/src/js/handlers/index.mjs @@ -0,0 +1,16 @@ +export * from "./posts/renderPosts.mjs"; +export * from "./posts/renderPostByID.mjs"; +export * from "./posts/createPost.mjs"; +export * from "./posts/deletePost.mjs"; +export * from "./posts/updatePost.mjs"; +export * from "./posts/searchPosts.mjs"; +export * from "./posts/sortPosts.mjs"; + +export * from "./components/getParams.mjs"; +export * from "./components/formatting.mjs"; + +export * from "./profile/login.mjs"; +export * from "./profile/register.mjs"; +export * from "./profile/logout.mjs"; +export * from "./profile/renderProfileThumbnail.mjs"; +export * from "./profile/updateProfile.mjs"; diff --git a/src/js/handlers/posts/createPost.mjs b/src/js/handlers/posts/createPost.mjs new file mode 100644 index 000000000..6fe38f852 --- /dev/null +++ b/src/js/handlers/posts/createPost.mjs @@ -0,0 +1,39 @@ +import { createPost } from "../../api/posts/create.mjs"; + +/** + * Sets a submit form listener for creating a new post. + * When the form is submitted, it prevents a default submission, + * takes the form data, creates a new post and redirect the user to the + * feed. + * + * @async + * @function + * @returns {void} + */ +export function setCreatePostFormListener() { + const form = document.querySelector("#createPost"); + + if (form) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + try { + const form = event.target; + const formData = new FormData(form); + const post = Object.fromEntries(formData.entries()); + + if (post.tags) { + post.tags = post.tags.split(" , ").map((tag) => tag.trim()); + } else { + post.tags = []; + } + + createPost(post); + alert("Your post was successfully created!"); + window.location.href = "/feed"; + } catch (error) { + console.error(error); + alert("Failed to create post."); + } + }); + } +} diff --git a/src/js/handlers/posts/deletePost.mjs b/src/js/handlers/posts/deletePost.mjs new file mode 100644 index 000000000..f28017d47 --- /dev/null +++ b/src/js/handlers/posts/deletePost.mjs @@ -0,0 +1,40 @@ +import { getPost, removePost } from "../../api/posts/index.mjs"; + +/** + * Sets a click event listener to the delete button for the user to delete post. + * Throws an alert for the user to confirm and sends a delete request. + * When post is deleted the user is redirected to feed page. + * + * @async + * @function + * @returns {void} + */ +export async function setDeletePostListener() { + const deleteButton = document.querySelector("#deleteButton"); + + const url = new URL(location.href); + const id = url.searchParams.get("id"); + + deleteButton.addEventListener("click", async (event) => { + event.preventDefault(); + + try { + const post = await getPost(id); + if (post) { + const confirmDelete = confirm( + "Are you sure you want to delete this post?" + ); + + if (confirmDelete) { + await removePost(id); + + alert("You successfully deleted the post."); + window.location.href = "/feed/"; + } + } else console.log("Post not found"); + } catch (error) { + console.error("Error deleting the post:", error); + alert("Error deleting post"); + } + }); +} diff --git a/src/js/handlers/posts/renderPostByID.mjs b/src/js/handlers/posts/renderPostByID.mjs new file mode 100644 index 000000000..9a7cf7976 --- /dev/null +++ b/src/js/handlers/posts/renderPostByID.mjs @@ -0,0 +1,34 @@ +import { getPost } from "../../api/posts/get.mjs"; +import { getQueryStringParam } from "../../handlers/index.mjs"; +import { clearContainer, postTemplate } from "../../templates/index.mjs"; + +/** + * Renders a single post by ID from the query string, + * updating the title element, and rendering the post in the postTemplate + * in the container. + * @async + * @function + * @returns {void} + */ +export async function renderPost() { + try { + const postId = getQueryStringParam("id"); + const postContainer = document.querySelector("#postByID"); + const postById = await getPost(postId); + + const titleElement = document.querySelector("title"); + + if (titleElement) { + titleElement.innerHTML = `${postById.title} | Hello!`; + } + + const renderPostTemplate = (postData, id) => { + clearContainer(postContainer); + postContainer.append(postTemplate(postData, id)); + }; + + renderPostTemplate(postById); + } catch (error) { + console.error(error, "Failed to render post by ID."); + } +} diff --git a/src/js/handlers/posts/renderPosts.mjs b/src/js/handlers/posts/renderPosts.mjs new file mode 100644 index 000000000..221b3ad67 --- /dev/null +++ b/src/js/handlers/posts/renderPosts.mjs @@ -0,0 +1,28 @@ +import { getPosts } from "../../api/posts/get.mjs"; +import { postTemplate, clearContainer } from "../../templates/index.mjs"; + +/** + * Renders a list of post by fetching the post data and displaying them in + * a container in the feed using post templates. + * The posts are filtered to at least include title and body content. + * + * @async + * @function + * @returns {void} + */ +export async function renderPosts() { + try { + const posts = await getPosts(); + const feedContainer = document.querySelector("#postFeed"); + + if (feedContainer) { + clearContainer(feedContainer); + } + + const postsWithContent = posts.filter((post) => post.title && post.body); + + feedContainer.append(...postsWithContent.map(postTemplate)); + } catch (error) { + console.error(error, "Failed to render posts."); + } +} diff --git a/src/js/handlers/posts/searchPosts.mjs b/src/js/handlers/posts/searchPosts.mjs new file mode 100644 index 000000000..64ffd37e9 --- /dev/null +++ b/src/js/handlers/posts/searchPosts.mjs @@ -0,0 +1,52 @@ +import { getPosts } from "../../api/posts/index.mjs"; +import { + postTemplate, + messageTemplate, + clearContainer, +} from "../../templates/index.mjs"; + +/** + * A form listener for the search form to filter and render posts that includ the search + * term and render the result in the container in the feed using post template. + * + * @async + * @function + * @returns {void} + */ +export async function setSearchFormListener() { + const form = document.querySelector("#searchForm"); + const input = document.querySelector("#searchInput"); + + if (form) { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const searchTerm = input.value.trim().toLowerCase(); + const posts = await getPosts(); + + const filteredPosts = posts.filter( + (post) => + (post.title && post.title.toLowerCase().includes(searchTerm)) || + (post.author.name && + post.author.name.toLowerCase().includes(searchTerm)) || + (post.body && post.body.toLowerCase().includes(searchTerm)) || + (post.tags && + post.tags.some((tag) => tag.toLowerCase().includes(searchTerm))) + ); + + const searchContainer = document.querySelector("#postFeed"); + clearContainer(searchContainer); + + if (filteredPosts.length === 0) { + searchContainer.appendChild( + messageTemplate("No search elements to be found") + ); + } else { + searchContainer.append(...filteredPosts.map(postTemplate)); + } + } catch (error) { + console.error(error, "Failed to render search results."); + } + }); + } +} diff --git a/src/js/handlers/posts/sortPosts.mjs b/src/js/handlers/posts/sortPosts.mjs new file mode 100644 index 000000000..8281754c2 --- /dev/null +++ b/src/js/handlers/posts/sortPosts.mjs @@ -0,0 +1,67 @@ +import { getPosts } from "../../api/posts/index.mjs"; +import { postTemplate, clearContainer } from "../../templates/index.mjs"; + +/** + * Sorts an array of posts in order, the oldest or latest. + * + * @param {Array} posts - The array of posts to be sorted + * @param {string} sortOrder - The order for the post to be sorted. + * + * @returns {Array} Returns the post in a sorted array. + */ +function sortPosts(posts, sortOrder) { + if (sortOrder !== "oldest" && sortOrder !== "latest") { + return posts; + } + + const sortedPosts = [...posts]; + + sortedPosts.sort((postA, postB) => { + const dateA = new Date(postA.created); + const dateB = new Date(postB.created); + return sortOrder === "oldest" ? dateA - dateB : dateB - dateA; + }); + return sortedPosts; +} + +/** + * Set an event listener for the sort button, sorting latest or oldest posts. + * + * @async + * @function + * + * @throws {Error} If there is a problem with fetching the posts from the API. + */ +export async function setSortButtonListeners() { + const latestButton = document.querySelector("#latestButton"); + const oldestButton = document.querySelector("#oldestButton"); + const sortButton = document.querySelector("#sortButton"); + const sortedPostsContainer = document.querySelector("#postFeed"); + + try { + const posts = await getPosts(); + + /** + * Renders the posts in the specified order and updates the container + * with the sorted posts. + * @param {string} sortOrder - The order which the posts should be sorted in + */ + function renderSortedPosts(sortOrder) { + const sortedPosts = sortPosts(posts, sortOrder); + clearContainer(sortedPostsContainer); + sortedPostsContainer.append(...sortedPosts.map(postTemplate)); + } + + latestButton.addEventListener("click", () => { + renderSortedPosts("latest"); + sortButton.textContent = "Latest"; + }); + + oldestButton.addEventListener("click", () => { + renderSortedPosts("oldest"); + sortButton.textContent = "Oldest"; + }); + } catch (error) { + console.error(error, "Failed to sort posts"); + } +} diff --git a/src/js/handlers/posts/updatePost.mjs b/src/js/handlers/posts/updatePost.mjs new file mode 100644 index 000000000..f772f1e41 --- /dev/null +++ b/src/js/handlers/posts/updatePost.mjs @@ -0,0 +1,55 @@ +import { getPost, updatePost } from "../../api/posts/index.mjs"; + +/** + * Sets a form listener for updating posts. + * Gets the HTML element and post ID, extraxts data from the form fields. + * Split tags if they are present. + * Sends an alert to the user if something goes wrong. + * + * @async + * @function + * @throws {Error} If there is an issue fetching the post details from the social API. + */ +export async function setUpdatePostFormListener() { + const form = document.querySelector("#updatePost"); + + const url = new URL(location.href); + const id = url.searchParams.get("id"); + + if (form) { + const button = form.querySelector("button"); + button.disabled = true; + + const post = await getPost(id); + + form.title.value = post.title; + form.body.value = post.body; + form.tags.value = post.tags; + form.media.value = post.media; + + button.disabled = false; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + try { + const form = event.target; + const formData = new FormData(form); + const post = Object.fromEntries(formData.entries()); + post.id = id; + + if (post.tags) { + post.tags = post.tags.split(" , ").map((tag) => tag.trim()); + } else { + post.tags = []; + } + + updatePost(post); + alert("Your post was successfully updated!"); + window.location.href = `/feed/post/?id=${post.id}`; + } catch (error) { + console.error(error); + alert("Failed to update post."); + } + }); + } +} diff --git a/src/js/handlers/profile/login.mjs b/src/js/handlers/profile/login.mjs new file mode 100644 index 000000000..3ef29ed19 --- /dev/null +++ b/src/js/handlers/profile/login.mjs @@ -0,0 +1,27 @@ +import { login } from "../../api/auth/login.mjs"; + +/** + * Sets a form listener on the log in form to handle the login submissions. + * + * @function + * @throws {Error} If there is an issue with the login process. + */ +export function setLoginFormListener() { + const form = document.querySelector("#loginForm"); + + try { + if (form) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + const profile = Object.fromEntries(formData.entries()); + + login(profile); + }); + } + } catch (error) { + console.error(error); + alert("Failed to login. Check your email and password."); + } +} diff --git a/src/js/handlers/profile/logout.mjs b/src/js/handlers/profile/logout.mjs new file mode 100644 index 000000000..cebfe8e28 --- /dev/null +++ b/src/js/handlers/profile/logout.mjs @@ -0,0 +1,24 @@ +import { remove } from "../../storage/index.mjs"; + +/** + * Sets a form listener on the log out anchor tag to handle logout actions. + * + * @function + * @throws {Error} If there is an issue with the logout process. + */ +export function setLogOutListener() { + const logOutButton = document.querySelector("#logOut"); + try { + if (logOutButton) { + logOutButton.addEventListener("click", (event) => { + event.preventDefault(); + + remove("token"); + window.location.href = "/"; + }); + } + } catch (error) { + console.error(error); + alert("Something went wrong"); + } +} diff --git a/src/js/handlers/profile/register.mjs b/src/js/handlers/profile/register.mjs new file mode 100644 index 000000000..df434198b --- /dev/null +++ b/src/js/handlers/profile/register.mjs @@ -0,0 +1,29 @@ +import { register } from "../../api/auth/register.mjs"; + +/** + * Sets a form listener on the register form to handle the register submissions. + * + * @function + * @throws {Error} If there is an issue with the login process. + */ +export function setRegisterFormListener() { + const form = document.querySelector("#registerForm"); + + if (form) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + try { + const form = event.target; + const formData = new FormData(form); + const profile = Object.fromEntries(formData.entries()); + + register(profile); + alert("You successfully registered a profile."); + window.location.href = "/feed"; + } catch (error) { + console.error(error); + alert("Failed to register profile"); + } + }); + } +} diff --git a/src/js/handlers/profile/renderProfileThumbnail.mjs b/src/js/handlers/profile/renderProfileThumbnail.mjs new file mode 100644 index 000000000..bb23cbb84 --- /dev/null +++ b/src/js/handlers/profile/renderProfileThumbnail.mjs @@ -0,0 +1,26 @@ +import { getProfile } from "../../api/profiles/get.mjs"; +import { load } from "../../storage/index.mjs"; +import { + clearContainer, + profileThumbnailTemplate, +} from "../../templates/index.mjs"; + +/** + * Renders the profile thumbnail in the thumbnail container. + * + * @async + * @function + * @throws {Error} If the rendering of the thumbnail fails. + */ +export async function renderProfileThumbnail() { + try { + const { name } = load("profile"); + const profile = await getProfile(name); + + const thumbnailContainer = document.querySelector("#profileThumbnail"); + clearContainer(thumbnailContainer); + thumbnailContainer.append(profileThumbnailTemplate(profile)); + } catch (error) { + console.error(error, "Failed to render thumbnail."); + } +} diff --git a/src/js/handlers/profile/updateProfile.mjs b/src/js/handlers/profile/updateProfile.mjs new file mode 100644 index 000000000..cce5ffa42 --- /dev/null +++ b/src/js/handlers/profile/updateProfile.mjs @@ -0,0 +1,48 @@ +import { getProfile, updateProfile } from "../../api/profiles/index.mjs"; +import { load } from "../../storage/index.mjs"; + +/** + * Sets a form listener for updating profile. + * Gets existing profile data, populates the form and + * handles the form submission. + * + * @async + * @function + * @throws {Error} If there is an issue fetching the post details from the social API. + */ +export async function setUpdateProfileListener() { + const form = document.querySelector("#editProfile"); + + if (form) { + const { name, email } = load("profile"); + form.name.value = name; + form.email.value = email; + + const button = form.querySelector("button"); + button.disabled = true; + + const profile = await getProfile(name); + + form.banner.value = profile.banner; + form.avatar.value = profile.avatar; + + button.disabled = false; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + try { + const form = event.target; + const formData = new FormData(form); + const profile = Object.fromEntries(formData.entries()); + + profile.name = name; + profile.email = email; + + updateProfile(profile); + } catch (error) { + console.error(error); + alert("Failed to update profile"); + } + }); + } +} diff --git a/src/js/index.mjs b/src/js/index.mjs new file mode 100644 index 000000000..16237cc59 --- /dev/null +++ b/src/js/index.mjs @@ -0,0 +1,3 @@ +import router from "./router.mjs"; + +router(); diff --git a/src/js/router.mjs b/src/js/router.mjs new file mode 100644 index 000000000..91dcbb81a --- /dev/null +++ b/src/js/router.mjs @@ -0,0 +1,51 @@ +import * as listeners from "./handlers/index.mjs"; + +/** + * Sets up a routing behavior for the event listeners based on the + * current URL path. + * The function ensures that the appropriate listeneres is being called + * on the correct pages and the correct actions are taken. + * + * @returns {void } + */ +export default function router() { + const path = location.pathname; + switch (path) { + case "/profile/login/": + listeners.setLoginFormListener(); + break; + case "/profile/register/": + listeners.setRegisterFormListener(); + break; + case "/feed/": + listeners.renderProfileThumbnail(); + listeners.renderPosts(); + listeners.setSearchFormListener(); + listeners.setSortButtonListeners(); + listeners.setLogOutListener(); + break; + case "/feed/post/": + listeners.renderProfileThumbnail(); + listeners.renderPost(); + listeners.setLogOutListener(); + break; + case "/post/create/": + listeners.setCreatePostFormListener(); + listeners.setLogOutListener(); + break; + case "/post/edit/": + listeners.setUpdatePostFormListener(); + listeners.setDeletePostListener(); + listeners.setLogOutListener(); + break; + case "/profile/edit/": + listeners.setUpdateProfileListener(); + listeners.setLogOutListener(); + break; + case "/profile/": + listeners.setLogOutListener(); + break; + + default: + } +} diff --git a/src/js/storage/index.mjs b/src/js/storage/index.mjs new file mode 100644 index 000000000..b87c37de7 --- /dev/null +++ b/src/js/storage/index.mjs @@ -0,0 +1,33 @@ +/** + * Saves the specified value associated with the key to localStorage. + * + * @param {string} key - The key where to store the value. + * @param {any} value - The value to be stored. + * + * @returns {void} + */ +export function save(key, value) { + localStorage.setItem(key, JSON.stringify(value)); +} + +/** + * Retreives the value that is assosiated with the specified key from localStorage. + * @param {string} key - The key of the item to be retreived. + * @returns {any} The value that is associated with the key or null. + */ +export function load(key) { + try { + const value = localStorage.getItem(key); + return JSON.parse(value); + } catch { + return null; + } +} + +/** + * Removes an item with a specified key from localStorage. + * @param {string} key - The key of the item to be removed. + */ +export function remove(key) { + localStorage.removeItem(key); +} diff --git a/src/js/templates/components/clearTemplate.mjs b/src/js/templates/components/clearTemplate.mjs new file mode 100644 index 000000000..75990796f --- /dev/null +++ b/src/js/templates/components/clearTemplate.mjs @@ -0,0 +1,11 @@ +/** + * Clears the container with all child elements. + * + * @param {HTMElement} container - The container element to be cleared + * @returns {void} + */ +export function clearContainer(container) { + while (container.firstChild) { + container.removeChild(container.firstChild); + } +} diff --git a/src/js/templates/components/message.mjs b/src/js/templates/components/message.mjs new file mode 100644 index 000000000..8271385d9 --- /dev/null +++ b/src/js/templates/components/message.mjs @@ -0,0 +1,16 @@ +/** + * Generates an HTML message element with a message text that is provided. + * @param {string} message - The message text to be displayed. + * + * @returns {HTMLDivElement} The generated message element + */ +export function messageTemplate(message) { + const messageElement = document.createElement("div"); + messageElement.classList.add("py-3"); + + const messageText = document.createElement("p"); + messageText.textContent = message; + messageElement.appendChild(messageText); + + return messageElement; +} diff --git a/src/js/templates/index.mjs b/src/js/templates/index.mjs new file mode 100644 index 000000000..7fe5c40d7 --- /dev/null +++ b/src/js/templates/index.mjs @@ -0,0 +1,4 @@ +export * from "./posts/post.mjs"; +export * from "./profiles/profile-thumbnail.mjs"; +export * from "./components/clearTemplate.mjs"; +export * from "./components/message.mjs"; diff --git a/src/js/templates/posts/post.mjs b/src/js/templates/posts/post.mjs new file mode 100644 index 000000000..9a0c98dff --- /dev/null +++ b/src/js/templates/posts/post.mjs @@ -0,0 +1,131 @@ +import { load } from "../../storage/index.mjs"; +import { formatDateString } from "../../handlers/index.mjs"; + +/** + * Generates a HTML representation of a post pased on provided post data. + * + * @param {Object} postData - The data of the post to be displayed. + * + * @returns {HTMLElement} Returns the HTML element representing the post data. + */ +export function postTemplate(postData) { + const profile = load("profile"); + + let postDetails; + + if (location.pathname === "/feed/") { + postDetails = document.createElement("li"); + const postURL = document.createElement("a"); + postURL.classList.add("stretched-link"); + postURL.href = `/feed/post/?id=${postData.id}`; + postDetails.appendChild(postURL); + } else { + postDetails = document.createElement("div"); + } + postDetails.classList.add("card", "py-2", "my-3"); + postDetails.id = postData.id; + + const postHeader = document.createElement("div"); + postHeader.classList.add("row", "align-items-center", "mx-1"); + postDetails.appendChild(postHeader); + + const authorAvatarWrapper = document.createElement("div"); + authorAvatarWrapper.classList.add("col-2", "col-md-3"); + postHeader.appendChild(authorAvatarWrapper); + + const authorAvatar = document.createElement("img"); + authorAvatar.classList.add("rounded", "w-100"); + + if (postData.author.avatar === null) { + authorAvatar.src = "/src/assets/images/profile-placeholder.png"; + authorAvatar.alt = `Example avatar for ${postData.name}`; + } else { + authorAvatar.src = postData.author.avatar; + authorAvatar.alt = `Avatar for ${postData.name}`; + } + authorAvatarWrapper.appendChild(authorAvatar); + + const authorUserName = document.createElement("p"); + authorUserName.classList.add("card-title", "fs-5", "col"); + authorUserName.innerHTML = `@ ${postData.author.name}`; + postHeader.appendChild(authorUserName); + + if ( + location.pathname === `/feed/post/` && + postData.author.name === profile.name + ) { + const editButton = document.createElement("a"); + editButton.id = "editPostButton"; + editButton.href = `/post/edit/?id=${postData.id}`; + editButton.classList.add("btn", "button-sm", "btn-secondary", "col-3"); + editButton.innerHTML = `Edit `; + postHeader.appendChild(editButton); + } + + const post = document.createElement("div"); + post.classList.add("mb-3", "mx-2", "p-2", "my-3"); + postDetails.appendChild(post); + + if (postData.title) { + const title = document.createElement("p"); + title.classList.add("card-title", "fs-5"); + title.innerHTML = postData.title; + post.appendChild(title); + } + + if (postData.media) { + const imgWrapper = document.createElement("div"); + imgWrapper.classList.add("d-flex", "justify-content-center"); + post.appendChild(imgWrapper); + + const img = document.createElement("img"); + img.classList.add("img-fluid"); + img.src = postData.media; + img.alt = `Image from ${postData.title}`; + imgWrapper.appendChild(img); + } + + const bodyWrapper = document.createElement("div"); + bodyWrapper.classList.add("card-body"); + post.appendChild(bodyWrapper); + + if (postData.body) { + const body = document.createElement("p"); + body.classList.add("card-text"); + body.innerHTML = postData.body; + bodyWrapper.appendChild(body); + } + + if (postData.tags) { + const tags = document.createElement("span"); + tags.classList.add("fw-bold"); + tags.innerHTML = postData.tags; + bodyWrapper.appendChild(tags); + } + + const reactionsWrapper = document.createElement("div"); + reactionsWrapper.classList.add("d-flex", "align-items-center", "my-2"); + post.appendChild(reactionsWrapper); + const reactions = document.createElement("i"); + reactions.classList.add("bi", "bi-heart", "fs-4", "me-2"); + const comments = document.createElement("i"); + comments.classList.add("bi", "bi-chat-right", "fs-4", "ms-2"); + reactionsWrapper.appendChild(reactions); + reactionsWrapper.appendChild(comments); + + if (postData.updated) { + const dateString = postData.updated; + const formattedDate = formatDateString(dateString); + + const timeWrapper = document.createElement("p"); + timeWrapper.classList.add("card-text"); + post.appendChild(timeWrapper); + + const time = document.createElement("small"); + time.classList.add("text-muted"); + time.innerHTML = `Posted: ${formattedDate}`; + timeWrapper.appendChild(time); + } + + return postDetails; +} diff --git a/src/js/templates/profiles/profile-thumbnail.mjs b/src/js/templates/profiles/profile-thumbnail.mjs new file mode 100644 index 000000000..5547d5b00 --- /dev/null +++ b/src/js/templates/profiles/profile-thumbnail.mjs @@ -0,0 +1,38 @@ +/** + * Generates an HMTL representation of a profile thumbnail + * with the provided profile data + * + * @param {Object} profileData - The data of the profile to be displayed + * @returns {HTMLDivElement} Returns an HTML element representing the profile data. + */ +export function profileThumbnailTemplate(profileData) { + const profileThumbnail = document.createElement("div"); + profileThumbnail.classList.add( + "row", + "mt-3", + "pb-3", + "align-items-center", + "border-bottom" + ); + + const profileAvatarWrapper = document.createElement("div"); + profileAvatarWrapper.classList.add("col-2"); + profileThumbnail.appendChild(profileAvatarWrapper); + + const profileAvatar = document.createElement("img"); + profileAvatar.classList.add("rounded", "w-100"); + profileAvatar.src = profileData.avatar; + profileAvatar.alt = `Image for the profile of ${profileData.name}`; + profileAvatarWrapper.appendChild(profileAvatar); + + const profileNameWrapper = document.createElement("div"); + profileNameWrapper.classList.add("col-8"); + profileThumbnail.appendChild(profileNameWrapper); + + const profileName = document.createElement("h1"); + profileName.classList.add("fw-light", "fs-5"); + profileName.innerHTML = `@ ${profileData.name}`; + profileNameWrapper.appendChild(profileName); + + return profileThumbnail; +} diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 1a17c78ba..99fe4b36a 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -15,3 +15,9 @@ $card-border-color: $light; $link-color: $primary; $link-hover-color: $secondary; $link-decoration: none; + +//Forms +.form-control:disabled { + opacity: 0.5; + cursor: not-allowed; +}