From bdbf1956df511db6fedfbb09f0cd4cf8b9faa5ef Mon Sep 17 00:00:00 2001
From: "Eugene \"Pebbles\" Akiwumi" <62018288+akiwumi@users.noreply.github.com>
Date: Thu, 4 Dec 2025 13:04:42 +0100
Subject: [PATCH 1/3] Add character counter, error handling, animations, and
design system matching image
---
src/App.css | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/App.jsx | 189 +++++++++++++++++++++++++++++++++++++-
src/index.css | 15 ++-
3 files changed, 448 insertions(+), 2 deletions(-)
create mode 100644 src/App.css
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 00000000..a518f7e9
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,246 @@
+.app {
+ max-width: 500px;
+ margin: 40px auto;
+ padding: 0 20px;
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+}
+
+/* Form Styles */
+.thought-form {
+ background-color: #f2f0f0;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
+ border-radius: 0;
+}
+
+.form-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 12px 0;
+ color: #000;
+}
+
+.thought-input {
+ width: 100%;
+ min-height: 80px;
+ padding: 12px;
+ border: 1px solid #d1d1d1;
+ background-color: #fff;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ box-sizing: border-box;
+ margin-bottom: 12px;
+}
+
+.thought-input:focus {
+ outline: 2px solid #ffadad;
+ outline-offset: 2px;
+ border-color: #ffadad;
+}
+
+.thought-input:disabled {
+ background-color: #f5f5f5;
+ cursor: not-allowed;
+}
+
+.form-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.char-count-container {
+ flex: 1;
+ min-width: 150px;
+}
+
+.char-count {
+ font-size: 12px;
+ color: #666;
+}
+
+.char-count-over {
+ color: #ff0000;
+ font-weight: 600;
+}
+
+.submit-button {
+ background-color: #ffadad;
+ color: #000;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 25px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ box-shadow: 0 0 8px rgba(255, 0, 0, 0.3);
+ transition: all 0.2s ease;
+}
+
+.submit-button:hover:not(:disabled) {
+ background-color: #ff9d9d;
+ box-shadow: 0 0 12px rgba(255, 0, 0, 0.4);
+ transform: translateY(-1px);
+}
+
+.submit-button:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.submit-button:disabled {
+ background-color: #d1d1d1;
+ cursor: not-allowed;
+ box-shadow: none;
+ opacity: 0.6;
+}
+
+.heart-emoji {
+ font-size: 14px;
+}
+
+.error-message {
+ margin-top: 12px;
+ padding: 10px;
+ background-color: #ffe0e0;
+ border: 1px solid #ffadad;
+ border-radius: 4px;
+ color: #d32f2f;
+ font-size: 13px;
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Thoughts List */
+.thoughts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.loading {
+ text-align: center;
+ padding: 40px;
+ color: #666;
+}
+
+.thought-card {
+ background-color: #f2f0f0;
+ padding: 20px;
+ box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
+ border-radius: 0;
+ animation: fadeInUp 0.5s ease;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.thought-content {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.thought-message {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: #000;
+ word-wrap: break-word;
+}
+
+.thought-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.like-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+}
+
+.like-button:hover {
+ background-color: rgba(255, 173, 173, 0.2);
+}
+
+.like-icon {
+ background-color: #fff;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.heart-emoji-small {
+ font-size: 14px;
+ line-height: 1;
+}
+
+.like-count {
+ font-size: 13px;
+ color: #000;
+ font-weight: 500;
+}
+
+.thought-time {
+ font-size: 12px;
+ color: #666;
+}
+
+/* Responsive */
+@media (max-width: 600px) {
+ .app {
+ margin: 20px auto;
+ padding: 0 15px;
+ }
+
+ .form-footer {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .char-count-container {
+ text-align: center;
+ }
+
+ .submit-button {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
diff --git a/src/App.jsx b/src/App.jsx
index 07f2cbdf..f7605951 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,5 +1,192 @@
+import { useState, useEffect } from 'react'
+import './App.css'
+
+const API_URL = 'https://happy-thoughts-technigo.herokuapp.com/thoughts'
+
export const App = () => {
+ const [thoughts, setThoughts] = useState([])
+ const [newThought, setNewThought] = useState('')
+ const [error, setError] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const maxLength = 140
+ const minLength = 5
+ const remainingChars = maxLength - newThought.length
+ const isOverLimit = newThought.length > maxLength
+
+ useEffect(() => {
+ fetchThoughts()
+ }, [])
+
+ const fetchThoughts = async () => {
+ setIsLoading(true)
+ try {
+ const response = await fetch(API_URL)
+ const data = await response.json()
+ setThoughts(data)
+ } catch (err) {
+ setError('Failed to load thoughts. Please try again later.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+
+ // Validation
+ if (!newThought.trim()) {
+ setError('Your thought cannot be empty. Please write something!')
+ return
+ }
+
+ if (newThought.length < minLength) {
+ setError(`Your thought is too short. Please write at least ${minLength} characters.`)
+ return
+ }
+
+ if (newThought.length > maxLength) {
+ setError(`Your thought is too long. Please keep it under ${maxLength} characters.`)
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const response = await fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ message: newThought }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ // Handle API error messages
+ if (data.errors) {
+ if (data.errors.message) {
+ setError(data.errors.message)
+ } else {
+ setError('Something went wrong. Please try again.')
+ }
+ } else {
+ setError('Something went wrong. Please try again.')
+ }
+ return
+ }
+
+ // Clear input and add new thought to the list with animation
+ setNewThought('')
+ setThoughts([data, ...thoughts])
+ } catch (err) {
+ setError('Failed to post your thought. Please check your connection and try again.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleLike = async (thoughtId) => {
+ try {
+ const response = await fetch(`${API_URL}/${thoughtId}/like`, {
+ method: 'POST',
+ })
+
+ if (response.ok) {
+ const updatedThought = await response.json()
+ setThoughts((prevThoughts) =>
+ prevThoughts.map((thought) =>
+ thought._id === thoughtId ? updatedThought : thought
+ )
+ )
+ }
+ } catch (err) {
+ // Silently fail for likes
+ console.error('Failed to like thought:', err)
+ }
+ }
+
+ const formatTimeAgo = (createdAt) => {
+ const now = new Date()
+ const created = new Date(createdAt)
+ const seconds = Math.floor((now - created) / 1000)
+
+ if (seconds < 60) {
+ return `${seconds} second${seconds !== 1 ? 's' : ''} ago`
+ }
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) {
+ return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`
+ }
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) {
+ return `${hours} hour${hours !== 1 ? 's' : ''} ago`
+ }
+ const days = Math.floor(hours / 24)
+ return `${days} day${days !== 1 ? 's' : ''} ago`
+ }
+
return (
-
Happy Thoughts
+
+
+
+
+ {isLoading ? (
+
Loading thoughts...
+ ) : (
+ thoughts.map((thought) => (
+
+
+
{thought.message}
+
+
+ {formatTimeAgo(thought.createdAt)}
+
+
+
+ ))
+ )}
+
+
)
}
diff --git a/src/index.css b/src/index.css
index f7c0aef5..85ad7988 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,16 @@
-:root {
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ background-color: #fff;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100vh;
}
From fc05d8144281c460062b4bcb76d7dab09eeeb43d Mon Sep 17 00:00:00 2001
From: "Eugene \"Pebbles\" Akiwumi" <62018288+akiwumi@users.noreply.github.com>
Date: Thu, 4 Dec 2025 13:23:47 +0100
Subject: [PATCH 2/3] Rebuild app with component structure and add mock API for
local development
---
src/App.css | 229 +--------------------------------
src/App.jsx | 179 +++++++-------------------
src/components/ThoughtCard.css | 82 ++++++++++++
src/components/ThoughtCard.jsx | 26 ++++
src/components/ThoughtForm.css | 137 ++++++++++++++++++++
src/components/ThoughtForm.jsx | 59 +++++++++
src/components/ThoughtList.css | 15 +++
src/components/ThoughtList.jsx | 29 +++++
src/components/index.js | 4 +
src/services/api.js | 91 +++++++++++++
src/services/mockApi.js | 139 ++++++++++++++++++++
src/utils/timeUtils.js | 20 +++
12 files changed, 655 insertions(+), 355 deletions(-)
create mode 100644 src/components/ThoughtCard.css
create mode 100644 src/components/ThoughtCard.jsx
create mode 100644 src/components/ThoughtForm.css
create mode 100644 src/components/ThoughtForm.jsx
create mode 100644 src/components/ThoughtList.css
create mode 100644 src/components/ThoughtList.jsx
create mode 100644 src/components/index.js
create mode 100644 src/services/api.js
create mode 100644 src/services/mockApi.js
create mode 100644 src/utils/timeUtils.js
diff --git a/src/App.css b/src/App.css
index a518f7e9..81264a6c 100644
--- a/src/App.css
+++ b/src/App.css
@@ -5,242 +5,27 @@
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
}
-/* Form Styles */
-.thought-form {
- background-color: #f2f0f0;
- padding: 20px;
- margin-bottom: 20px;
- box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
- border-radius: 0;
-}
-
-.form-title {
- font-size: 16px;
- font-weight: 600;
- margin: 0 0 12px 0;
- color: #000;
-}
-
-.thought-input {
- width: 100%;
- min-height: 80px;
- padding: 12px;
- border: 1px solid #d1d1d1;
- background-color: #fff;
- font-family: inherit;
- font-size: 14px;
- resize: vertical;
- box-sizing: border-box;
- margin-bottom: 12px;
-}
-
-.thought-input:focus {
- outline: 2px solid #ffadad;
- outline-offset: 2px;
- border-color: #ffadad;
-}
-
-.thought-input:disabled {
- background-color: #f5f5f5;
- cursor: not-allowed;
-}
-
-.form-footer {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 12px;
-}
-
-.char-count-container {
- flex: 1;
- min-width: 150px;
-}
-
-.char-count {
- font-size: 12px;
- color: #666;
-}
-
-.char-count-over {
- color: #ff0000;
- font-weight: 600;
-}
-
-.submit-button {
- background-color: #ffadad;
- color: #000;
- border: none;
- padding: 10px 20px;
- border-radius: 25px;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- box-shadow: 0 0 8px rgba(255, 0, 0, 0.3);
- transition: all 0.2s ease;
-}
-
-.submit-button:hover:not(:disabled) {
- background-color: #ff9d9d;
- box-shadow: 0 0 12px rgba(255, 0, 0, 0.4);
- transform: translateY(-1px);
-}
-
-.submit-button:active:not(:disabled) {
- transform: translateY(0);
-}
-
-.submit-button:disabled {
- background-color: #d1d1d1;
- cursor: not-allowed;
- box-shadow: none;
- opacity: 0.6;
-}
-
-.heart-emoji {
- font-size: 14px;
-}
-
-.error-message {
- margin-top: 12px;
- padding: 10px;
+.api-error-message {
background-color: #ffe0e0;
border: 1px solid #ffadad;
border-radius: 4px;
+ padding: 15px;
+ margin-bottom: 20px;
color: #d32f2f;
- font-size: 13px;
- animation: slideIn 0.3s ease;
-}
-
-@keyframes slideIn {
- from {
- opacity: 0;
- transform: translateY(-10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Thoughts List */
-.thoughts-list {
- display: flex;
- flex-direction: column;
- gap: 20px;
-}
-
-.loading {
- text-align: center;
- padding: 40px;
- color: #666;
-}
-
-.thought-card {
- background-color: #f2f0f0;
- padding: 20px;
- box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
- border-radius: 0;
- animation: fadeInUp 0.5s ease;
-}
-
-@keyframes fadeInUp {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-.thought-content {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.thought-message {
- margin: 0;
font-size: 14px;
- line-height: 1.5;
- color: #000;
- word-wrap: break-word;
-}
-
-.thought-footer {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.like-button {
- display: flex;
- align-items: center;
- gap: 6px;
- background: none;
- border: none;
- cursor: pointer;
- padding: 4px 8px;
- border-radius: 4px;
- transition: background-color 0.2s ease;
-}
-
-.like-button:hover {
- background-color: rgba(255, 173, 173, 0.2);
-}
-
-.like-icon {
- background-color: #fff;
- width: 32px;
- height: 32px;
- border-radius: 50%;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-}
-
-.heart-emoji-small {
- font-size: 14px;
- line-height: 1;
-}
-
-.like-count {
- font-size: 13px;
- color: #000;
- font-weight: 500;
+ box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
}
-.thought-time {
+.api-error-hint {
+ margin: 10px 0 0 0;
font-size: 12px;
color: #666;
+ font-style: italic;
}
-/* Responsive */
@media (max-width: 600px) {
.app {
margin: 20px auto;
padding: 0 15px;
}
-
- .form-footer {
- flex-direction: column;
- align-items: stretch;
- }
-
- .char-count-container {
- text-align: center;
- }
-
- .submit-button {
- width: 100%;
- justify-content: center;
- }
}
-
diff --git a/src/App.jsx b/src/App.jsx
index f7605951..4f082395 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,88 +1,62 @@
import { useState, useEffect } from 'react'
+import { ThoughtForm, ThoughtList } from './components'
+import { fetchThoughts, postThought, likeThought } from './services/api'
import './App.css'
-const API_URL = 'https://happy-thoughts-technigo.herokuapp.com/thoughts'
+const MIN_LENGTH = 5
+const MAX_LENGTH = 140
export const App = () => {
const [thoughts, setThoughts] = useState([])
- const [newThought, setNewThought] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
- const maxLength = 140
- const minLength = 5
- const remainingChars = maxLength - newThought.length
- const isOverLimit = newThought.length > maxLength
-
useEffect(() => {
- fetchThoughts()
+ loadThoughts()
}, [])
- const fetchThoughts = async () => {
+ const loadThoughts = async () => {
setIsLoading(true)
+ setError('')
try {
- const response = await fetch(API_URL)
- const data = await response.json()
+ const data = await fetchThoughts()
setThoughts(data)
} catch (err) {
- setError('Failed to load thoughts. Please try again later.')
+ const errorMsg = err.message || 'Failed to load thoughts. Please try again later.'
+ setError(errorMsg)
+ console.error('Error loading thoughts:', err)
} finally {
setIsLoading(false)
}
}
- const handleSubmit = async (e) => {
- e.preventDefault()
+ const handleSubmit = async (message) => {
setError('')
// Validation
- if (!newThought.trim()) {
+ if (!message.trim()) {
setError('Your thought cannot be empty. Please write something!')
return
}
- if (newThought.length < minLength) {
- setError(`Your thought is too short. Please write at least ${minLength} characters.`)
+ if (message.length < MIN_LENGTH) {
+ setError(`Your thought is too short. Please write at least ${MIN_LENGTH} characters.`)
return
}
- if (newThought.length > maxLength) {
- setError(`Your thought is too long. Please keep it under ${maxLength} characters.`)
+ if (message.length > MAX_LENGTH) {
+ setError(`Your thought is too long. Please keep it under ${MAX_LENGTH} characters.`)
return
}
setIsSubmitting(true)
try {
- const response = await fetch(API_URL, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ message: newThought }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- // Handle API error messages
- if (data.errors) {
- if (data.errors.message) {
- setError(data.errors.message)
- } else {
- setError('Something went wrong. Please try again.')
- }
- } else {
- setError('Something went wrong. Please try again.')
- }
- return
- }
-
- // Clear input and add new thought to the list with animation
- setNewThought('')
- setThoughts([data, ...thoughts])
+ const newThought = await postThought(message)
+ setThoughts([newThought, ...thoughts])
+ setError('')
} catch (err) {
- setError('Failed to post your thought. Please check your connection and try again.')
+ setError(err.message || 'Failed to post your thought. Please check your connection and try again.')
} finally {
setIsSubmitting(false)
}
@@ -90,103 +64,42 @@ export const App = () => {
const handleLike = async (thoughtId) => {
try {
- const response = await fetch(`${API_URL}/${thoughtId}/like`, {
- method: 'POST',
- })
-
- if (response.ok) {
- const updatedThought = await response.json()
- setThoughts((prevThoughts) =>
- prevThoughts.map((thought) =>
- thought._id === thoughtId ? updatedThought : thought
- )
+ const updatedThought = await likeThought(thoughtId)
+ setThoughts((prevThoughts) =>
+ prevThoughts.map((thought) =>
+ thought._id === thoughtId ? updatedThought : thought
)
- }
+ )
} catch (err) {
// Silently fail for likes
console.error('Failed to like thought:', err)
}
}
- const formatTimeAgo = (createdAt) => {
- const now = new Date()
- const created = new Date(createdAt)
- const seconds = Math.floor((now - created) / 1000)
-
- if (seconds < 60) {
- return `${seconds} second${seconds !== 1 ? 's' : ''} ago`
- }
- const minutes = Math.floor(seconds / 60)
- if (minutes < 60) {
- return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`
- }
- const hours = Math.floor(minutes / 60)
- if (hours < 24) {
- return `${hours} hour${hours !== 1 ? 's' : ''} ago`
- }
- const days = Math.floor(hours / 24)
- return `${days} day${days !== 1 ? 's' : ''} ago`
- }
+ // Separate form errors from API errors
+ const formError = error && (error.includes('empty') || error.includes('short') || error.includes('long') || error.includes('post'))
+ const apiError = error && !formError
return (
-