diff --git a/projects/bp-gallery/cypress/e2e/discover.cy.ts b/projects/bp-gallery/cypress/e2e/discover.cy.ts index c9806fcd3..d335c7f47 100644 --- a/projects/bp-gallery/cypress/e2e/discover.cy.ts +++ b/projects/bp-gallery/cypress/e2e/discover.cy.ts @@ -85,14 +85,15 @@ describe('Discover View', () => { }); it('shows "Orte" tag overview', () => { + cy.get('.overview-selection-container:eq(2)').contains('Orte').click(); // check for basic components (title, show more button) - cy.get('.overview-container:eq(3)') + cy.get('.overview-selection-container:eq(2)') .children() .should('contain.text', 'Orte') .and('contain.text', 'Mehr anzeigen'); // check if it contains first 6 verified locations - cy.get('.overview-container:eq(3) .overview-collection-grid-container .items') + cy.get('.overview-selection-container:eq(2) .overview-collection-grid-container .items') .should('contain.text', 'VERIFIZIERTER TESTORT 1') .and('contain.text', 'VERIFIZIERTER TESTORT 2') .and('contain.text', 'VERIFIZIERTER TESTORT 3') diff --git a/projects/bp-gallery/cypress/e2e/show-more.cy.ts b/projects/bp-gallery/cypress/e2e/show-more.cy.ts index 77f1795c3..5b4911044 100644 --- a/projects/bp-gallery/cypress/e2e/show-more.cy.ts +++ b/projects/bp-gallery/cypress/e2e/show-more.cy.ts @@ -35,18 +35,20 @@ describe('Navigation to Show More View from Discover View', () => { }); it('works for "Orte"', () => { - cy.get('.overview-container:contains(Orte)').contains('Mehr anzeigen').click(); + cy.get('.overview-selection-container:eq(2)').contains('Orte').click(); + cy.get('.overview-selection-container:contains(Orte)').contains('Mehr anzeigen').click(); urlIs('/show-more/location'); }); it('works for single locations', () => { + cy.get('.overview-selection-container:eq(2)').contains('Orte').click(); // IDs of the six locations shown in tag overview - const targetIDs = [7, 8, 9, 10, 11, 13]; + const targetIDs = [7, 13, 10, 11, 9, 8]; // iterate over the six locations shown in tag overview //for (const targetID of targetIDs) { targetIDs.forEach((targetID, index) => { cy.get( - `.overview-container:contains(Orte) .overview-collection-grid-container .items .item:eq(${index})` + `.overview-selection-container:contains(Orte) .overview-collection-grid-container .items .item:eq(${index})` ).click(); urlIs(`/show-more/location/${targetID}`); cy.go(-1); // is a bit faster using cy.go(-1) instead of cy.visit('/discover) diff --git a/projects/bp-gallery/package.json b/projects/bp-gallery/package.json index ade51c545..683ccd81f 100755 --- a/projects/bp-gallery/package.json +++ b/projects/bp-gallery/package.json @@ -17,6 +17,7 @@ "@sentry/react": "^7.24.2", "@tailwindcss/line-clamp": "^0.4.2", "@types/apollo-upload-client": "^17.0.2", + "@types/leaflet.markercluster": "^1.5.1", "apollo-upload-client": "^17.0.0", "bp-graphql": "../bp-graphql", "dayjs": "^1.11.7", @@ -32,6 +33,7 @@ "isomorphic-dompurify": "^0.24.0", "jodit-react": "^1.3.31", "leaflet": "^1.9.3", + "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", "meilisearch": "^0.33.0", "nanoid": "^4.0.2", @@ -46,6 +48,7 @@ "react-html-parser": "^2.0.2", "react-i18next": "^11.13.0", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "react-perfect-scrollbar": "^1.5.8", "react-router-config": "^5.1.1", "react-router-dom": "^5.3.0", diff --git a/projects/bp-gallery/src/components/common/OverviewContainer.tsx b/projects/bp-gallery/src/components/common/OverviewContainer.tsx index 5f2a657c5..eab9b0399 100644 --- a/projects/bp-gallery/src/components/common/OverviewContainer.tsx +++ b/projects/bp-gallery/src/components/common/OverviewContainer.tsx @@ -6,7 +6,6 @@ import { MobileContext } from '../provider/MobileProvider'; export interface OverviewContainerTab { title: string; icon: ReactElement; - desktopText?: string; content: ReactElement; } diff --git a/projects/bp-gallery/src/components/common/PictureMap.tsx b/projects/bp-gallery/src/components/common/PictureMap.tsx new file mode 100644 index 000000000..cbdfdbbb6 --- /dev/null +++ b/projects/bp-gallery/src/components/common/PictureMap.tsx @@ -0,0 +1,71 @@ +import { Map } from 'leaflet'; +import { useMemo, useRef, useState } from 'react'; +import { useSimplifiedQueryResponseData } from '../../graphql/queryUtils'; +import { useVisit } from '../../helpers/history'; +import useGetTagsWithThumbnail, { NO_LIMIT } from '../../hooks/get-tags-with-thumbnail.hook'; +import { TagType } from '../../types/additionalFlatTypes'; +import { BAD_HARZBURG_COORDINATES } from '../views/location-curating/tag-structure-helpers'; +import Loading from './Loading'; +import PictureMapView, { ExtendedFlatTag } from './PictureMapView'; +import QueryErrorDisplay from './QueryErrorDisplay'; + +const PictureMap = () => { + const { location } = useVisit(); + + const initialMapValues = useMemo(() => { + return location.state?.mapState ?? { center: BAD_HARZBURG_COORDINATES, zoom: 10 }; + }, [location.state?.mapState]); + + const [isMaximized, setIsMaximized] = useState(location.state?.openMap ?? false); + const map = useRef(null); + + const { data, loading, error } = useGetTagsWithThumbnail( + {}, + {}, + TagType.LOCATION, + ['name:asc'], + NO_LIMIT, + 'cache-first' + ); + + const flattened = useSimplifiedQueryResponseData(data); + const flattenedTags: ExtendedFlatTag[] | undefined = flattened + ? Object.values(flattened)[0] + : undefined; + + const localMap = useMemo( + () => ( + + ), + [flattenedTags, initialMapValues.center, initialMapValues.zoom, isMaximized] + ); + + if (error) { + return ; + } else if (loading) { + return ; + } else { + return ( + <> + {isMaximized ? ( +
{localMap}
+ ) : ( + localMap + )} + + ); + } +}; + +export default PictureMap; diff --git a/projects/bp-gallery/src/components/common/PictureMapView.tsx b/projects/bp-gallery/src/components/common/PictureMapView.tsx new file mode 100644 index 000000000..465ee175c --- /dev/null +++ b/projects/bp-gallery/src/components/common/PictureMapView.tsx @@ -0,0 +1,226 @@ +import { ZoomInMapOutlined, ZoomOutMapOutlined } from '@mui/icons-material'; +import { DivIcon, LatLng, Map, MarkerCluster, MarkerOptions, Point } from 'leaflet'; +import myMarkerIcon from 'leaflet/dist/images/marker-icon-2x.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; +import { Dispatch, RefObject, SetStateAction } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { useTranslation } from 'react-i18next'; +import { MapContainer, Marker, TileLayer } from 'react-leaflet'; +import MarkerClusterGroup from 'react-leaflet-cluster'; +import { PictureOrigin, asUploadPath } from '../../helpers/app-helpers'; +import { useVisit } from '../../helpers/history'; +import { FlatPicture, FlatTag, Thumbnail } from '../../types/additionalFlatTypes'; +import { useGetDescendantsMatrix } from '../views/location-curating/tag-structure-helpers'; + +export interface ExtendedFlatTag extends FlatTag { + thumbnail: Thumbnail[]; + pictures: FlatPicture[]; + verified_pictures: FlatPicture[]; +} + +interface ExtendedMarkerOptions extends MarkerOptions { + locationTag?: ExtendedFlatTag; +} + +const getCommonSupertag = ( + tags: ExtendedFlatTag[], + descendantsMatrix?: { + [k: string]: { + [k: string]: boolean; + }; + } +) => { + if (!tags.length || !descendantsMatrix) { + return; + } + let potentialCommonSupertag = tags[0]; + for (const tag of tags) { + if (descendantsMatrix[potentialCommonSupertag.id][tag.id]) { + potentialCommonSupertag = tag; + } else if (!descendantsMatrix[tag.id][potentialCommonSupertag.id]) { + return; + } + } + return potentialCommonSupertag; +}; + +const PictureMapView = ({ + isMaximized, + setIsMaximized, + initialMapValues, + locations, + widthStyle, + heightStyle, + map, +}: { + isMaximized: boolean; + setIsMaximized: Dispatch>; + initialMapValues: { + center: LatLng; + zoom: number; + }; + locations: ExtendedFlatTag[] | undefined; + widthStyle: string; + heightStyle: string; + map: RefObject; +}) => { + const { descendantsMatrix } = useGetDescendantsMatrix(locations); + const { t } = useTranslation(); + + const getDividerIcon = (locationTags: ExtendedFlatTag[], clusterLocationCount?: number) => { + const locationTag = locationTags.length === 1 ? locationTags[0] : undefined; + let pictureCount = 0; + let subLocationCount = 0; + const thumbnails = locationTags.map(tag => tag.thumbnail[0]); + locationTags.forEach(tag => { + pictureCount += tag.pictures.length; + subLocationCount += tag.child_tags?.length ?? 0; + }); + return new DivIcon({ + html: renderToStaticMarkup( +
+ {[ + 'bottom-0 left-0 z-50', + 'bg-white bottom-2 left-2 z-40', + 'bg-white bottom-4 left-4 shadow-[5px_-5px_10px_10px_rgba(0,0,0,0.2)] z-30', + ].map((extraClassNames, index) => ( +
+ +
+ ))} +
+

+ {locationTag + ? locationTag.name + : t('map.locations', { count: clusterLocationCount ?? 0 })} +

+
+ {t('common.pictureCount', { count: pictureCount })} +
+
+ {t('map.sublocations', { count: subLocationCount })} +
+
+ {clusterLocationCount && clusterLocationCount > 1 ? ( +
+ + {clusterLocationCount > 99 ? '99+' : clusterLocationCount} + +
+ ) : null} +
+ +
+
+ +
+
+ ), + iconSize: new Point(0, 0), + iconAnchor: new Point(214.5, -52), + }); + }; + + const MyMarker = ({ + position, + locationTag, + }: { + position: LatLng; + locationTag: ExtendedFlatTag; + }) => { + const { visit } = useVisit(); + const dividerIcon = getDividerIcon([locationTag]); + + const options = { + icon: dividerIcon, + position: position, + eventHandlers: { + click: (_: any) => { + visit('/show-more/location/' + locationTag.id, { + mapState: { + center: map.current?.getCenter() ?? initialMapValues.center, + zoom: map.current?.getZoom() ?? initialMapValues.zoom, + }, + wasOpenMap: isMaximized, + }); + }, + }, + locationTag: locationTag, + }; + + return ; + }; + + const createCustomClusterIcon = (cluster: MarkerCluster) => { + const tags = cluster + .getAllChildMarkers() + .map(marker => (marker.options as ExtendedMarkerOptions).locationTag!); + const tagsWithoutParents = tags.filter(tag => !tag.parent_tags?.length); + if (tagsWithoutParents.length) { + return getDividerIcon(tagsWithoutParents, tags.length); + } else { + const commonSupertag = getCommonSupertag(tags, descendantsMatrix); + if (commonSupertag) { + return getDividerIcon([commonSupertag], tags.length); + } else { + return getDividerIcon(tags, tags.length); + } + } + }; + + return ( +
+
{ + event.stopPropagation(); + setIsMaximized(value => !value); + }} + > + {isMaximized ? : } +
+ + + + + {locations?.map(location => + location.coordinates && location.thumbnail.length && location.thumbnail[0].media ? ( + + ) : null + )} + + +
+ ); +}; + +export default PictureMapView; diff --git a/projects/bp-gallery/src/components/common/TagOverview.tsx b/projects/bp-gallery/src/components/common/TagOverview.tsx index 73673a566..0a40c7ba6 100644 --- a/projects/bp-gallery/src/components/common/TagOverview.tsx +++ b/projects/bp-gallery/src/components/common/TagOverview.tsx @@ -13,7 +13,9 @@ import useGetTagsWithThumbnail from '../../hooks/get-tags-with-thumbnail.hook'; import { FlatTag, TagType, Thumbnail } from '../../types/additionalFlatTypes'; import DecadesList from '../views/search/DecadesList'; import TagList from '../views/search/TagList'; +import Loading from './Loading'; import './PictureOverview.scss'; +import QueryErrorDisplay from './QueryErrorDisplay'; const MAX_TAGS_PER_ROW = 3; @@ -73,7 +75,7 @@ const TagOverview = ({ }, [onResize]); // check if there is a tag - const { data } = useGetTagsWithThumbnail( + const { data, loading, error } = useGetTagsWithThumbnail( queryParams, thumbnailQueryParams, type, @@ -87,7 +89,16 @@ const TagOverview = ({ ? Object.values(flattened)[0] : undefined; - if (flattenedTags?.length === 0 && type !== TagType.TIME_RANGE) { + // recalculate when done loading + useEffect(() => { + setRowLength(calculateMaxCategoriesPerRow(ref.current?.clientWidth ?? 0)); + }, [calculateMaxCategoriesPerRow, loading]); + + if (error) { + return ; + } else if (loading) { + return ; + } else if (flattenedTags?.length === 0 && type !== TagType.TIME_RANGE) { return
; } else { return ( diff --git a/projects/bp-gallery/src/components/views/discover/DiscoverView.tsx b/projects/bp-gallery/src/components/views/discover/DiscoverView.tsx index 705f83aa3..6d87a7387 100644 --- a/projects/bp-gallery/src/components/views/discover/DiscoverView.tsx +++ b/projects/bp-gallery/src/components/views/discover/DiscoverView.tsx @@ -1,4 +1,4 @@ -import { AccessTime, ThumbUp, Widgets } from '@mui/icons-material'; +import { AccessTime, GridView, Map, ThumbUp } from '@mui/icons-material'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useFlag } from '../../../helpers/growthbook'; @@ -8,6 +8,7 @@ import OverviewContainer, { OverviewContainerPosition, OverviewContainerTab, } from '../../common/OverviewContainer'; +import PictureMap from '../../common/PictureMap'; import PictureOverview from '../../common/PictureOverview'; import TagOverview from '../../common/TagOverview'; import TimelineComponent from '../../common/picture-gallery/TimelineComponent'; @@ -28,7 +29,7 @@ const DiscoverView = () => { }, { title: t('discover.decades'), - icon: , + icon: , content: ( { }, ]; }, [t]); + + const locationTabs: OverviewContainerTab[] = useMemo(() => { + return [ + { + title: t('discover.map'), + icon: , + content: , + }, + { + title: t('discover.locations'), + icon: , + content: ( + { + visit('/show-more/location'); + }} + rows={2} + /> + ), + }, + ]; + }, [t, visit]); + const showStories = useFlag('showstories'); return (
@@ -88,16 +119,10 @@ const DiscoverView = () => { /> - { - visit('/show-more/location'); - }} - rows={2} + { return { - center: position ?? BAD_HARZBURG_POS, + center: position ?? BAD_HARZBURG_COORDINATES, zoom: 10, }; }, [position]); diff --git a/projects/bp-gallery/src/components/views/location-curating/tag-structure-helpers.tsx b/projects/bp-gallery/src/components/views/location-curating/tag-structure-helpers.tsx index 992ad8bb6..15bd03066 100644 --- a/projects/bp-gallery/src/components/views/location-curating/tag-structure-helpers.tsx +++ b/projects/bp-gallery/src/components/views/location-curating/tag-structure-helpers.tsx @@ -1,7 +1,58 @@ +import { LatLng } from 'leaflet'; import { uniqBy } from 'lodash'; import { useMemo } from 'react'; import { FlatTag } from '../../../types/additionalFlatTypes'; +export const BAD_HARZBURG_COORDINATES = new LatLng(51.8392573, 10.5279953); + +const useGetTagsById = (flattenedTags: FlatTag[] | undefined) => { + return useMemo(() => { + if (!flattenedTags) return; + return Object.fromEntries( + flattenedTags.map(tag => [ + tag.id, + { ...tag, child_tags: [] as FlatTag[], unacceptedSubtags: 0 }, + ]) + ); + }, [flattenedTags]); +}; + +const useGetTagTree = ( + flattenedTags: FlatTag[] | undefined, + tagsById: { [k: string]: FlatTag } | undefined +) => { + return useMemo(() => { + if (!flattenedTags || !tagsById) return undefined; + + // set child tags for each tag in tree + for (const tag of Object.values(tagsById)) { + tag.parent_tags?.forEach(parentTag => { + tagsById[parentTag.id].child_tags?.push(tag); + }); + } + for (const tag of Object.values(tagsById)) { + tagsById[tag.id].child_tags?.sort((a, b) => a.name.localeCompare(b.name)); + } + // filter for roots of tree + const sortedTagTree = Object.values(tagsById) + .filter(tag => !tag.parent_tags?.length || tag.root) + .sort((a, b) => a.name.localeCompare(b.name)); + + //replace stubs with complete parent tags + for (const flatTag of flattenedTags) { + if (!(flatTag.id in tagsById)) { + continue; + } + const tag = tagsById[flatTag.id]; + tag.parent_tags = tag.parent_tags + ?.map(parentTag => tagsById[parentTag.id]) + .filter(parentTag => !!parentTag); + } + + return sortedTagTree; + }, [flattenedTags, tagsById]); +}; + const useGetTopologicalOrder = (tagsById: { [k: string]: FlatTag } | undefined) => { return useMemo(() => { if (!tagsById) { @@ -44,46 +95,9 @@ export const useGetTagStructures = ( currentParentTag?: FlatTag, currentIsRoot?: boolean ) => { - const tagsById = useMemo(() => { - if (!flattenedTags) return; - return Object.fromEntries( - flattenedTags.map(tag => [ - tag.id, - { ...tag, child_tags: [] as FlatTag[], unacceptedSubtags: 0 }, - ]) - ); - }, [flattenedTags]); + const tagsById = useGetTagsById(flattenedTags); - const tagTree = useMemo(() => { - if (!flattenedTags || !tagsById) return undefined; - - // set child tags for each tag in tree - for (const tag of Object.values(tagsById)) { - tag.parent_tags?.forEach(parentTag => { - tagsById[parentTag.id].child_tags.push(tag); - }); - } - for (const tag of Object.values(tagsById)) { - tagsById[tag.id].child_tags.sort((a, b) => a.name.localeCompare(b.name)); - } - // filter for roots of tree - const sortedTagTree = Object.values(tagsById) - .filter(tag => !tag.parent_tags?.length || tag.root) - .sort((a, b) => a.name.localeCompare(b.name)); - - //replace stubs with complete parent tags - for (const flatTag of flattenedTags) { - if (!(flatTag.id in tagsById)) { - continue; - } - const tag = tagsById[flatTag.id]; - tag.parent_tags = tag.parent_tags - ?.map(parentTag => tagsById[parentTag.id]) - .filter(parentTag => !!parentTag); - } - - return sortedTagTree; - }, [flattenedTags, tagsById]); + const tagTree = useGetTagTree(flattenedTags, tagsById); const tagChildTags = useMemo(() => { return flattenedTags && tagsById @@ -192,3 +206,42 @@ export const useGetBreadthFirstOrder = ( return tagOrder; }; + +export const useGetDescendantsMatrix = (flattenedTags: FlatTag[] | undefined) => { + const tagsById = useGetTagsById(flattenedTags); + const tagTree = useGetTagTree(flattenedTags, tagsById); + const topologicalOrder = useGetTopologicalOrder(tagsById); + + const descendantsMatrix = useMemo(() => { + if (!tagTree || !topologicalOrder || !flattenedTags) return; + + const tempDescendantsMatrix = Object.fromEntries( + topologicalOrder.map(tag => [ + tag.id, + Object.fromEntries(topologicalOrder.map(otherTag => [otherTag.id, false])), + ]) + ); + + const visit = (tag: FlatTag) => { + tempDescendantsMatrix[tag.id][tag.id] = true; + if (!tag.child_tags) { + return; + } + for (const child of tag.child_tags) { + flattenedTags.forEach(flatTag => { + tempDescendantsMatrix[child.id][flatTag.id] ||= tempDescendantsMatrix[tag.id][flatTag.id]; + }); + } + }; + + for (const tag of topologicalOrder) { + visit(tag); + } + + return tempDescendantsMatrix; + }, [flattenedTags, tagTree, topologicalOrder]); + + return { + descendantsMatrix, + }; +}; diff --git a/projects/bp-gallery/src/graphql/APIConnector.tsx b/projects/bp-gallery/src/graphql/APIConnector.tsx index 1577ccbda..5dda90540 100644 --- a/projects/bp-gallery/src/graphql/APIConnector.tsx +++ b/projects/bp-gallery/src/graphql/APIConnector.tsx @@ -2035,6 +2035,7 @@ export type Query = { faceTags?: Maybe; findPicturesByAllSearch?: Maybe>>; getAllLocationTags?: Maybe; + getLocationTagsWithThumbnail?: Maybe; keywordTag?: Maybe; keywordTags?: Maybe; link?: Maybe; @@ -2179,6 +2180,13 @@ export type QueryFindPicturesByAllSearchArgs = { textFilter?: InputMaybe; }; +export type QueryGetLocationTagsWithThumbnailArgs = { + filters?: InputMaybe; + pagination?: InputMaybe; + sortBy?: InputMaybe>>; + thumbnailFilters?: InputMaybe; +}; + export type QueryKeywordTagArgs = { id?: InputMaybe; }; @@ -3333,34 +3341,7 @@ export type GetLocationTagsWithThumbnailQueryVariables = Exact<{ sortBy?: InputMaybe> | InputMaybe>; }>; -export type GetLocationTagsWithThumbnailQuery = { - locationTags?: { - data: Array<{ - id?: string | null; - attributes?: { - name: string; - thumbnail?: { - data: Array<{ - attributes?: { - media: { - data?: { attributes?: { formats?: any | null; provider: string } | null } | null; - }; - } | null; - }>; - } | null; - verified_thumbnail?: { - data: Array<{ - attributes?: { - media: { - data?: { attributes?: { formats?: any | null; provider: string } | null } | null; - }; - } | null; - }>; - } | null; - } | null; - }>; - } | null; -}; +export type GetLocationTagsWithThumbnailQuery = { getLocationTagsWithThumbnail?: any | null }; export type GetMostLikedPicturesQueryVariables = Exact<{ filters: PictureFiltersInput; @@ -6165,45 +6146,12 @@ export const GetLocationTagsWithThumbnailDocument = gql` $pagination: PaginationArg! $sortBy: [String] ) { - locationTags(filters: $filters, pagination: $pagination, sort: $sortBy) { - data { - id - attributes { - name - thumbnail: pictures(filters: $thumbnailFilters, pagination: { limit: 1 }) { - data { - attributes { - media { - data { - attributes { - formats - provider - } - } - } - } - } - } - verified_thumbnail: verified_pictures( - filters: $thumbnailFilters - pagination: { limit: 1 } - ) { - data { - attributes { - media { - data { - attributes { - formats - provider - } - } - } - } - } - } - } - } - } + getLocationTagsWithThumbnail( + filters: $filters + thumbnailFilters: $thumbnailFilters + pagination: $pagination + sortBy: $sortBy + ) } `; diff --git a/projects/bp-gallery/src/graphql/operation.graphql b/projects/bp-gallery/src/graphql/operation.graphql index 0c2bd4f58..d6264e2db 100644 --- a/projects/bp-gallery/src/graphql/operation.graphql +++ b/projects/bp-gallery/src/graphql/operation.graphql @@ -18,7 +18,7 @@ query getExhibitions($archiveId: ID $sortBy: [String] = ["createdAt:desc"]) { ex query getFaceTags($pictureId: ID!) { faceTags(filters: { picture: { id: { eq: $pictureId } } }) { data { id attributes { x y tag_direction person_tag { data { id attributes { name } } } } } } } query getIdeaLotContent($exhibitionId: ID!) { exhibition(id: $exhibitionId) { data { id attributes { idealot_pictures { data { id attributes { subtitle picture { data { id attributes { media { data { id attributes { width height formats url updatedAt provider } } } } } } } } } } } } } query getKeywordTagsWithThumbnail( $filters: KeywordTagFiltersInput = {} $thumbnailFilters: PictureFiltersInput = {} $pagination: PaginationArg! $sortBy: [String] ) { keywordTags(filters: $filters pagination: $pagination sort: $sortBy) { data { id attributes { name thumbnail: pictures(filters: $thumbnailFilters pagination: { limit: 1 }) { data { attributes { media { data { attributes { formats provider } } } } } } verified_thumbnail: verified_pictures( filters: $thumbnailFilters pagination: { limit: 1 } ) { data { attributes { media { data { attributes { formats provider } } } } } } } } } } -query getLocationTagsWithThumbnail( $filters: LocationTagFiltersInput = {} $thumbnailFilters: PictureFiltersInput = {} $pagination: PaginationArg! $sortBy: [String] ) { locationTags(filters: $filters pagination: $pagination sort: $sortBy) { data { id attributes { name thumbnail: pictures(filters: $thumbnailFilters pagination: { limit: 1 }) { data { attributes { media { data { attributes { formats provider } } } } } } verified_thumbnail: verified_pictures( filters: $thumbnailFilters pagination: { limit: 1 } ) { data { attributes { media { data { attributes { formats provider } } } } } } } } } } +query getLocationTagsWithThumbnail( $filters: LocationTagFiltersInput = {} $thumbnailFilters: PictureFiltersInput = {} $pagination: PaginationArg! $sortBy: [String] ) { getLocationTagsWithThumbnail( filters: $filters thumbnailFilters: $thumbnailFilters pagination: $pagination sortBy: $sortBy ) } query getMostLikedPictures($filters: PictureFiltersInput! $pagination: PaginationArg!) { pictures( filters: { and: [{ likes: { gt: 0 } } $filters] } pagination: $pagination sort: ["likes:desc"] ) { data { id attributes { is_text comments { data { id } } likes media { data { id attributes { width height formats url updatedAt provider } } } picture_sequence { data { id attributes { pictures(sort: "picture_sequence_order:asc") { data { id } } } } } } } } } query getMultiplePictureInfo($pictureIds: [ID!]) { pictures(filters: { id: { in: $pictureIds } }) { data { id attributes { descriptions(sort: "createdAt:asc") { data { id attributes { text } } } time_range_tag { data { id attributes { start end isEstimate } } } verified_time_range_tag { data { id attributes { start end isEstimate } } } keyword_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } verified_keyword_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } location_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } verified_location_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } person_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } verified_person_tags(sort: "updatedAt:asc") { data { id attributes { name updatedAt } } } collections(publicationState: PREVIEW) { data { id attributes { name } } } media { data { id attributes { url updatedAt provider } } } comments(publicationState: PREVIEW sort: "date:asc") { data { id attributes { text author date publishedAt pinned } } } is_text linked_pictures { data { id } } linked_texts { data { id } } archive_tag { data { id attributes { name } } } } } } } query getParameterizedPermissions($userId: ID) { parameterizedPermissions(filters: { users_permissions_user: { id: { eq: $userId } } }) { data { id attributes { operation_name archive_tag { data { id } } on_other_users } } } } diff --git a/projects/bp-gallery/src/graphql/schema/schema.json b/projects/bp-gallery/src/graphql/schema/schema.json index 9ad22c488..488fd627f 100644 --- a/projects/bp-gallery/src/graphql/schema/schema.json +++ b/projects/bp-gallery/src/graphql/schema/schema.json @@ -17982,6 +17982,63 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "getLocationTagsWithThumbnail", + "description": null, + "args": [ + { + "name": "filters", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "LocationTagFiltersInput", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "thumbnailFilters", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PictureFiltersInput", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "pagination", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaginationArg", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sortBy", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "findPicturesByAllSearch", "description": null, diff --git a/projects/bp-gallery/src/helpers/history.ts b/projects/bp-gallery/src/helpers/history.ts index b1d83bfff..e96e31330 100644 --- a/projects/bp-gallery/src/helpers/history.ts +++ b/projects/bp-gallery/src/helpers/history.ts @@ -1,4 +1,5 @@ import { History, Location } from 'history'; +import { LatLng } from 'leaflet'; import { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { FoldoutStatus } from '../components/views/location-curating/FoldoutStatusContext'; @@ -20,6 +21,8 @@ type LocationState = { showBack?: boolean; scrollPos?: number; open?: boolean; + openMap?: boolean; + mapState?: { center: LatLng; zoom: number }; archiveId?: string; openBranches?: FoldoutStatus; }; @@ -37,6 +40,8 @@ export const useVisit = () => { options?: { state?: LocationState; wasOpen?: boolean; + wasOpenMap?: boolean; + mapState?: { center: LatLng; zoom: number }; openBranches?: FoldoutStatus; customScrollPos?: number; } @@ -45,6 +50,8 @@ export const useVisit = () => { ...history.location.state, scrollPos: options?.customScrollPos ?? scrollRef.current, open: options?.wasOpen, + openMap: options?.wasOpenMap, + mapState: options?.mapState, openBranches: options?.openBranches, }); history.push(url, { showBack: options?.state?.showBack ?? true, ...options?.state }); diff --git a/projects/bp-gallery/src/hooks/get-tags-with-thumbnail.hook.ts b/projects/bp-gallery/src/hooks/get-tags-with-thumbnail.hook.ts index 44025b145..56f12f262 100644 --- a/projects/bp-gallery/src/hooks/get-tags-with-thumbnail.hook.ts +++ b/projects/bp-gallery/src/hooks/get-tags-with-thumbnail.hook.ts @@ -1,4 +1,4 @@ -import { NUMBER_OF_PICTURES_LOADED_PER_FETCH } from './get-pictures.hook'; +import { WatchQueryFetchPolicy } from '@apollo/client'; import { KeywordTagFiltersInput, LocationTagFiltersInput, @@ -7,14 +7,16 @@ import { } from '../graphql/APIConnector'; import { TagType } from '../types/additionalFlatTypes'; import useGenericTagEndpoints from './generic-endpoints.hook'; -import { WatchQueryFetchPolicy } from '@apollo/client'; +import { NUMBER_OF_PICTURES_LOADED_PER_FETCH } from './get-pictures.hook'; + +export const NO_LIMIT = Symbol('NO_LIMIT'); const useGetTagsWithThumbnail = ( queryParams: LocationTagFiltersInput | KeywordTagFiltersInput | PersonTagFiltersInput | undefined, thumbnailQueryParams: PictureFiltersInput | undefined, type: TagType, sortBy: string[] = ['name:asc'], - limit: number = NUMBER_OF_PICTURES_LOADED_PER_FETCH, + limit: number | typeof NO_LIMIT = NUMBER_OF_PICTURES_LOADED_PER_FETCH, fetchPolicy?: WatchQueryFetchPolicy ) => { const { tagsWithThumbnailQuery } = useGenericTagEndpoints(type); @@ -30,7 +32,7 @@ const useGetTagsWithThumbnail = ( } as PictureFiltersInput, pagination: { start: 0, - limit: limit, + limit: limit === NO_LIMIT ? undefined : limit, }, sortBy, }, diff --git a/projects/bp-gallery/src/shared/locales/de.json b/projects/bp-gallery/src/shared/locales/de.json index ed9ef86b6..1fce6e500 100755 --- a/projects/bp-gallery/src/shared/locales/de.json +++ b/projects/bp-gallery/src/shared/locales/de.json @@ -582,6 +582,7 @@ "our-categories": "Unsere Kategorien", "most-liked": "Bestbewertete Bilder", "our-pictures": "Unsere Bilder", + "map": "Karte", "timeline": "Zeitstrahl" }, "exhibition": { @@ -669,5 +670,11 @@ "show-location": "Ort anzeigen.", "hide-location": "Ort verstecken.", "delete-location": "Ort löschen." + }, + "map": { + "locations_other": "{{ count }} Orte", + "locations_one": "{{ count }} Ort", + "sublocations_other": "{{ count }} Unterorte", + "sublocations_one": "{{ count }} Unterort" } } diff --git a/projects/bp-gallery/yarn.lock b/projects/bp-gallery/yarn.lock index d5286bb63..19690b23d 100755 --- a/projects/bp-gallery/yarn.lock +++ b/projects/bp-gallery/yarn.lock @@ -2120,7 +2120,14 @@ resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.34.tgz#c0fb25e4d957e0ee2e497c1f553d7f8bb668fd75" integrity sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw== -"@types/leaflet@^1.9.3": +"@types/leaflet.markercluster@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz#d039ada408a30bda733b19a24cba89b81f0ace4b" + integrity sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet@*", "@types/leaflet@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.3.tgz#7aac302189eb3aa283f444316167995df42a5467" integrity sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA== @@ -5814,6 +5821,11 @@ lazy-ass@1.6.0, lazy-ass@^1.6.0: resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" integrity sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw== +leaflet.markercluster@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz#9cdb52a4eab92671832e1ef9899669e80efc4056" + integrity sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA== + leaflet@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.3.tgz#52ec436954964e2d3d39e0d433da4b2500d74414" @@ -7101,6 +7113,13 @@ react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-leaflet-cluster@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz#9e5299efb7b16eff75511a47ed4a5d763dcf55b5" + integrity sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g== + dependencies: + leaflet.markercluster "^1.5.3" + react-leaflet@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" diff --git a/projects/bp-graphql/src/operations/getLocationTagsWithThumbnail.ts b/projects/bp-graphql/src/operations/getLocationTagsWithThumbnail.ts index e5bcc4812..f724c2027 100644 --- a/projects/bp-graphql/src/operations/getLocationTagsWithThumbnail.ts +++ b/projects/bp-graphql/src/operations/getLocationTagsWithThumbnail.ts @@ -9,45 +9,12 @@ export default { $pagination: PaginationArg! $sortBy: [String] ) { - locationTags(filters: $filters, pagination: $pagination, sort: $sortBy) { - data { - id - attributes { - name - thumbnail: pictures(filters: $thumbnailFilters, pagination: { limit: 1 }) { - data { - attributes { - media { - data { - attributes { - formats - provider - } - } - } - } - } - } - verified_thumbnail: verified_pictures( - filters: $thumbnailFilters - pagination: { limit: 1 } - ) { - data { - attributes { - media { - data { - attributes { - formats - provider - } - } - } - } - } - } - } - } - } + getLocationTagsWithThumbnail( + filters: $filters + thumbnailFilters: $thumbnailFilters + pagination: $pagination + sortBy: $sortBy + ) } `, } satisfies Operation; diff --git a/projects/bp-strapi/src/api/location-tag/services/custom-resolver.ts b/projects/bp-strapi/src/api/location-tag/services/custom-resolver.ts index 734788b25..0101ed88d 100644 --- a/projects/bp-strapi/src/api/location-tag/services/custom-resolver.ts +++ b/projects/bp-strapi/src/api/location-tag/services/custom-resolver.ts @@ -8,3 +8,79 @@ export const getAllLocationTags = async (strapi: StrapiExtended) => populate: ['coordinates', 'synonyms', 'parent_tags'], }) ); + +export const getLocationTagsWithThumbnail = async ( + strapi: StrapiExtended, + filters: any = {}, + thumbnailFilters: any = {}, + pagination: any, + sortBy: string[] +) => { + const { transformArgs } = strapi.plugin('graphql').service('builders').utils; + + const locationArgs = transformArgs( + { + filters, + pagination, + sortBy, + }, + { + contentType: strapi.contentTypes['api::location-tag.location-tag'], + usePagination: true, + } + ); + + const locationTags = await strapi.entityService.findMany('api::location-tag.location-tag', { + ...locationArgs, + fields: ['name'], + populate: ['coordinates', 'child_tags', 'parent_tags', 'pictures', 'verified_pictures'], + }); + + const { filters: transformedThumbnailFilters } = transformArgs( + { + filters: thumbnailFilters, + }, + { + contentType: strapi.contentTypes['api::picture.picture'], + } + ); + + const thumbnails = await strapi.entityService.findMany('api::picture.picture', { + filters: { + $and: [ + { + id: { + $in: locationTags + .flatMap(tag => [tag?.pictures?.[0], tag?.verified_pictures?.[0]]) + .map(picture => (picture as { id: number } | undefined)?.id) + .filter(id => id), + }, + }, + transformedThumbnailFilters, + ], + }, + populate: ['media' as 'createdBy'], + }); + + const thumbnailsById = Object.fromEntries( + thumbnails.map(thumbnail => [thumbnail?.id, thumbnail]) + ); + + const lookupThumbnail = (pictures?: unknown[]) => { + const id = (pictures?.[0] as { id: number } | undefined)?.id; + if (!id) { + return []; + } + const thumbnail = thumbnailsById[id]; + if (!thumbnail) { + return []; + } + return [thumbnail]; + }; + + return locationTags.map(tag => ({ + ...tag, + thumbnail: lookupThumbnail(tag?.pictures), + verified_thumbnail: lookupThumbnail(tag?.verified_pictures), + })); +}; diff --git a/projects/bp-strapi/src/index.ts b/projects/bp-strapi/src/index.ts index 48fb93429..d1d9ce0a1 100644 --- a/projects/bp-strapi/src/index.ts +++ b/projects/bp-strapi/src/index.ts @@ -9,7 +9,10 @@ import { } from './api/collection/services/custom-resolver'; import { contact } from './api/contact/services/contact'; import { mergeSourceTagIntoTargetTag } from './api/custom-tag-resolver'; -import { getAllLocationTags } from './api/location-tag/services/custom-resolver'; +import { + getAllLocationTags, + getLocationTagsWithThumbnail, +} from './api/location-tag/services/custom-resolver'; import { addPermission, addUser, @@ -112,6 +115,24 @@ export default { return getAllLocationTags(strapi as StrapiExtended); }, }), + queryField('getLocationTagsWithThumbnail', { + type: 'JSON', + args: { + filters: 'LocationTagFiltersInput', + thumbnailFilters: 'PictureFiltersInput', + pagination: 'PaginationArg', + sortBy: list('String'), + }, + resolve(_, { filters = {}, thumbnailFilters = {}, pagination, sortBy }) { + return getLocationTagsWithThumbnail( + strapi as StrapiExtended, + filters, + thumbnailFilters, + pagination, + sortBy + ); + }, + }), mutationField('updatePictureWithTagCleanup', { type: 'ID', args: { diff --git a/projects/bp-strapi/src/types.ts b/projects/bp-strapi/src/types.ts index 7c5d6a780..14dabd1d8 100644 --- a/projects/bp-strapi/src/types.ts +++ b/projects/bp-strapi/src/types.ts @@ -83,7 +83,7 @@ type FilterParameters = Partial< > >; -type OrderByParameter = string | { [key: string]: string } | { [key: string]: string }[]; +type OrderByParameter = string | string[] | { [key: string]: string } | { [key: string]: string }[]; type ResponseAttributes = | null