Skip to content

Commit

Permalink
falling-fruit#496 Enhancement. Add page activity with infinity scroll
Browse files Browse the repository at this point in the history
  • Loading branch information
Vladyslav Moskalenko committed Oct 23, 2024
1 parent 86ccc9d commit 81feba2
Show file tree
Hide file tree
Showing 16 changed files with 517 additions and 2 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@styled-icons/boxicons-solid": "^10.23.0",
"axios": "^0.21.1",
"coordinate-parser": "^1.0.7",
"date-fns": "^4.1.0",
"debounce": "^1.2.1",
"formik": "^2.2.6",
"google-map-react": "^2.1.9",
Expand Down
4 changes: 3 additions & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"imported_from": "Imported from {{name}}",
"edited_on": "Edited on {{date}}",
"glossary": {
"activity": "Activity",
"about": "About",
"list": "List",
"tree_inventory": "Tree inventory",
Expand Down Expand Up @@ -53,7 +54,8 @@
"project": "The project",
"data": "The data",
"sharing": "Sharing the harvest",
"press": "In the press"
"press": "In the press",
"last_activity": "Last activity"
},
"users": {
"sign_in": "Sign in",
Expand Down
155 changes: 155 additions & 0 deletions src/components/activity/ActivityPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'

import { fetchLocationChanges } from '../../redux/locationSlice'
import { fetchAndLocalizeTypes } from '../../redux/typeSlice'
import { PageScrollWrapper, PageTemplate } from '../about/PageTemplate'
import InfinityList from './InfinityList'
import LazyLoader from './LazyLoader'
import { LazyLoaderWrapper } from './styles/ActivityPageStyles'
import { groupChangesByDate, timePeriods } from './utils/listSortUtils'

const MAX_RECORDS = 1000

const ActivityPage = () => {
const dispatch = useDispatch()
const { i18n } = useTranslation()
const language = i18n.language

const [locationChanges, setLocationChanges] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [offset, setOffset] = useState(0)

const loadMoreRef = useRef()

const { type, error } = useSelector((state) => ({
type: state.type.typesAccess.localizedTypes,
error: state.location.error,
}))

const loadMoreChanges = useCallback(async () => {
if (isLoading || locationChanges.length >= MAX_RECORDS) {
return
}

setIsLoading(true)

try {
const newChanges = await dispatch(
fetchLocationChanges({ offset }),
).unwrap()

if (newChanges.length > 0) {
setLocationChanges((prevChanges) => [...prevChanges, ...newChanges])
setOffset((prevOffset) => prevOffset + newChanges.length)
}
} finally {
setIsLoading(false)
}
}, [dispatch, isLoading, offset, locationChanges.length])

useEffect(() => {
dispatch(fetchAndLocalizeTypes(language))
}, [dispatch, language])

useEffect(() => {
const handleScroll = () => {
if (
!isLoading &&
loadMoreRef.current &&
loadMoreRef.current.getBoundingClientRect().bottom <= window.innerHeight
) {
loadMoreChanges()
}
}

window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [isLoading, loadMoreChanges])

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoading) {
loadMoreChanges()
}
},
{ threshold: 1.0 },
)

const currentRef = loadMoreRef.current

if (currentRef) {
observer.observe(currentRef)
}

return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [isLoading, loadMoreChanges])

const getPlantName = (typeId) => {
const plant = type.find((t) => t.id === typeId)
return plant ? plant.commonName || plant.scientificName : 'Unknown Plant'
}

const groupedChanges = groupChangesByDate(locationChanges)

return (
<PageScrollWrapper>
{/* eslint-disable-next-line react/style-prop-object */}
<PageTemplate from="Settings">
<h1>Recent Activity</h1>
<p>
Explore the latest contributions from our community as they document
fruit-bearing trees and plants across different regions. Your input
helps make foraging and sustainable living accessible to everyone!
</p>

<p>
Join the growing community of foragers and urban explorers by adding
your own findings or discovering what’s nearby. Together, we can map
the world’s!
</p>

<p>
Browse through the latest additions to find trees near you, or sign up
to add your own. Click on a tree name for more details about the
location and type of fruit.
</p>

{error && (
<p>
Error fetching changes: {error.message || JSON.stringify(error)}
</p>
)}

{locationChanges.length > 0 && (
<InfinityList
groupedChanges={groupedChanges}
timePeriods={timePeriods}
getPlantName={getPlantName}
/>
)}

<div ref={loadMoreRef}></div>

{isLoading && (
<LazyLoaderWrapper>
<LazyLoader />
</LazyLoaderWrapper>
)}
{locationChanges.length >= MAX_RECORDS && (
<LazyLoaderWrapper>
You have only viewed the first {MAX_RECORDS} activities!
</LazyLoaderWrapper>
)}
</PageTemplate>
</PageScrollWrapper>
)
}

export default ActivityPage
53 changes: 53 additions & 0 deletions src/components/activity/InfinityList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'

import {
ActivityText,
AuthorName,
List,
ListItem,
PlantLink,
} from './styles/ActivityPageStyles'

const InfinityList = ({ groupedChanges, timePeriods, getPlantName }) => {
const renderGroup = (groupName, changes) => {
if (changes.length === 0) {
return null
}

return (
<div key={groupName}>
<h3>{groupName.replace(/[A-Z]/g, (letter) => `${letter}`).trim()}</h3>
<List>
{changes.map((change, index) => (
<ListItem key={index}>
{change.type_ids.map((typeId, idx) => (
<p key={`${index}${idx}`}>
<PlantLink
href={`/locations/${change.lat},${change.lng},15z`}
>
{getPlantName(typeId)}
</PlantLink>
<ActivityText>
, {change.description} in {change.city}, {change.state},{' '}
{change.country}{' '}
</ActivityText>
<AuthorName>{change.author}</AuthorName>
</p>
))}
</ListItem>
))}
</List>
</div>
)
}

return (
<>
{timePeriods.map((period) =>
renderGroup(period.name, groupedChanges[period.name]),
)}
</>
)
}

export default InfinityList
41 changes: 41 additions & 0 deletions src/components/activity/LazyLoader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react'
import styled from 'styled-components'

const Loader = styled.div`
display: block;
--height-of-loader: 4px;
--loader-color: #0071e2;
width: 130px;
height: 0.1rem;
border-radius: 30px;
background-color: rgba(0, 0, 0, 0.2);
position: relative;
&::before {
content: '';
position: absolute;
background: orange;
top: 0;
left: 0;
width: 0%;
height: 100%;
border-radius: 30px;
animation: moving 1s ease-in-out infinite;
}
@keyframes moving {
50% {
width: 100%;
}
100% {
width: 0;
right: 0;
left: unset;
}
}
`

const LazyLoader = () => <Loader />

export default LazyLoader
15 changes: 15 additions & 0 deletions src/components/activity/activityRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Route } from 'react-router-dom'

import ActivityPage from './ActivityPage'

const pages = [
{
path: ['/activity'],
component: ActivityPage,
},
]

const activityRoutes = pages.map((props) => (
<Route key={props.path[0]} {...props} />
))
export default activityRoutes
42 changes: 42 additions & 0 deletions src/components/activity/styles/ActivityPageStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import styled from 'styled-components'

export const PlantLink = styled.a`
color: #007bff !important;
font-weight: bold !important;
font-size: 1rem;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
`

export const AuthorName = styled.span`
color: grey;
font-weight: bold;
font-size: 1rem;
`

export const ActivityText = styled.span`
font-size: 1rem;
color: grey;
`

export const List = styled.ul`
list-style-type: none;
padding: 0;
margin: 0;
`

export const ListItem = styled.li`
margin-bottom: 1rem;
`

export const LazyLoaderWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 5rem;
`
44 changes: 44 additions & 0 deletions src/components/activity/utils/listSortUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const timePeriods = [
{ name: 'Today', condition: (daysAgo) => daysAgo === 0 },
{ name: 'Yesterday', condition: (daysAgo) => daysAgo === 1 },
{ name: '2 Days Ago', condition: (daysAgo) => daysAgo === 2 },
{ name: '3 Days Ago', condition: (daysAgo) => daysAgo === 3 },
{ name: 'This Week', condition: (daysAgo) => daysAgo <= 7 },
{ name: 'Last Week', condition: (daysAgo) => daysAgo <= 14 },
{ name: '2 Weeks Ago', condition: (daysAgo) => daysAgo <= 21 },
{ name: '3 Weeks Ago', condition: (daysAgo) => daysAgo <= 28 },
{ name: 'This Month', condition: (daysAgo) => daysAgo <= 30 },
{ name: 'Last Month', condition: (daysAgo) => daysAgo <= 60 },
{ name: 'Three Months Ago', condition: (daysAgo) => daysAgo <= 90 },
{ name: 'Six Months Ago', condition: (daysAgo) => daysAgo <= 180 },
{ name: 'One Year Ago', condition: (daysAgo) => daysAgo <= 365 },
{ name: 'More Than a Year', condition: (daysAgo) => daysAgo > 365 },
]

const getDaysDifference = (date1, date2) => {
const day1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate())
const day2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate())
const timeDiff = day1.getTime() - day2.getTime()
return Math.floor(timeDiff / (1000 * 3600 * 24))
}

export const groupChangesByDate = (changes) => {
const today = new Date()

const groups = timePeriods.reduce((acc, period) => {
acc[period.name] = []
return acc
}, {})

changes.forEach((change) => {
const changeDate = new Date(change.created_at)
const daysAgo = getDaysDifference(today, changeDate)

const period = timePeriods.find((period) => period.condition(daysAgo))
if (period) {
groups[period.name].push(change)
}
})

return groups
}
2 changes: 2 additions & 0 deletions src/components/desktop/DesktopLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SplitPane from 'react-split-pane'
import styled from 'styled-components/macro'

import aboutRoutes from '../about/aboutRoutes'
import activityRoutes from '../activity/activityRoutes'
import authRoutes from '../auth/authRoutes'
import connectRoutes from '../connect/connectRoutes'
import MapPage from '../map/MapPage'
Expand Down Expand Up @@ -51,6 +52,7 @@ const DesktopLayout = () => (
<Header />
<Switch>
{aboutRoutes}
{activityRoutes}
{authRoutes}
<Route>
{connectRoutes}
Expand Down
Loading

0 comments on commit 81feba2

Please sign in to comment.