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;