diff --git a/package.json b/package.json
index 530568e7..4385aa4a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/locales/en.json b/public/locales/en.json
index f51de210..59813ad4 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 00000000..b8c395b3
--- /dev/null
+++ b/src/components/activity/ActivityPage.js
@@ -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 (
+
+ {/* eslint-disable-next-line react/style-prop-object */}
+
+ Recent Activity
+
+ 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!
+
+
+
+ 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!
+
+
+
+ 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.
+
+
+ {error && (
+
+ Error fetching changes: {error.message || JSON.stringify(error)}
+
+ )}
+
+ {locationChanges.length > 0 && (
+
+ )}
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {locationChanges.length >= MAX_RECORDS && (
+
+ You have only viewed the first {MAX_RECORDS} activities!
+
+ )}
+
+
+ )
+}
+
+export default ActivityPage
diff --git a/src/components/activity/InfinityList.js b/src/components/activity/InfinityList.js
new file mode 100644
index 00000000..a5b9fbd0
--- /dev/null
+++ b/src/components/activity/InfinityList.js
@@ -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 (
+
+
{groupName.replace(/[A-Z]/g, (letter) => `${letter}`).trim()}
+
+ {changes.map((change, index) => (
+
+ {change.type_ids.map((typeId, idx) => (
+
+
+ {getPlantName(typeId)}
+
+
+ , {change.description} in {change.city}, {change.state},{' '}
+ {change.country} —{' '}
+
+ {change.author}
+
+ ))}
+
+ ))}
+
+
+ )
+ }
+
+ return (
+ <>
+ {timePeriods.map((period) =>
+ renderGroup(period.name, groupedChanges[period.name]),
+ )}
+ >
+ )
+}
+
+export default InfinityList
diff --git a/src/components/activity/LazyLoader.js b/src/components/activity/LazyLoader.js
new file mode 100644
index 00000000..9dbc48c2
--- /dev/null
+++ b/src/components/activity/LazyLoader.js
@@ -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 = () =>
+
+export default LazyLoader
diff --git a/src/components/activity/activityRoutes.js b/src/components/activity/activityRoutes.js
new file mode 100644
index 00000000..7882c707
--- /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: ['/activity'],
+ component: ActivityPage,
+ },
+]
+
+const activityRoutes = pages.map((props) => (
+
+))
+export default activityRoutes
diff --git a/src/components/activity/styles/ActivityPageStyles.js b/src/components/activity/styles/ActivityPageStyles.js
new file mode 100644
index 00000000..5d8e2101
--- /dev/null
+++ b/src/components/activity/styles/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 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;
+`
diff --git a/src/components/activity/utils/listSortUtils.js b/src/components/activity/utils/listSortUtils.js
new file mode 100644
index 00000000..d08c7ef3
--- /dev/null
+++ b/src/components/activity/utils/listSortUtils.js
@@ -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
+}
diff --git a/src/components/desktop/DesktopLayout.js b/src/components/desktop/DesktopLayout.js
index b88143de..8dc47131 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 3e0a0a18..8880196e 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 e2ec369f..35be770c 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 0f3d1388..e571d892 100644
--- a/src/components/settings/SettingsPage.js
+++ b/src/components/settings/SettingsPage.js
@@ -335,6 +335,12 @@ const SettingsPage = ({ desktop }) => {
{!desktop && (
<>
+ {t('glossary.activity')}
+ }
+ primaryText={t('pages.last_activity')}
+ onClick={() => history.push('/activity')}
+ />
{t('glossary.about')}
{
+ const state = getState()
+ const { muni, invasive } = state.filter
+ const ids = locationIds.join(',')
+
+ const params = {
+ ids,
+ muni,
+ invasive,
+ photo: false,
+ count: false,
+ }
+
+ return await getLocations(params)
+ },
+)
+
export const fetchListLocationsStart = () =>
fetchListLocations({ fetchCount: true, offset: 0 })
export const fetchListLocationsExtend = (locations) =>
@@ -39,6 +57,7 @@ export const listSlice = createSlice({
offset: 0,
shouldFetchNewLocations: true,
locations: [],
+ locationsByIds: [],
lastMapView: null,
},
reducers: {},
@@ -64,6 +83,16 @@ export const listSlice = createSlice({
state.isLoading = false
state.shouldFetchNewLocations = false
},
+ [fetchListLocationsByIds.pending]: (state) => {
+ state.isLoading = true
+ },
+ [fetchListLocationsByIds.fulfilled]: (state, action) => {
+ state.locationsByIds = action.payload // Заменяем старые локации новыми
+ state.isLoading = false
+ },
+ [fetchListLocationsByIds.rejected]: (state) => {
+ state.isLoading = false
+ },
[updateSelection.type]: (state) => {
state.shouldFetchNewLocations = true
},
diff --git a/src/redux/locationSlice.js b/src/redux/locationSlice.js
index 286490f8..98087523 100644
--- a/src/redux/locationSlice.js
+++ b/src/redux/locationSlice.js
@@ -8,7 +8,9 @@ import {
editLocation,
editReview,
getLocationById,
+ getLocationsChanges,
} from '../utils/api'
+import { fetchListLocationsByIds } from './listSlice'
import { fetchReviewData } from './reviewSlice'
export const fetchLocationData = createAsyncThunk(
@@ -24,6 +26,53 @@ export const fetchLocationData = createAsyncThunk(
},
)
+export const fetchLocationChanges = createAsyncThunk(
+ 'location/fetchLocationChanges',
+ async (
+ { limit = 100, offset = 0, userId },
+ { rejectWithValue, dispatch },
+ ) => {
+ try {
+ const locationChanges = await getLocationsChanges({
+ limit,
+ offset,
+ userId,
+ })
+
+ const locationIds = locationChanges.map((change) => change.location_id)
+ const locationsByIdsResult = await dispatch(
+ fetchListLocationsByIds(locationIds),
+ )
+ const locationsByIds = locationsByIdsResult.payload
+
+ if (!Array.isArray(locationsByIds)) {
+ throw new Error('Expected locationsByIds to be an array')
+ }
+
+ const locationsMap = {}
+ locationsByIds.forEach((location) => {
+ locationsMap[location.id] = location
+ })
+
+ return locationChanges
+ .map((change) => {
+ const location = locationsMap[change.location_id]
+ if (location) {
+ return {
+ ...change,
+ lat: location.lat,
+ lng: location.lng,
+ }
+ }
+ return null
+ })
+ .filter((item) => item !== null)
+ } catch (error) {
+ return rejectWithValue(error.response?.data || error.message)
+ }
+ },
+)
+
export const submitLocation = createAsyncThunk(
'location/submitLocation',
async ({ editingId, locationValues }) => {
@@ -90,10 +139,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 +277,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 694a16a4..eef9d95f 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 e30057e0..36c2bddf 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: {
+ /** Время создания изменения */
+ created_at: string;
+ /** Описание изменения (например, добавлено, обновлено, удалено) */
+ description: string;
+ /** ID локации */
+ location_id: number;
+ /** Массив ID типов */
+ type_ids: number[];
+ /** ID отзыва */
+ review_id: number;
+ /** ID пользователя */
+ user_id: number;
+ /** Имя автора */
+ author: string;
+ /** Город */
+ city: string;
+ /** Штат */
+ state: string;
+ /** Страна */
+ country: string;
+ };
BaseReview: {
/** Comment. */
comment?: string | null;