diff --git a/README.md b/README.md index 41ebece2..c408cd12 100644 --- a/README.md +++ b/README.md @@ -1 +1,26 @@ -# Happy Thoughts +# Happy Thoughts # +A small web app to share short, positive messages. Users can write tiny happy notes and read messages from others. + +# Demo # +https://project-happy-thoughts-ml.netlify.app/ + +# Features # +- You can write messages (5-140 characters) +- Like other messages +- Shows how long ago messages were posted +- Works on mobile and desktop + +# Tech # +- React +- Styled components +- Day.js +- Fetch API + +# API # + +- GET /thoughts - get recent messages +- POST /thoughts - send a new message +- POST /thought/:id/like - like a message + + +###### ENJOY ####### diff --git a/index.html b/index.html index d4492e94..4a72f712 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,34 @@ - - - - - Happy Thoughts - - -
- - - + + + + + + + Happy Thoughts — Share small joyful messages + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index 2f66d295..c82846a1 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "dayjs": "^1.11.19", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "styled-components": "^6.1.19" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/public/assets/og-image.png b/public/assets/og-image.png new file mode 100644 index 00000000..a7d9ad1f Binary files /dev/null and b/public/assets/og-image.png differ diff --git a/src/App.jsx b/src/App.jsx index 07f2cbdf..f1551bbd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,129 @@ +import React, { useState, useEffect } from 'react'; +import styled from "styled-components"; +import { MessageForm } from './components/MessageForm'; +import { MessageCard } from './components/MessageCard'; +import { GlobalStyle } from "./styles/GlobalStyle"; +import { fetchThoughts, postThought, likeThought } from "./api.js"; + +// App - the main component for the application. It handles the list of messages och passes them down function to child components export const App = () => { + + // States that stores all submitted messages, shows when loading and when error + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null) + + // When the app starts, we get thoughts from API. The data is normalized and stored in messages (state). Runs only once when the app loads (empty array []) + useEffect(() => { + const loadThoughts = async () => { + try { + setIsLoading(true); //shows text when loading + setError(null); //reset previous errors + const data = await fetchThoughts(); //calling api.js + + const normalized = data.map((t) => ({ + id: t._id, + text: t.message, + likes: t.hearts, + time: new Date(t.createdAt).getTime(), + })); + + setMessages(normalized); + } catch (err) { + console.error(err); + setError("Could not fetch thoughts. Please try again later."); + } finally { + setIsLoading(false); + } + }; + + loadThoughts(); + }, []); + + // addMessage - this function is called when MessageForm submits next time. It creates a message object and adds it to the start of the list + const addMessage = async (text) => { + try { + const newThought = await postThought({ message: text }); //Sending to API + + //Making the object easier to read in the app by normalizing it. + const normalized = { + id: newThought._id, + text: newThought.message, + likes: newThought.hearts, + time: new Date(newThought.createdAt).getTime() + }; + + setMessages(prev => [normalized, ...prev]); + setError(null); // add the newest message at the top + } catch (err) { + console.error(err); + setError("Could not send your thought. Make sure you have 5-140 characters."); + } + }; + + // Updates the like count for a message both locally and in the API + const handleLike = async (id) => { + setMessages(prev => + prev.map(msg => + msg.id === id ? { ...msg, likes: msg.likes + 1 } : msg + ) + ); + + try { + const updatedThought = await likeThought(id); + setMessages(prev => + prev.map(msg => + msg.id === id ? { ...msg, likes: updatedThought.hearts } : msg + ) + ) + } catch (err) { + console.error("Could not like thought", err); + } + }; + return ( -

Happy Thoughts

- ) + <> + + + + {isLoading &&

Loading thoughts...

} + {error &&

{error}

} + + + {messages.map((msg) => ( + + ))} + +
+ + ); } + +// ===== Styled Components ===== // + +const AppContainer = styled.main` + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 20px; +`; + +const MessageList = styled.section` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + + @media (min-width: 480px) { + gap: 16px; + } +`; + + + diff --git a/src/api.js b/src/api.js new file mode 100644 index 00000000..060acef0 --- /dev/null +++ b/src/api.js @@ -0,0 +1,32 @@ +const BASE_URL = "https://happy-thoughts-api-4ful.onrender.com/thoughts"; + +// GET - fetches the 20 most recent thoughts from the API. A list of messages will be shown as the app loads. +export const fetchThoughts = async () => { + const res = await fetch(BASE_URL); + const data = await res.json(); + return data; +}; + +// POST - creates a new thought. The API wants a JSON body with a "message" property. If it is successful, the API returns a full thought object back. +export const postThought = async (message) => { + const res = await fetch(BASE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(message) + }); + + const data = await res.json(); + return data; +}; + +// POST - Like a thought. This function sends a like to the API using the thoughts ID. The API then sends back an updated thought, including a new heart count. +export const likeThought = async (thoughtId) => { + const res = await fetch(`${BASE_URL}/${thoughtId}/like`, { + method: "POST", + }); + + const data = await res.json(); + return data; +}; diff --git a/src/components/MessageCard.jsx b/src/components/MessageCard.jsx new file mode 100644 index 00000000..4f057831 --- /dev/null +++ b/src/components/MessageCard.jsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +// Shows the time e.g., "2 minutes ago", formatting in dayjs +dayjs.extend(relativeTime); + +// MessageCard - displays a single message with its text, like-button, like-count and relative timestamp +export const MessageCard = ({ message, onLike }) => { + const [tick, setTick] = useState(0); + const [likes, setLikes] = useState(message.likes ?? 0); + + // Update likes when app.js sends new data from API + useEffect(() => { + setLikes(message.likes); + }, [message.likes]); + + // Re-render every 60 sec for "time ago", and cleans up interval on unmount. + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 60000); + return () => clearInterval(interval); + }, []); + + // Increments the like counter when the heard button is pressed, sends to the API + const handleLike = () => { + if (onLike) onLike(message.id); + }; + console.log( + "message.time:", + message.time, + dayjs(message.time).toISOString() + ); + + // Converts timestamp info "x minutes ago" + const timeText = dayjs(message.time).fromNow(); + + return ( + + {message.text} + + + 0} + aria-label="Like this thought" + >❤️ + + {likes} + + + + + ); +}; + +// ===== Styled Components ===== // + +/* With animation for new message */ +const CardWrapper = styled.section` + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border: 1px solid #000; + border-radius: 2px; + box-shadow: 6px 6px 0 #000; + margin: 0 auto; + width: 100%; + max-width: 550px; + opacity: 0; + transform: translateY(-20px) scale(0.95); + animation: fadeIn 0.6s cubic-bezier(0.25, 1, 0.5, 1) forwards; + + @keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + 60% { + opacity: 1; + transform: translateY(5px) scale(1.02); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } +`; + +const MessageText = styled.p` + font-size: 14px; + line-height: 1.4; + margin: 0; +`; + +const CardFooter = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const LikeContainer = styled.div` + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; +`; + +const HeartButton = styled.button` + background: ${(p) => (p.$active ? "#ffb3b3" : "rgba(237, 232, 232, 1)")}; + border-radius: 50%; + padding: 10px; + border: none; + cursor: pointer; + transition: background 120ms ease, transform 120ms ease; + + &:active { + animation: jump 0.8s forwards; + } + + @keyframes jump { + 0% { + transform: translateY(0) scale(1); + } + 30% { + transform: translateY(-15px) scale(1.2); + } + 60% { + transform: translateY(5px) scale(0.9); + } + 100% { + transform: translateY(0) scale(1); + } + } +`; + +const LikesCount = styled.span` + font-size: 14px; + margin-left: 6px; +`; + +const Time = styled.span` + font-size: 12px; + color: color: #333; +`; \ No newline at end of file diff --git a/src/components/MessageForm.jsx b/src/components/MessageForm.jsx new file mode 100644 index 00000000..af20e5c3 --- /dev/null +++ b/src/components/MessageForm.jsx @@ -0,0 +1,125 @@ +import React, { useState } from "react"; +import styled from "styled-components"; + +// MessageForm (component) with props onSend (function) This will be called when the user writes a message. +export const MessageForm = ({ onSend }) => { + const [message, setMessage] = useState(""); //useState = hook + const MAX_CHARS = 140; + const [error, setError] = useState(null); + + // setMessage (function) is used to update the value in message (variable). handleSubmit runs when the user clicks on submit-button . After send, the message-area is cleared. + const handleSubmit = (e) => { + e.preventDefault(); + if (!message.trim()) { + setError("You need to write something!"); + return; + } + if (message.length < 5) { + setError("You have to have more than 5 characters!"); + return; + } + + onSend(message); + setMessage(""); + setError(null); + }; + + // When the user writes in the textarea, this function runs. If its to long, cut it by 140 letters + const handleChange = (e) => { + const value = e.target.value.slice(0, MAX_CHARS); + setMessage(value); + }; + + // Variables for UI, counter and warning about too many characters + const chars = message.length; + const isOverLimit = chars >= MAX_CHARS; + const charsLeft = MAX_CHARS - message.length; + + // FORM. handleSubmit runs when the user clicks the button or presses Enter. All controlled input values come from React state (message)*/ + return ( + + + + {/* Controlled textarea. Value comes from state and updates via handleChange */} +