Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Happy Thoughts
netlyfy link https://pebbleshappy-app.netlify.app
31 changes: 31 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
102 changes: 101 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<h1>Happy Thoughts</h1>
<div className="app">
<ThoughtForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
error={formError ? error : ''}
/>
{apiError && (
<div className="api-error-message">
{error}
<p className="api-error-hint">
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.
</p>
</div>
)}
<ThoughtList
thoughts={thoughts}
isLoading={isLoading}
onLike={handleLike}
/>
</div>
)
}
82 changes: 82 additions & 0 deletions src/components/ThoughtCard.css
Original file line number Diff line number Diff line change
@@ -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;
}

26 changes: 26 additions & 0 deletions src/components/ThoughtCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { formatTimeAgo } from '../utils/timeUtils'
import './ThoughtCard.css'

export const ThoughtCard = ({ thought, onLike }) => {
return (
<article className="thought-card">
<div className="thought-content">
<p className="thought-message">{thought.message}</p>
<div className="thought-footer">
<button
className="like-button"
onClick={() => onLike(thought._id)}
aria-label="Like this thought"
>
<span className="like-icon">
<span className="heart-emoji-small">❤️</span>
</span>
<span className="like-count">x {thought.hearts || 0}</span>
</button>
<span className="thought-time">{formatTimeAgo(thought.createdAt)}</span>
</div>
</div>
</article>
)
}

137 changes: 137 additions & 0 deletions src/components/ThoughtForm.css
Original file line number Diff line number Diff line change
@@ -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;
}
}

Loading