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 +}