diff --git a/public/locales/en.json b/public/locales/en.json
index f51de2105..59813ad40 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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",
@@ -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",
diff --git a/src/components/activity/ActivityPage.js b/src/components/activity/ActivityPage.js
new file mode 100644
index 000000000..9ff8a5ff8
--- /dev/null
+++ b/src/components/activity/ActivityPage.js
@@ -0,0 +1,132 @@
+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 { debounce } from '../../utils/debounce'
+import { groupChangesByDate } from '../../utils/groupChangesByDate'
+import { PageScrollWrapper, PageTemplate } from '../about/PageTemplate'
+import SkeletonLoader from '../ui/SkeletonLoader'
+import InfinityList from './InfinityList'
+
+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 [isInitialLoad, setIsInitialLoad] = useState(true)
+
+ const loadMoreRef = useRef()
+ const scrollPositionRef = useRef(0)
+
+ const { type, error } = useSelector((state) => ({
+ type: state.type.typesAccess.localizedTypes,
+ error: state.location.error,
+ }))
+
+ const loadMoreChanges = useCallback(async () => {
+ if (isLoading) {
+ return
+ }
+
+ setIsLoading(true)
+ scrollPositionRef.current = window.scrollY
+
+ try {
+ const newChanges = await dispatch(
+ fetchLocationChanges({ offset }),
+ ).unwrap()
+
+ if (newChanges.length > 0) {
+ setLocationChanges((prevChanges) => {
+ const updatedChanges = [...prevChanges, ...newChanges]
+ return updatedChanges.length > MAX_RECORDS
+ ? updatedChanges.slice(-MAX_RECORDS)
+ : updatedChanges
+ })
+ setOffset((prevOffset) => prevOffset + newChanges.length)
+ }
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ window.scrollTo({
+ top: scrollPositionRef.current,
+ behavior: 'smooth',
+ })
+ }
+ }, [dispatch, isLoading, offset])
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const debouncedLoadMoreChanges = useCallback(debounce(loadMoreChanges, 300), [
+ loadMoreChanges,
+ ])
+
+ useEffect(() => {
+ dispatch(fetchAndLocalizeTypes(language))
+ }, [dispatch, language])
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && !isLoading) {
+ setIsLoading(true)
+ debouncedLoadMoreChanges()
+ }
+ },
+ { threshold: 1.0 },
+ )
+
+ const currentRef = loadMoreRef.current
+
+ if (currentRef) {
+ observer.observe(currentRef)
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef)
+ }
+ }
+ }, [isLoading, debouncedLoadMoreChanges])
+
+ const getPlantName = (typeId) => {
+ const plant = type.find((t) => t.id === typeId)
+ return plant ? plant.commonName || plant.scientificName : 'Unknown Plant'
+ }
+
+ const groupedChanges = groupChangesByDate(locationChanges)
+
+ return (
+
+
+ Recent Changes
+
+ Explore the latest contributions from our community as they document
+ fruit-bearing trees and plants across different regions.
+
+ {error && (
+
+ Error fetching changes: {error.message || JSON.stringify(error)}
+
+ )}
+ {locationChanges.length > 0 && (
+
+ )}
+
+ {isLoading && }{' '}
+ {/* Render 3 skeletons on initial load */}
+
+
+ )
+}
+
+export default ActivityPage
diff --git a/src/components/activity/ActivityPageStyles.js b/src/components/activity/ActivityPageStyles.js
new file mode 100644
index 000000000..570ff8d33
--- /dev/null
+++ b/src/components/activity/ActivityPageStyles.js
@@ -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 ListChanges = styled.ul`
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+`
+
+export const ListItem = styled.li`
+ p {
+ margin: 0 0 0.5rem;
+ }
+
+ @media (max-width: 767px) {
+ p {
+ margin: 0 0 1rem;
+ }
+ }
+`
diff --git a/src/components/activity/InfinityList.js b/src/components/activity/InfinityList.js
new file mode 100644
index 000000000..3945ba616
--- /dev/null
+++ b/src/components/activity/InfinityList.js
@@ -0,0 +1,151 @@
+import React from 'react'
+
+import {
+ ActivityText,
+ AuthorName,
+ ListChanges,
+ ListItem,
+ PlantLink,
+} from './ActivityPageStyles'
+
+const InfinityList = ({ groupedChanges, getPlantName }) => {
+ const renderGroup = (groupName, changes) => {
+ if (changes.length === 0) {
+ return null
+ }
+
+ const filteredChanges = []
+ const seenChanges = new Map()
+
+ changes.forEach((change) => {
+ const key = `${change.author}-${change.city}-${change.state}-${
+ change.country
+ }-${change.location_id}-${change.type_ids.join(',')}-${
+ change.description
+ }`
+ const date = change.created_at.split('T')[0]
+
+ if (!seenChanges.has(key)) {
+ seenChanges.set(key, date)
+ filteredChanges.push(change)
+ } else if (seenChanges.get(key) === date) {
+ return
+ } else {
+ seenChanges.set(key, date)
+ filteredChanges.push(change)
+ }
+ })
+
+ const groupedByUserAndLocation = filteredChanges.reduce((acc, change) => {
+ const groupKey = `${change.author}-${change.city}-${change.state}-${
+ change.country
+ }-${change.created_at.split('T')[0]}`
+
+ if (!acc[groupKey]) {
+ acc[groupKey] = []
+ }
+
+ acc[groupKey].push(change)
+ return acc
+ }, {})
+
+ const getTypeLinks = (changes) => {
+ const plantLocations = changes.map((change) => ({
+ locationId: change.location_id,
+ typeIds: change.type_ids,
+ }))
+
+ const typeLinks = []
+
+ plantLocations.forEach(({ locationId, typeIds }, idx) => {
+ // eslint-disable-next-line @hack4impact-uiuc/no-redundant-functions
+ const plantNames = typeIds.map((typeId) => getPlantName(typeId))
+
+ typeLinks.push(
+
+
+ {plantNames.join(', ')}
+
+ {idx < plantLocations.length - 1 &&
+ (plantLocations[idx + 1].locationId === locationId ? (
+ ,
+ ) : (
+ ,
+ ))}
+ ,
+ )
+ })
+
+ return typeLinks
+ }
+
+ return (
+
+
{groupName}
+
+ {Object.entries(groupedByUserAndLocation).map(
+ ([_, userChanges], index) => {
+ const { author, city, state, country } = userChanges[0]
+
+ const addedTypeLinks = getTypeLinks(
+ userChanges.filter((change) => change.description === 'added'),
+ )
+
+ const editedTypeLinks = getTypeLinks(
+ userChanges.filter((change) => change.description === 'edited'),
+ )
+
+ return (
+
+ {addedTypeLinks.length > 0 && (
+
+ {addedTypeLinks.map((link, idx) => (
+
+ {link}
+ {idx < addedTypeLinks.length - 1 && ' '}
+
+ ))}
+
+ {' '}
+ added in {city}, {state}, {country}
+ {author && ' — '}
+
+ {author && {author}}
+
+ )}
+
+ {editedTypeLinks.length > 0 && (
+
+ {editedTypeLinks.map((link, idx) => (
+
+ {link}
+ {idx < editedTypeLinks.length - 1 && ' '}
+
+ ))}
+
+ {' '}
+ edited in {city}, {state}, {country}
+ {author && ' — '}
+
+ {author && {author}}
+
+ )}
+
+ )
+ },
+ )}
+
+
+ )
+ }
+
+ return (
+ <>
+ {Object.entries(groupedChanges).map(([dateString, changes]) =>
+ renderGroup(dateString, changes),
+ )}
+ >
+ )
+}
+
+export default InfinityList
diff --git a/src/components/activity/activityRoutes.js b/src/components/activity/activityRoutes.js
new file mode 100644
index 000000000..038c47f25
--- /dev/null
+++ b/src/components/activity/activityRoutes.js
@@ -0,0 +1,15 @@
+import { Route } from 'react-router-dom'
+
+import ActivityPage from './ActivityPage'
+
+const pages = [
+ {
+ path: ['/changes'],
+ component: ActivityPage,
+ },
+]
+
+const activityRoutes = pages.map((props) => (
+
+))
+export default activityRoutes
diff --git a/src/components/desktop/DesktopLayout.js b/src/components/desktop/DesktopLayout.js
index b88143dee..8dc471312 100644
--- a/src/components/desktop/DesktopLayout.js
+++ b/src/components/desktop/DesktopLayout.js
@@ -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'
@@ -51,6 +52,7 @@ const DesktopLayout = () => (
{aboutRoutes}
+ {activityRoutes}
{authRoutes}
{connectRoutes}
diff --git a/src/components/desktop/Header.js b/src/components/desktop/Header.js
index 3e0a0a186..0562b0e75 100644
--- a/src/components/desktop/Header.js
+++ b/src/components/desktop/Header.js
@@ -236,6 +236,14 @@ const Header = () => {
{t('glossary.map')}
+
+
+ {t('glossary.activity')}
+
+
diff --git a/src/components/mobile/MobileLayout.js b/src/components/mobile/MobileLayout.js
index e2ec369f9..35be770c9 100644
--- a/src/components/mobile/MobileLayout.js
+++ b/src/components/mobile/MobileLayout.js
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'
import { matchPath, Route, Switch, useLocation } from 'react-router-dom'
import aboutRoutes from '../about/aboutRoutes'
+import activityRoutes from '../activity/activityRoutes'
import AccountPage from '../auth/AccountPage'
import authRoutes from '../auth/authRoutes'
import connectRoutes from '../connect/connectRoutes'
@@ -89,6 +90,7 @@ const MobileLayout = () => {
{connectRoutes}
+ {activityRoutes}
{aboutRoutes}
{authRoutes}
diff --git a/src/components/settings/SettingsPage.js b/src/components/settings/SettingsPage.js
index 0f3d13882..47d477ee2 100644
--- a/src/components/settings/SettingsPage.js
+++ b/src/components/settings/SettingsPage.js
@@ -357,6 +357,11 @@ const SettingsPage = ({ desktop }) => {
primaryText={t('pages.press')}
onClick={() => history.push('/press')}
/>
+ }
+ primaryText={t('pages.last_activity')}
+ onClick={() => history.push('/changes')}
+ />
>
)}
diff --git a/src/components/ui/SkeletonLoader.js b/src/components/ui/SkeletonLoader.js
new file mode 100644
index 000000000..2ef39eacf
--- /dev/null
+++ b/src/components/ui/SkeletonLoader.js
@@ -0,0 +1,55 @@
+import React from 'react'
+import styled, { keyframes } from 'styled-components'
+
+const shimmer = keyframes`
+ 0% {
+ background-position: -1000px 0;
+ }
+ 100% {
+ background-position: 1000px 0;
+ }
+`
+
+const SkeletonWrapper = styled.div`
+ margin-bottom: 16px;
+`
+
+const SkeletonItem = styled.div`
+ height: 1rem;
+ background-color: #e0e0e0;
+ background-image: linear-gradient(
+ to right,
+ #e0e0e0 0%,
+ #f0f0f0 50%,
+ #e0e0e0 100%
+ );
+ background-size: 200% 100%;
+ animation: ${shimmer} 2.5s infinite linear;
+ border-radius: 4px;
+ margin-bottom: 0.5rem;
+ width: ${(props) => props.width || '100%'};
+
+ &:first-child {
+ margin-bottom: 20px;
+ height: 1.2rem;
+ }
+
+ &:last-child {
+ margin-bottom: 20px;
+ }
+`
+
+const SkeletonLoader = ({ count = 3 }) => (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+)
+
+export default SkeletonLoader
diff --git a/src/redux/listSlice.js b/src/redux/listSlice.js
index e78c46320..f063856fb 100644
--- a/src/redux/listSlice.js
+++ b/src/redux/listSlice.js
@@ -16,7 +16,6 @@ const fetchListLocations = createAsyncThunk(
{ types, muni, invasive, bounds, zoom, center },
{ limit: 100, offset, photo: true },
)
-
return {
offset,
extend,
@@ -39,6 +38,7 @@ export const listSlice = createSlice({
offset: 0,
shouldFetchNewLocations: true,
locations: [],
+ locationsByIds: [],
lastMapView: null,
},
reducers: {},
diff --git a/src/redux/locationSlice.js b/src/redux/locationSlice.js
index 286490f8a..a519bc7c4 100644
--- a/src/redux/locationSlice.js
+++ b/src/redux/locationSlice.js
@@ -8,6 +8,7 @@ import {
editLocation,
editReview,
getLocationById,
+ getLocationsChanges,
} from '../utils/api'
import { fetchReviewData } from './reviewSlice'
@@ -24,6 +25,23 @@ export const fetchLocationData = createAsyncThunk(
},
)
+export const fetchLocationChanges = createAsyncThunk(
+ 'location/fetchLocationChanges',
+ async ({ limit = 100, offset = 0, userId }, { rejectWithValue }) => {
+ try {
+ const locationChanges = await getLocationsChanges({
+ limit,
+ offset,
+ userId,
+ })
+
+ return locationChanges
+ } catch (error) {
+ return rejectWithValue(error.response?.data || error.message)
+ }
+ },
+)
+
export const submitLocation = createAsyncThunk(
'location/submitLocation',
async ({ editingId, locationValues }) => {
@@ -90,10 +108,12 @@ const locationSlice = createSlice({
reviews: [],
position: null, // {lat: number, lng: number}
locationId: null,
+ locationChanges: [],
isBeingEdited: false,
form: null,
tooltipOpen: false,
streetViewOpen: false,
+ error: null,
lightbox: {
isOpen: false,
reviewIndex: null,
@@ -226,6 +246,19 @@ const locationSlice = createSlice({
state.position = null
state.isBeingEdited = false
},
+ [fetchLocationChanges.pending]: (state) => {
+ state.isLoading = true
+ state.error = null
+ },
+ [fetchLocationChanges.fulfilled]: (state, action) => {
+ state.isLoading = false
+ state.locationChanges = action.payload
+ },
+ [fetchLocationChanges.rejected]: (state, action) => {
+ state.isLoading = false
+ state.error = action.payload || 'Failed to fetch location changes'
+ toast.error(`Error fetching location changes: ${action.error.message}`)
+ },
[submitLocation.fulfilled]: (state, action) => {
if (action.meta.arg.editingId) {
/*
diff --git a/src/utils/api.ts b/src/utils/api.ts
index 694a16a42..eef9d95f0 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -26,6 +26,7 @@ instance.interceptors.request.use((config) => {
'/locations',
'/locations/:id',
'/locations/:id/reviews',
+ '/locations/changes',
'/reviews/:id',
'/clusters',
'/imports',
@@ -133,6 +134,10 @@ export const editLocation = (
data: paths['/locations/{id}']['put']['requestBody']['content']['application/json'],
) => instance.put(`/locations/${id}`, data)
+export const getLocationsChanges = (
+ params: paths['/locations/changes']['get']['parameters']['query'],
+) => instance.get('/locations/changes', { params })
+
export const getTypes = () => instance.get('/types')
export const getTypeCounts = (
diff --git a/src/utils/apiSchema.ts b/src/utils/apiSchema.ts
index e30057e07..8a8ade25b 100644
--- a/src/utils/apiSchema.ts
+++ b/src/utils/apiSchema.ts
@@ -216,6 +216,30 @@ export interface paths {
};
};
};
+ "/locations/changes": {
+ get: {
+ parameters: {
+ query: {
+ /** Максимальное количество изменений */
+ limit?: number;
+ /** Смещение для пагинации */
+ offset?: number;
+ /** ID пользователя для фильтрации изменений */
+ user_id?: number;
+ /** Фильтрация изменений в пределах зоны поиска */
+ range?: boolean;
+ };
+ };
+ responses: {
+ /** Success */
+ 200: {
+ content: {
+ "application/json": components["schemas"]["LocationChange"][];
+ };
+ };
+ };
+ };
+ };
"/photos": {
post: {
responses: {
@@ -722,6 +746,28 @@ export interface components {
/** Location reviews. */
reviews?: components["schemas"]["Review"][];
};
+ LocationChange: {
+ /** Time of the change creation */
+ created_at: string;
+ /** Description of the change (e.g., added, updated, deleted) */
+ description: string;
+ /** Location ID */
+ location_id: number;
+ /** Array of type IDs */
+ type_ids: number[];
+ /** Review ID */
+ review_id: number;
+ /** User ID */
+ user_id: number;
+ /** Author's name */
+ author: string;
+ /** City */
+ city: string;
+ /** State */
+ state: string;
+ /** Country */
+ country: string;
+ };
BaseReview: {
/** Comment. */
comment?: string | null;
diff --git a/src/utils/debounce.js b/src/utils/debounce.js
new file mode 100644
index 000000000..48e71e0cd
--- /dev/null
+++ b/src/utils/debounce.js
@@ -0,0 +1,9 @@
+export function debounce(func, delay) {
+ let timeoutId
+ return (...args) => {
+ clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => {
+ func(...args)
+ }, delay)
+ }
+}
diff --git a/src/utils/groupChangesByDate.js b/src/utils/groupChangesByDate.js
new file mode 100644
index 000000000..3c42e3679
--- /dev/null
+++ b/src/utils/groupChangesByDate.js
@@ -0,0 +1,32 @@
+const getHoursDifference = (date1, date2) => {
+ const timeDiff = date1.getTime() - date2.getTime()
+ return Math.floor(timeDiff / (1000 * 3600))
+}
+
+export const groupChangesByDate = (changes) => {
+ const now = new Date()
+
+ const groups = {}
+
+ changes.forEach((change) => {
+ const changeDate = new Date(change.created_at)
+ const hoursAgo = getHoursDifference(now, changeDate)
+
+ let periodName
+ if (hoursAgo < 24) {
+ periodName = 'Last 24 Hours'
+ } else if (hoursAgo < 48) {
+ periodName = '1 Day Ago'
+ } else {
+ const daysAgo = Math.floor(hoursAgo / 24)
+ periodName = `${daysAgo} Days Ago`
+ }
+
+ if (!groups[periodName]) {
+ groups[periodName] = []
+ }
+ groups[periodName].push(change)
+ })
+
+ return groups
+}