diff --git a/README.md b/README.md
index 41ebece2..9dea1d32 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,2 @@
# Happy Thoughts
+netlyfy link https://pebbleshappy-app.netlify.app
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 00000000..81264a6c
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,31 @@
+.app {
+ max-width: 500px;
+ margin: 40px auto;
+ padding: 0 20px;
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+}
+
+.api-error-message {
+ background-color: #ffe0e0;
+ border: 1px solid #ffadad;
+ border-radius: 4px;
+ padding: 15px;
+ margin-bottom: 20px;
+ color: #d32f2f;
+ font-size: 14px;
+ box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
+}
+
+.api-error-hint {
+ margin: 10px 0 0 0;
+ font-size: 12px;
+ color: #666;
+ font-style: italic;
+}
+
+@media (max-width: 600px) {
+ .app {
+ margin: 20px auto;
+ padding: 0 15px;
+ }
+}
diff --git a/src/App.jsx b/src/App.jsx
index 07f2cbdf..4f082395 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,5 +1,105 @@
+import { useState, useEffect } from 'react'
+import { ThoughtForm, ThoughtList } from './components'
+import { fetchThoughts, postThought, likeThought } from './services/api'
+import './App.css'
+
+const MIN_LENGTH = 5
+const MAX_LENGTH = 140
+
export const App = () => {
+ const [thoughts, setThoughts] = useState([])
+ const [error, setError] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ useEffect(() => {
+ loadThoughts()
+ }, [])
+
+ const loadThoughts = async () => {
+ setIsLoading(true)
+ setError('')
+ try {
+ const data = await fetchThoughts()
+ setThoughts(data)
+ } catch (err) {
+ 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 (message) => {
+ setError('')
+
+ // Validation
+ if (!message.trim()) {
+ setError('Your thought cannot be empty. Please write something!')
+ return
+ }
+
+ if (message.length < MIN_LENGTH) {
+ setError(`Your thought is too short. Please write at least ${MIN_LENGTH} characters.`)
+ return
+ }
+
+ if (message.length > MAX_LENGTH) {
+ setError(`Your thought is too long. Please keep it under ${MAX_LENGTH} characters.`)
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const newThought = await postThought(message)
+ setThoughts([newThought, ...thoughts])
+ setError('')
+ } catch (err) {
+ setError(err.message || 'Failed to post your thought. Please check your connection and try again.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleLike = async (thoughtId) => {
+ try {
+ 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)
+ }
+ }
+
+ // 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 (
-
Happy Thoughts
+
+
+ {apiError && (
+
+ {error}
+
+ Note: The Heroku API endpoint may be unavailable. You may need to update the API_URL in src/services/api.js to use an alternative endpoint.
+
+
+ )}
+
+
)
}
diff --git a/src/components/ThoughtCard.css b/src/components/ThoughtCard.css
new file mode 100644
index 00000000..12daa562
--- /dev/null
+++ b/src/components/ThoughtCard.css
@@ -0,0 +1,82 @@
+.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;
+}
+
diff --git a/src/components/ThoughtCard.jsx b/src/components/ThoughtCard.jsx
new file mode 100644
index 00000000..ba78fdf2
--- /dev/null
+++ b/src/components/ThoughtCard.jsx
@@ -0,0 +1,26 @@
+import { formatTimeAgo } from '../utils/timeUtils'
+import './ThoughtCard.css'
+
+export const ThoughtCard = ({ thought, onLike }) => {
+ return (
+
+
+
{thought.message}
+
+
+ {formatTimeAgo(thought.createdAt)}
+
+
+
+ )
+}
+
diff --git a/src/components/ThoughtForm.css b/src/components/ThoughtForm.css
new file mode 100644
index 00000000..7c4e0dbe
--- /dev/null
+++ b/src/components/ThoughtForm.css
@@ -0,0 +1,137 @@
+.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);
+ }
+}
+
+@media (max-width: 600px) {
+ .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/components/ThoughtForm.jsx b/src/components/ThoughtForm.jsx
new file mode 100644
index 00000000..1bd02c54
--- /dev/null
+++ b/src/components/ThoughtForm.jsx
@@ -0,0 +1,59 @@
+import { useState } from 'react'
+import './ThoughtForm.css'
+
+const MAX_LENGTH = 140
+const MIN_LENGTH = 5
+
+export const ThoughtForm = ({ onSubmit, isSubmitting, error }) => {
+ const [newThought, setNewThought] = useState('')
+
+ const remainingChars = MAX_LENGTH - newThought.length
+ const isOverLimit = newThought.length > MAX_LENGTH
+ const isValid = newThought.trim().length >= MIN_LENGTH && newThought.length <= MAX_LENGTH
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+
+ if (!isValid) {
+ return
+ }
+
+ await onSubmit(newThought)
+ setNewThought('')
+ }
+
+ const handleChange = (e) => {
+ setNewThought(e.target.value)
+ }
+
+ return (
+
+ )
+}
+
diff --git a/src/components/ThoughtList.css b/src/components/ThoughtList.css
new file mode 100644
index 00000000..6d989295
--- /dev/null
+++ b/src/components/ThoughtList.css
@@ -0,0 +1,15 @@
+.thoughts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.loading,
+.empty-state {
+ text-align: center;
+ padding: 40px;
+ color: #666;
+ background-color: #f2f0f0;
+ box-shadow: 2px 2px 0 0 rgba(0, 0, 0, 0.1);
+}
+
diff --git a/src/components/ThoughtList.jsx b/src/components/ThoughtList.jsx
new file mode 100644
index 00000000..92759d1c
--- /dev/null
+++ b/src/components/ThoughtList.jsx
@@ -0,0 +1,29 @@
+import { ThoughtCard } from './ThoughtCard'
+import './ThoughtList.css'
+
+export const ThoughtList = ({ thoughts, isLoading, onLike }) => {
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (thoughts.length === 0) {
+ return (
+
+
No thoughts yet. Be the first to share!
+
+ )
+ }
+
+ return (
+
+ {thoughts.map((thought) => (
+
+ ))}
+
+ )
+}
+
diff --git a/src/components/index.js b/src/components/index.js
new file mode 100644
index 00000000..7bde5190
--- /dev/null
+++ b/src/components/index.js
@@ -0,0 +1,4 @@
+export { ThoughtForm } from './ThoughtForm'
+export { ThoughtCard } from './ThoughtCard'
+export { ThoughtList } from './ThoughtList'
+
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;
}
diff --git a/src/services/api.js b/src/services/api.js
new file mode 100644
index 00000000..42c12ed9
--- /dev/null
+++ b/src/services/api.js
@@ -0,0 +1,91 @@
+// API Configuration
+// Using mock API for development (stores data in localStorage)
+// To switch back to real API, set USE_MOCK_API to false and update API_URL
+import { mockApi } from './mockApi.js'
+
+const USE_MOCK_API = true // Set to false to use real API
+const API_URL = 'https://happy-thoughts-technigo.herokuapp.com/thoughts'
+
+// Real API implementation
+const handleResponse = async (response) => {
+ if (!response.ok) {
+ let errorMessage = 'Something went wrong'
+ try {
+ const data = await response.json()
+ errorMessage = data.errors?.message || data.message || errorMessage
+ } catch {
+ errorMessage = `Server error: ${response.status} ${response.statusText}`
+ }
+ throw new Error(errorMessage)
+ }
+ return response.json()
+}
+
+const fetchThoughtsReal = async () => {
+ try {
+ const response = await fetch(API_URL)
+ return await handleResponse(response)
+ } catch (err) {
+ if (err.name === 'TypeError' && err.message.includes('fetch')) {
+ throw new Error('Network error: Unable to connect to the server. Please check your internet connection.')
+ }
+ throw err
+ }
+}
+
+const postThoughtReal = async (message) => {
+ try {
+ const response = await fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ message }),
+ })
+
+ return await handleResponse(response)
+ } catch (err) {
+ if (err.name === 'TypeError' && err.message.includes('fetch')) {
+ throw new Error('Network error: Unable to connect to the server. Please check your internet connection.')
+ }
+ throw err
+ }
+}
+
+const likeThoughtReal = async (thoughtId) => {
+ try {
+ const response = await fetch(`${API_URL}/${thoughtId}/like`, {
+ method: 'POST',
+ })
+
+ return await handleResponse(response)
+ } catch (err) {
+ if (err.name === 'TypeError' && err.message.includes('fetch')) {
+ throw new Error('Network error: Unable to like thought. Please check your internet connection.')
+ }
+ throw err
+ }
+}
+
+// Export functions that use either mock or real API
+export const fetchThoughts = USE_MOCK_API
+ ? () => mockApi.fetchThoughts()
+ : fetchThoughtsReal
+
+export const postThought = USE_MOCK_API
+ ? (message) => {
+ try {
+ return mockApi.postThought(message)
+ } catch (err) {
+ // Format error to match real API error structure
+ const error = new Error(err.errors?.message || err.message || 'Failed to post thought')
+ error.errors = err.errors
+ throw error
+ }
+ }
+ : postThoughtReal
+
+export const likeThought = USE_MOCK_API
+ ? (thoughtId) => mockApi.likeThought(thoughtId)
+ : likeThoughtReal
+
diff --git a/src/services/mockApi.js b/src/services/mockApi.js
new file mode 100644
index 00000000..54357c7e
--- /dev/null
+++ b/src/services/mockApi.js
@@ -0,0 +1,139 @@
+// Mock API for Happy Thoughts
+// This simulates the backend API and stores data in localStorage for persistence
+
+const STORAGE_KEY = 'happy-thoughts-mock-data'
+
+// Initialize mock data from localStorage or use default
+const getStoredThoughts = () => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) {
+ return JSON.parse(stored)
+ }
+ } catch (err) {
+ console.error('Error reading from localStorage:', err)
+ }
+ return []
+}
+
+// Save thoughts to localStorage
+const saveThoughts = (thoughts) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(thoughts))
+ } catch (err) {
+ console.error('Error saving to localStorage:', err)
+ }
+}
+
+// Generate a unique ID
+const generateId = () => {
+ return `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+}
+
+// Simulate network delay
+const delay = (ms = 300) => {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+// Mock API implementation
+export const mockApi = {
+ // Fetch all thoughts
+ async fetchThoughts() {
+ await delay(200)
+ const thoughts = getStoredThoughts()
+ // Return in reverse chronological order (newest first)
+ return [...thoughts].reverse()
+ },
+
+ // Post a new thought
+ async postThought(message) {
+ await delay(400)
+
+ // Validation
+ if (!message || !message.trim()) {
+ const error = new Error('Message cannot be empty')
+ error.errors = { message: 'Your thought cannot be empty. Please write something!' }
+ throw error
+ }
+
+ if (message.length < 5) {
+ const error = new Error('Message too short')
+ error.errors = { message: 'Your thought is too short. Please write at least 5 characters.' }
+ throw error
+ }
+
+ if (message.length > 140) {
+ const error = new Error('Message too long')
+ error.errors = { message: 'Your thought is too long. Please keep it under 140 characters.' }
+ throw error
+ }
+
+ const newThought = {
+ _id: generateId(),
+ message: message.trim(),
+ hearts: 0,
+ createdAt: new Date().toISOString(),
+ }
+
+ const thoughts = getStoredThoughts()
+ thoughts.push(newThought)
+ saveThoughts(thoughts)
+
+ return newThought
+ },
+
+ // Like a thought
+ async likeThought(thoughtId) {
+ await delay(200)
+
+ const thoughts = getStoredThoughts()
+ const thoughtIndex = thoughts.findIndex((t) => t._id === thoughtId)
+
+ if (thoughtIndex === -1) {
+ const error = new Error('Thought not found')
+ throw error
+ }
+
+ thoughts[thoughtIndex].hearts = (thoughts[thoughtIndex].hearts || 0) + 1
+ saveThoughts(thoughts)
+
+ return thoughts[thoughtIndex]
+ },
+
+ // Clear all thoughts (useful for testing)
+ clearAllThoughts() {
+ localStorage.removeItem(STORAGE_KEY)
+ },
+}
+
+// Initialize with some sample thoughts if empty
+const initializeSampleData = () => {
+ const thoughts = getStoredThoughts()
+ if (thoughts.length === 0) {
+ const sampleThoughts = [
+ {
+ _id: generateId(),
+ message: 'Just finished a great workout! Feeling energized and ready for the day. 💪',
+ hearts: 3,
+ createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
+ },
+ {
+ _id: generateId(),
+ message: 'Had an amazing cup of coffee this morning. The little things that bring joy! ☕',
+ hearts: 5,
+ createdAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
+ },
+ {
+ _id: generateId(),
+ message: 'Beautiful sunset today. Nature always finds a way to amaze me. 🌅',
+ hearts: 8,
+ createdAt: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
+ },
+ ]
+ saveThoughts(sampleThoughts)
+ }
+}
+
+// Initialize on module load
+initializeSampleData()
+
diff --git a/src/utils/timeUtils.js b/src/utils/timeUtils.js
new file mode 100644
index 00000000..8847123c
--- /dev/null
+++ b/src/utils/timeUtils.js
@@ -0,0 +1,20 @@
+export 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`
+}
+