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 ( +
+

What's making you happy right now?

+