diff --git a/src/components/App.tsx b/src/components/App.tsx
index e8c7d74..7cdf8f3 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1,12 +1,14 @@
import React, { FC } from 'react'
import styled from '@emotion/styled'
import Header from './Header'
+import AppLayout from './AppLayout'
const App: FC = () => {
return (
- {/* Happy coding! */}
+ {/* Happy coding! (Ty) */}
+
)
}
@@ -16,6 +18,10 @@ const Container = styled.div({
height: '100%',
width: '560px',
paddingTop: '60px',
+ '@media only screen and (max-width: 600px)': {
+ padding: '40px',
+ width: '100%',
+ },
})
export default App
diff --git a/src/components/AppLayout.js b/src/components/AppLayout.js
new file mode 100644
index 0000000..61a85d4
--- /dev/null
+++ b/src/components/AppLayout.js
@@ -0,0 +1,23 @@
+import React from 'react'
+
+import Main from './search/Main'
+import Aside from './favorites/Aside'
+
+import styled from '@emotion/styled'
+
+function AppLayout() {
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+const Separator = styled.hr({
+ border: 'none',
+ borderTop: '0.2rem solid #f7f7f7',
+})
+
+export default AppLayout
diff --git a/src/components/favorites/Aside.js b/src/components/favorites/Aside.js
new file mode 100644
index 0000000..3758912
--- /dev/null
+++ b/src/components/favorites/Aside.js
@@ -0,0 +1,25 @@
+import { useSelector } from 'react-redux'
+
+import FavoritesHeader from './FavoritesHeader'
+import Favorited from './Favorited'
+import MessageDisplay from '../ui/MessageDisplay'
+
+import styled from '@emotion/styled'
+
+function Aside() {
+ const { favoriteDogs } = useSelector((store) => store.favorites)
+
+ return (
+
+
+ {favoriteDogs.length > 0 && }
+ {favoriteDogs.length === 0 && No favorites yet 🐕}
+
+ )
+}
+
+const AsideLayout = styled.aside({
+ paddingBottom: '2rem',
+})
+
+export default Aside
diff --git a/src/components/favorites/Favorited.js b/src/components/favorites/Favorited.js
new file mode 100644
index 0000000..47e45fa
--- /dev/null
+++ b/src/components/favorites/Favorited.js
@@ -0,0 +1,34 @@
+import { useSelector, useDispatch } from 'react-redux'
+
+import GridList from '../ui/GridList'
+import ImageItem from '../ui/ImageItem'
+import InteractiveFloatingHeart from '../ui/InteractiveFloatingHeart'
+
+import { toggleFavorite } from '../../redux/actions'
+
+const favoritedGridStyled = {
+ gridTemplateColumns: 'repeat(4, minmax(22%,1fr))',
+ gridTemplateRows: '1fr',
+ gap: '1rem',
+ margin: '1rem 0',
+ '@media only screen and (max-width: 600px)': {
+ gap: '0.4rem',
+ },
+}
+
+function Favorited() {
+ const { favoriteDogs } = useSelector((store) => store.favorites)
+ const dispatch = useDispatch()
+
+ return (
+
+ {favoriteDogs.map((dog) => (
+
+ dispatch(toggleFavorite(dog))} />
+
+ ))}
+
+ )
+}
+
+export default Favorited
diff --git a/src/components/favorites/FavoritesHeader.js b/src/components/favorites/FavoritesHeader.js
new file mode 100644
index 0000000..8e9a957
--- /dev/null
+++ b/src/components/favorites/FavoritesHeader.js
@@ -0,0 +1,25 @@
+import Heart from '../Heart'
+
+import styled from '@emotion/styled'
+
+function FavoritesHeader() {
+ return (
+
+
+ Favorites
+
+ )
+}
+
+const HeaderLayout = styled.div({
+ display: 'flex',
+ gap: '1rem',
+})
+
+const Heading = styled.h2({
+ fontWeight: 'bold',
+ fontSize: '1.25rem',
+ lineHeight: '1.5rem',
+})
+
+export default FavoritesHeader
diff --git a/src/components/search/Main.js b/src/components/search/Main.js
new file mode 100644
index 0000000..47279c0
--- /dev/null
+++ b/src/components/search/Main.js
@@ -0,0 +1,22 @@
+import React from 'react'
+import { useSelector } from 'react-redux'
+
+import Search from './Search'
+import Results from './Results'
+import MessageDisplay from './../ui/MessageDisplay'
+
+function Main() {
+ const { searchedDogs, error } = useSelector((store) => store.search)
+
+ return (
+
+
+ {searchedDogs.length > 0 && }
+ {searchedDogs.length === 0 && (
+ {error ? error : 'Start searching dogs today 🐶'}
+ )}
+
+ )
+}
+
+export default Main
diff --git a/src/components/search/Results.js b/src/components/search/Results.js
new file mode 100644
index 0000000..404fcdc
--- /dev/null
+++ b/src/components/search/Results.js
@@ -0,0 +1,37 @@
+import { useDispatch, useSelector } from 'react-redux'
+
+import InteractiveFloatingHeart from '../ui/InteractiveFloatingHeart'
+import GridList from '../ui/GridList'
+import ImageItem from '../ui/ImageItem'
+
+import { toggleFavorite } from '../../redux/actions'
+
+const resultGridStyled = {
+ gridTemplateColumns: 'repeat(3, minmax(28%,1fr))',
+ gridTemplateRows: 'repeat(4, 1fr)',
+ margin: '2rem 0',
+}
+
+function Results() {
+ const { searchedDogs } = useSelector((store) => store.search)
+ const { favoriteDogs } = useSelector((store) => store.favorites)
+ const dispatch = useDispatch()
+
+ return (
+
+ {searchedDogs.map((dog) => {
+ const isFavorite = favoriteDogs.find((d) => d.id === dog.id)
+ return (
+
+ dispatch(toggleFavorite(dog))}
+ />
+
+ )
+ })}
+
+ )
+}
+
+export default Results
diff --git a/src/components/search/Search.js b/src/components/search/Search.js
new file mode 100644
index 0000000..41ff5d8
--- /dev/null
+++ b/src/components/search/Search.js
@@ -0,0 +1,99 @@
+import React, { useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+
+import { icons } from '../../assets'
+import { setError, setLoading, setSearchResults } from './../../redux/actions.ts'
+
+import 'regenerator-runtime/runtime'
+import styled from '@emotion/styled'
+
+function Search() {
+ const [localQuery, setLocalQuery] = useState('')
+
+ const { isLoading } = useSelector((store) => store.search)
+ const dispatch = useDispatch()
+
+ async function handleFetchResults(e) {
+ e.preventDefault()
+
+ if (!localQuery) return
+ const query = localQuery.toLowerCase()
+ dispatch(setLoading())
+ try {
+ const response = await fetch(`https://dog.ceo/api/breed/${query}/images/random/10`)
+ const data = await response.json()
+ if (data.status === 'error') throw new Error(`Failed to find ${localQuery} dogs 😔`)
+ dispatch(setSearchResults(data, query))
+ } catch (err) {
+ dispatch(setError(err.message))
+ }
+ }
+
+ return (
+
+ setLocalQuery(e.target.value)} />
+
+
+ )
+}
+
+const SearchLayout = styled.form({
+ backgroundColor: '#f7f7f7',
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: '2rem',
+ borderRadius: '0.2rem',
+ overflow: 'hidden',
+ '&:focus-within': {
+ boxShadow: 'inset 0 0 0 0.1rem rgb(138,138,138, 0.2)',
+ },
+})
+
+const Input = styled.input({
+ backgroundColor: 'transparent',
+ border: 'none',
+ width: '100%',
+ fontFamily: 'inherit',
+ fontSize: 'inherit',
+ color: '#8a8a8a',
+ margin: '0.2rem 1rem',
+ '&:focus': {
+ outline: 'none',
+ },
+})
+
+const Button = styled.button({
+ backgroundColor: '#0794e3',
+ alignSelf: 'stretch',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '0.4rem',
+ fontFamily: 'inherit',
+ fontSize: 'inherit',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '0.2rem',
+ padding: '0.2rem 0.8rem',
+ transition: 'all 0.3s ease-out',
+ '&:hover': {
+ cursor: 'pointer',
+ backgroundColor: '#20afff',
+ },
+ '&:disabled': {
+ cursor: 'not-allowed',
+ backgroundColor: '#20afff',
+ },
+})
+
+const SearchIcon = styled.img({
+ height: '1rem',
+ width: '1rem',
+})
+
+export default Search
diff --git a/src/components/ui/GridList.js b/src/components/ui/GridList.js
new file mode 100644
index 0000000..82a4555
--- /dev/null
+++ b/src/components/ui/GridList.js
@@ -0,0 +1,23 @@
+import styled from '@emotion/styled'
+
+function GridList({ children, customStyles }) {
+ return {children}
+}
+
+const Grid = styled.ul((props) => ({
+ listStyle: 'none',
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, minmax(min-content,max-content))',
+ gridTemplateRows: '1fr',
+ gap: '2rem',
+ height: '100%',
+ width: '100%',
+ padding: '0',
+ margin: '0',
+ ...props.customStyles,
+ '@media only screen and (max-width: 600px)': {
+ gap: '1rem',
+ },
+}))
+
+export default GridList
diff --git a/src/components/ui/ImageItem.js b/src/components/ui/ImageItem.js
new file mode 100644
index 0000000..b459d47
--- /dev/null
+++ b/src/components/ui/ImageItem.js
@@ -0,0 +1,30 @@
+import styled from '@emotion/styled'
+
+function ImageItem({ src, children }) {
+ return (
+ -
+
+ {children}
+
+ )
+}
+
+const Item = styled.li({
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ aspectRatio: '1 / 1',
+ position: 'relative',
+ borderRadius: '0.3rem',
+ overflow: 'hidden',
+})
+
+const Image = styled.img({
+ objectFit: 'cover',
+ height: '100%',
+ width: '100%',
+})
+
+export default ImageItem
diff --git a/src/components/ui/InteractiveFloatingHeart.js b/src/components/ui/InteractiveFloatingHeart.js
new file mode 100644
index 0000000..f82c353
--- /dev/null
+++ b/src/components/ui/InteractiveFloatingHeart.js
@@ -0,0 +1,40 @@
+import { icons } from './../../assets'
+
+import styled from '@emotion/styled'
+
+function InteractiveFloatingHeart({ customStyles, isFilled = false, onClick = () => {} }) {
+ return (
+
+
+
+ )
+}
+
+const InteractiveHeart = styled.img(({ isFilled }) => ({
+ transition: 'all 0.3s ease-out',
+ '&:hover': {
+ willChange: 'transform',
+ transform: 'scale(1.2)',
+ filter: isFilled
+ ? 'saturate(0.8) brightness(3.8)'
+ : 'invert(0.19) saturate(15) brightness(0.9) sepia(2) contrast(0.89) hue-rotate(-30deg)',
+ cursor: 'pointer',
+ },
+}))
+
+const HeartLayout = styled.div(({ customStyles }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ position: 'absolute',
+ bottom: '0.4rem',
+ right: '0.4rem',
+ ...customStyles,
+}))
+
+export default InteractiveFloatingHeart
diff --git a/src/components/ui/MessageDisplay.js b/src/components/ui/MessageDisplay.js
new file mode 100644
index 0000000..fdab4ee
--- /dev/null
+++ b/src/components/ui/MessageDisplay.js
@@ -0,0 +1,14 @@
+import styled from '@emotion/styled'
+
+function MessageDisplay({ children }) {
+ return {children}
+}
+
+const Message = styled.p({
+ color: '#8a8a8a',
+ fontSize: '1.25rem',
+ fontWeight: '600',
+ textAlign: 'center',
+})
+
+export default MessageDisplay
diff --git a/src/redux/actions.ts b/src/redux/actions.ts
index e69de29..28a73c3 100644
--- a/src/redux/actions.ts
+++ b/src/redux/actions.ts
@@ -0,0 +1,36 @@
+export function setLoading(isLoading = true) {
+ return {
+ type: 'search/setLoading',
+ payload: isLoading,
+ }
+}
+
+export function setError(error = 'Something went wrong 😐') {
+ return {
+ type: 'search/setError',
+ payload: error,
+ }
+}
+
+export function setSearchResults(data, query) {
+ // example link: "https://images.dog.ceo/breeds/labrador/n02099712_1941.jpg"
+ const cleanedData = data.message.map((link) => {
+ const id = query + '_' + link.split(`${query}`).at(1).split('.jpg').at(0).split('/').at(1)
+ return { id, imageURL: link }
+ })
+
+ return {
+ type: 'search/setSearchResults',
+ payload: {
+ query,
+ searchedDogs: cleanedData,
+ },
+ }
+}
+
+export function toggleFavorite(favoriteDog) {
+ return {
+ type: 'favorites/toggleFavorite',
+ payload: favoriteDog,
+ }
+}
diff --git a/src/redux/reducer.ts b/src/redux/reducer.ts
index be51d22..0df0b7e 100644
--- a/src/redux/reducer.ts
+++ b/src/redux/reducer.ts
@@ -1,6 +1,67 @@
-export const reducer = (initialState = {}, action) => {
+export const reducer = (state, action) => {
switch (action.type) {
+ case 'search/setQuery':
+ return {
+ ...state,
+ search: { ...state.search, query: action.payload },
+ }
+
+ case 'search/setLoading':
+ return {
+ ...state,
+ search: {
+ ...state.search,
+ searchedDogs: action.payload ? [] : state.search.searchedDogs,
+ isLoading: action.payload,
+ },
+ }
+
+ case 'search/setError':
+ return {
+ ...state,
+ search: {
+ ...state.search,
+ searchedDogs: [],
+ error: action.payload,
+ isLoading: false,
+ },
+ }
+
+ case 'search/setSearchResults':
+ return {
+ ...state,
+ search: {
+ ...state.search,
+ isLoading: false,
+ error: '',
+ query: action.payload.query,
+ searchedDogs: action.payload.searchedDogs,
+ },
+ }
+
+ case 'favorites/toggleFavorite':
+ const id = action.payload.id
+ const favoriteDogs = state.favorites.favoriteDogs
+ const isFavorite = favoriteDogs.find((dog) => dog.id === id)
+
+ if (isFavorite) {
+ const updatedFavoriteDogs = favoriteDogs.filter((dog) => dog.id !== id)
+ return {
+ ...state,
+ favorites: {
+ favoriteDogs: updatedFavoriteDogs,
+ },
+ }
+ }
+
+ return {
+ ...state,
+ favorites: {
+ favoriteDogs: [...favoriteDogs, action.payload],
+ },
+ }
+
default:
- return initialState
+ return state
}
}
diff --git a/src/redux/store.ts b/src/redux/store.ts
index 06536aa..d0fe387 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -1,4 +1,16 @@
import { createStore } from 'redux'
import { reducer } from './reducer'
-export default createStore(reducer)
+const initialState = {
+ search: {
+ query: '',
+ isLoading: false,
+ error: '',
+ searchedDogs: [],
+ },
+ favorites: {
+ favoriteDogs: [],
+ },
+}
+
+export default createStore(reducer, initialState)
diff --git a/yarn.lock b/yarn.lock
index 2b88693..7d2000a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10214,10 +10214,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-typescript@^3.6.4:
- version "3.9.9"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.9.tgz#e69905c54bc0681d0518bd4d587cc6f2d0b1a674"
- integrity sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w==
+typescript@4.5.4:
+ version "4.5.4"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8"
+ integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg==
uglify-js@3.4.x:
version "3.4.10"