diff --git a/README.md b/README.md index 41ebece2..cd930a74 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ # Happy Thoughts + + +https://happyping.netlify.app/ diff --git a/index.html b/index.html index d4492e94..6961d983 100644 --- a/index.html +++ b/index.html @@ -2,15 +2,16 @@ - + Happy Thoughts +
- + diff --git a/package.json b/package.json index 2f66d295..e0a541b6 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,24 @@ "preview": "vite preview" }, "dependencies": { + "jwt-decode": "^4.0.0", + "lucide-react": "^0.503.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@tailwindcss/postcss": "^4.1.4", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.21", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", "vite": "^6.2.0" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/ding.wav b/public/ding.wav new file mode 100644 index 00000000..1e0fd88d Binary files /dev/null and b/public/ding.wav differ diff --git a/public/heart.svg b/public/heart.svg new file mode 100644 index 00000000..9bfc1d17 --- /dev/null +++ b/public/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/heartbeat.wav b/public/heartbeat.wav new file mode 100644 index 00000000..bd95eaaa Binary files /dev/null and b/public/heartbeat.wav differ diff --git a/public/love.m4a b/public/love.m4a new file mode 100644 index 00000000..8a2e7aea Binary files /dev/null and b/public/love.m4a differ diff --git a/public/pling.m4a b/public/pling.m4a new file mode 100644 index 00000000..37da9334 Binary files /dev/null and b/public/pling.m4a differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 07f2cbdf..00000000 --- a/src/App.jsx +++ /dev/null @@ -1,5 +0,0 @@ -export const App = () => { - return ( -

Happy Thoughts

- ) -} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..d6448250 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,283 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { jwtDecode } from "jwt-decode"; +import ThoughtForm from "./components/ThoughtForm"; +import ThoughtList from "./components/ThoughtList"; +import Spinner from "./components/Spinner"; +import MyLikedThoughts from "./components/MyLikedThoughts"; +import LoginForm from "./components/LoginForm"; +import SignupForm from "./components/SignupForm"; + +export type Thought = { + id: string; + message: string; + likes: number; + timestamp: Date; + createdBy: string; +}; + +type DecodedToken = { + id: string; + exp: number; + iat: number; +}; + +const API_BASE = "https://happy-thoughts-api-5hw3.onrender.com"; +const plingSound = "/ding.wav"; + +export default function App() { + const [token, setToken] = useState( + localStorage.getItem("token") + ); + const [isLoggedIn, setIsLoggedIn] = useState(!!token); + const [loading, setLoading] = useState(false); + const [thoughts, setThoughts] = useState([]); + const [likedThoughtIds, setLikedThoughtIds] = useState([]); + const [posting, setPosting] = useState(false); + const [showLogin, setShowLogin] = useState(false); + const [showSignup, setShowSignup] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + + const login = (newToken: string) => { + localStorage.setItem("token", newToken); + setToken(newToken); + setIsLoggedIn(true); + }; + + const logout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("likedThoughts"); + setToken(null); + setIsLoggedIn(false); + setCurrentUserId(null); + setLikedThoughtIds([]); + }; + + useEffect(() => { + const fetchThoughts = async () => { + setLoading(true); + try { + const response = await fetch(`${API_BASE}/thoughts`); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + const transformedData: Thought[] = data.thoughts.map((item: any) => ({ + id: item._id, + message: item.message, + likes: item.hearts, + timestamp: new Date(item.createdAt), + createdBy: item.createdBy, + })); + + setThoughts(transformedData); + } catch (error) { + console.error("Error fetching thoughts:", error); + } finally { + setLoading(false); + } + }; + + fetchThoughts(); + + const stored = localStorage.getItem("likedThoughts"); + if (stored) { + setLikedThoughtIds(JSON.parse(stored)); + } + }, []); + + useEffect(() => { + if (token) { + try { + const decoded = jwtDecode(token); + setCurrentUserId(decoded.id); + } catch (err) { + console.error("Invalid token", err); + setCurrentUserId(null); + } + } + }, [token]); + + useEffect(() => { + if (isLoggedIn) { + setShowLogin(false); + setShowSignup(false); + } + }, [isLoggedIn]); + + const addThought = async (message: string) => { + if (!token) { + alert("Please log in to post a thought."); + return; + } + + setPosting(true); + try { + const response = await fetch(`${API_BASE}/thoughts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ message }), + }); + + if (!response.ok) { + console.error(`HTTP error! Status: ${response.status}`); + } else { + const newThoughtData = await response.json(); + const newThought: Thought = { + id: newThoughtData._id, + message: newThoughtData.message, + likes: newThoughtData.hearts, + timestamp: new Date(newThoughtData.createdAt), + createdBy: newThoughtData.createdBy, + }; + setThoughts([newThought, ...thoughts]); + + const audio = new Audio(plingSound); + audio.volume = 0.2; + audio.play(); + } + } catch (error: any) { + console.error("Error posting thought:", error); + } finally { + setPosting(false); + } + }; + + const handleEdit = async (id: string, newMessage: string) => { + try { + const response = await fetch(`${API_BASE}/thoughts/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ message: newMessage }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const updated = await response.json(); + + setThoughts((prev) => + prev.map((thought) => + thought.id === id + ? { + ...thought, + message: updated.message || newMessage, + timestamp: new Date(updated.updatedAt || thought.timestamp), + } + : thought + ) + ); + } catch (error) { + console.error("Failed to edit thought", error); + } + }; + + const handleDelete = async (id: string) => { + try { + const response = await fetch(`${API_BASE}/thoughts/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + setThoughts((prev) => prev.filter((thought) => thought.id !== id)); + } catch (error) { + console.error("Failed to delete thought", error); + } + }; + + const handleLike = async (id: string) => { + setThoughts((prev) => + prev.map((thought) => + thought.id === id ? { ...thought, likes: thought.likes + 1 } : thought + ) + ); + + try { + await fetch(`${API_BASE}/thoughts/${id}/like`, { + method: "PATCH", + }); + } catch (error) { + console.error("Failed to send like to API", error); + } + + setLikedThoughtIds((prev) => { + if (prev.includes(id)) return prev; + const updated = [...prev, id]; + localStorage.setItem("likedThoughts", JSON.stringify(updated)); + return updated; + }); + }; + + return ( +
+ + + {!isLoggedIn && showLogin && } + {!isLoggedIn && showSignup && } + + + + {loading ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/src/components/LikeCounter.tsx b/src/components/LikeCounter.tsx new file mode 100644 index 00000000..418a0de4 --- /dev/null +++ b/src/components/LikeCounter.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +type LikeCounterProps = { + count: number; +}; + +function LikeCounter({ count }: LikeCounterProps) { + return ( + <> + + {count === 1 ? "1 like" : `${count} likes`} + + + + ); +} + +export default LikeCounter; diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 00000000..34291e67 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; + +interface Props { + onLogin: (token: string) => void; +} + +const API = "https://happy-thoughts-api-5hw3.onrender.com"; + +export default function LoginForm({ onLogin }: Props) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + try { + const response = await fetch(`${API}/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || "Login failed"); + } else { + localStorage.setItem("token", data.token); + onLogin(data.token); + } + } catch (err) { + setError("Something went wrong."); + } + }; + + return ( +
+

Log in

+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + {error &&

{error}

} +
+ ); +} diff --git a/src/components/MyLikedThoughts.tsx b/src/components/MyLikedThoughts.tsx new file mode 100644 index 00000000..51e39cfb --- /dev/null +++ b/src/components/MyLikedThoughts.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Thought } from "../App"; + +type MyLikedThoughtsProps = { + likedThoughtIds: string[]; + thoughts: Thought[]; +}; + +export default function MyLikedThoughts({ + likedThoughtIds = [], + thoughts = [], +}: MyLikedThoughtsProps) { + const likedThoughts = thoughts.filter((t) => likedThoughtIds.includes(t.id)); + + return ( +
+ You’ve liked {likedThoughts.length}{" "} + {likedThoughts.length === 1 ? "thought" : "thoughts"} +
+ ); +} diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx new file mode 100644 index 00000000..b65fb574 --- /dev/null +++ b/src/components/SignupForm.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; + +interface Props { + onSignup: (token: string) => void; +} + +const API = "https://happy-thoughts-api-5hw3.onrender.com"; + +export default function SignupForm({ onSignup }: Props) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + try { + const response = await fetch(`${API}/users/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || "Signup failed"); + } else { + localStorage.setItem("token", data.token); + onSignup(data.token); + } + } catch (err) { + setError("Something went wrong."); + } + }; + + return ( +
+

Create Account

+ setEmail(e.target.value)} + required + /> + + {error && ( +

+ {error} +

+ )} + + setPassword(e.target.value)} + required + /> + +
+ ); +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 00000000..76b3dc97 --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Heart } from "lucide-react"; + +function Spinner() { + const placeholders = Array.from({ length: 3 }); + + return ( +
+ {placeholders.map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export default Spinner; diff --git a/src/components/ThoughtForm.tsx b/src/components/ThoughtForm.tsx new file mode 100644 index 00000000..cf186e4a --- /dev/null +++ b/src/components/ThoughtForm.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React from "react"; + +import { useState, useEffect } from "react"; +import { Heart } from "lucide-react"; + +interface ThoughtFormProps { + onSubmit: (message: string) => void; + isPosting: boolean; +} + +export default function ThoughtForm({ onSubmit, isPosting }: ThoughtFormProps) { + const [message, setMessage] = useState(""); + const [error, setError] = useState(null); + + const MAX_LENGTH = 140; + const MIN_LENGTH = 5; + + useEffect(() => { + if (isPosting) { + setError(null); + } + }, [isPosting]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (message.trim().length < MIN_LENGTH) { + setError( + `Your message is too short. Please write at least ${MIN_LENGTH} characters.` + ); + return; + } + + if (message.length > MAX_LENGTH) { + setError( + `Your message is too long. Please keep it under ${MAX_LENGTH} characters.` + ); + return; + } + + onSubmit(message); + setMessage(""); + setError(null); + }; + + const handleChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + if (error) setError(null); + }; + + const charactersLeft = MAX_LENGTH - message.length; + const isOverLimit = charactersLeft < 0; + + return ( +
+
+

+ Is something bringing you happiness right now? +

+ +