From 00267098a8dc2f22197edf12ff10bc6f9147e48a Mon Sep 17 00:00:00 2001 From: Philipp Wambach Date: Wed, 20 May 2020 09:51:17 +0200 Subject: [PATCH] feat(story-tags): add story filter bar (#344) * feat(story-tags): add story filter bar * feat(story-tags): translate tags --- i18n/de.json | 21 ++- i18n/en.json | 21 ++- ...{download-icon.jsx => arrow-left-icon.tsx} | 8 +- .../components/icons/arrow-right-icon.tsx | 15 +++ src/scripts/components/icons/check-icon.tsx | 15 +++ .../stories-selector/stories-selector.tsx | 2 + .../components/story-filter/story-filter.styl | 75 +++++++++++ .../components/story-filter/story-filter.tsx | 124 ++++++++++++++++++ .../story-list-item/story-list-item.styl | 3 +- .../components/story-tags/story-tags.styl | 4 + .../components/story-tags/story-tags.tsx | 3 +- src/scripts/components/url-sync/url-sync.tsx | 23 +++- storage/stories/stories-de.json | 25 +++- storage/stories/stories-en.json | 25 +++- tsconfig.json | 1 + 15 files changed, 348 insertions(+), 17 deletions(-) rename src/scripts/components/icons/{download-icon.jsx => arrow-left-icon.tsx} (51%) create mode 100644 src/scripts/components/icons/arrow-right-icon.tsx create mode 100644 src/scripts/components/icons/check-icon.tsx create mode 100644 src/scripts/components/story-filter/story-filter.styl create mode 100644 src/scripts/components/story-filter/story-filter.tsx diff --git a/i18n/de.json b/i18n/de.json index 33f93b1a5..bde9cfb52 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -32,5 +32,24 @@ "closeStory": "Geschichte schließen", "removeCompare": "Vergleich entfernen", "dataInfo": "Dateninformation", - "storiesSelected": "{numberSelected, number} {numberSelected, plural, one {Geschichte} other {Geschichten}} ausgewählt" + "storiesSelected": "{numberSelected, number} {numberSelected, plural, one {Geschichte} other {Geschichten}} ausgewählt", + "resetFilters": "Filter löschen", + "tags.nature": "Natur", + "tags.biomass": "Biomasse", + "tags.university": "Universität", + "tags.school": "Schule", + "tags.ocean": "Ozean", + "tags.water": "Wasser", + "tags.weather": "Wetter", + "tags.heating": "Erwärmung", + "tags.satellite": "Satellit", + "tags.temperature": "Temperatur", + "tags.seasurface": "Meeresoverfläche", + "tags.earth": "Erde", + "tags.wind": "Wind", + "tags.planet": "Planet", + "tags.landcover": "Landfläche", + "tags.aerosol": "Aerosol", + "tags.globalwarming": "Globale Erwärmung", + "tags.co2": "CO2" } diff --git a/i18n/en.json b/i18n/en.json index bc543a510..f5ee7a14a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -32,5 +32,24 @@ "closeStory": "Close story", "removeCompare": "Remove Compare", "dataInfo": "Data Information", - "storiesSelected": "{numberSelected, number} {numberSelected, plural, one {Story} other {Stories}} selected" + "storiesSelected": "{numberSelected, number} {numberSelected, plural, one {Story} other {Stories}} selected", + "resetFilters": "Reset Filters", + "tags.nature": "Nature", + "tags.biomass": "Biomass", + "tags.university": "University", + "tags.school": "School", + "tags.ocean": "Ocean", + "tags.water": "Water", + "tags.weather": "Weather", + "tags.heating": "Heating", + "tags.satellite": "Satellite", + "tags.temperature": "Temperature", + "tags.seasurface": "Sea Surface", + "tags.earth": "Earth", + "tags.wind": "Wind", + "tags.planet": "Planet", + "tags.landcover": "Land cover", + "tags.aerosol": "Aerosol", + "tags.globalwarming": "Global Warming", + "tags.co2": "CO2" } diff --git a/src/scripts/components/icons/download-icon.jsx b/src/scripts/components/icons/arrow-left-icon.tsx similarity index 51% rename from src/scripts/components/icons/download-icon.jsx rename to src/scripts/components/icons/arrow-left-icon.tsx index d4c82aeb5..7848318ae 100644 --- a/src/scripts/components/icons/download-icon.jsx +++ b/src/scripts/components/icons/arrow-left-icon.tsx @@ -1,15 +1,15 @@ import React, {FunctionComponent} from 'react'; -export const DownloadIcon: FunctionComponent = () => ( +export const ArrowLeftIcon: FunctionComponent = () => ( ); diff --git a/src/scripts/components/icons/arrow-right-icon.tsx b/src/scripts/components/icons/arrow-right-icon.tsx new file mode 100644 index 000000000..5ba31237c --- /dev/null +++ b/src/scripts/components/icons/arrow-right-icon.tsx @@ -0,0 +1,15 @@ +import React, {FunctionComponent} from 'react'; + +export const ArrowRightIcon: FunctionComponent = () => ( + + + +); diff --git a/src/scripts/components/icons/check-icon.tsx b/src/scripts/components/icons/check-icon.tsx new file mode 100644 index 000000000..888590fe1 --- /dev/null +++ b/src/scripts/components/icons/check-icon.tsx @@ -0,0 +1,15 @@ +import React, {FunctionComponent} from 'react'; + +export const CheckIcon: FunctionComponent = () => ( + + + +); diff --git a/src/scripts/components/stories-selector/stories-selector.tsx b/src/scripts/components/stories-selector/stories-selector.tsx index cd8eef9c2..136454d9f 100644 --- a/src/scripts/components/stories-selector/stories-selector.tsx +++ b/src/scripts/components/stories-selector/stories-selector.tsx @@ -2,6 +2,7 @@ import React, {FunctionComponent} from 'react'; import {useIntl} from 'react-intl'; import StoryList from '../story-list/story-list'; +import StoryFilter from '../story-filter/story-filter'; import Header from '../header/header'; import Share from '../share/share'; @@ -20,6 +21,7 @@ const StoriesSelector: FunctionComponent = () => { title={intl.formatMessage({id: 'storyMode'})}> + ); diff --git a/src/scripts/components/story-filter/story-filter.styl b/src/scripts/components/story-filter/story-filter.styl new file mode 100644 index 000000000..5e142964f --- /dev/null +++ b/src/scripts/components/story-filter/story-filter.styl @@ -0,0 +1,75 @@ +@require '../../../variables.styl' + +.storyFilter + display: flex + flex-direction: row + align-items: center + padding: emCalc(24px) emCalc(26px) 0 emCalc(26px) + +.arrowIcon + display: flex + justify-content: center + align-items: center + padding: 0 0.25em + width: emCalc(48px) + color: $textDefault + cursor: pointer + + &:hover + svg + color: white + +.tagScrollerOuter + position: relative + flex-grow: 1 + overflow-x: hidden + + &:after + position: absolute + top: 0 + left: 0 + width: 100% + height: 100% + background: linear-gradient(to right, $black 0%, transparent 1%, transparent 99%, $black 100%) + content: '' + pointer-events: none + +.tagScrollerInner + display: flex + flex-direction: row + +.tag + display: flex + flex-direction: row + align-items: center + margin-right: emCalc(24px) + min-height: emCalc(24px) + color: $textDefault + text-transform: uppercase + white-space: nowrap + font-size: 0.9em + cursor: pointer + + svg + margin-right: emCalc(4px) + width: emCalc(24px) + height: emCalc(24px) + transform: translateY(-1px) + +.selected + color: $textColor + +.resetButton + flex-grow: 0 + padding-top: emCalc(2px) + outline: none + border: none + background: transparent + color: $textColor + white-space: nowrap + font-size: 1rem + cursor: pointer + + &[disabled] + color: $darkGrey4 + cursor: default diff --git a/src/scripts/components/story-filter/story-filter.tsx b/src/scripts/components/story-filter/story-filter.tsx new file mode 100644 index 000000000..9f19166a1 --- /dev/null +++ b/src/scripts/components/story-filter/story-filter.tsx @@ -0,0 +1,124 @@ +import React, {FunctionComponent, useState, useEffect, useRef} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector, useDispatch} from 'react-redux'; +import cx from 'classnames'; +import {ArrowLeftIcon} from '../icons/arrow-left-icon'; +import {ArrowRightIcon} from '../icons/arrow-right-icon'; +import {CheckIcon} from '../icons/check-icon'; + +import {storyListSelector} from '../../selectors/story/list'; +import {selectedTagsSelector} from '../../selectors/story/selected-tags'; +import setSelectedStoryTags from '../../actions/set-selected-story-tags'; + +import styles from './story-filter.styl'; + +let translateValue = 0; +const scrollSpeed = 4; // pixels per frame + +const StoryFilter: FunctionComponent = () => { + const dispatch = useDispatch(); + const stories = useSelector(storyListSelector); + const selectedTags = useSelector(selectedTagsSelector); + const innerRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(false); + const [scrollRight, setScrollRight] = useState(false); + const maxScroll = + (innerRef.current?.scrollWidth || 0) - (innerRef.current?.offsetWidth || 0); + + const allTags: string[] = stories + .map(({tags}) => tags) + .filter(Boolean) + // @ts-ignore Array.flat() + .flat(); + const uniqTags = Array.from(new Set(allTags)); + const isSelected = (tag: string) => selectedTags.includes(tag); + const getTagClasses = (tag: string) => + cx(styles.tag, isSelected(tag) && styles.selected); + + const toggleTag = (tag: string) => { + const newTags = selectedTags.includes(tag) + ? selectedTags.filter(oldTag => oldTag !== tag) + : selectedTags.concat([tag]); + + dispatch(setSelectedStoryTags(newTags)); + }; + const resetTags = () => dispatch(setSelectedStoryTags([])); + + // handle left side scrolling + useEffect(() => { + if (!scrollLeft) { + return () => {}; + } + + const raf = {id: 0}; // keep reference to requestAniationFrame id + const loop = () => { + if (innerRef.current && translateValue < 0) { + translateValue += scrollSpeed; + innerRef.current.style.transform = `translateX(${translateValue}px)`; + raf.id = requestAnimationFrame(loop); + } + }; + + loop(); + + return () => cancelAnimationFrame(raf.id); + }, [scrollLeft]); + + // handle right side scrolling + useEffect(() => { + if (!scrollRight) { + return () => {}; + } + + const raf = {id: 0}; // keep reference to requestAniationFrame id + const loop = () => { + if (innerRef.current && translateValue > maxScroll * -1) { + translateValue -= scrollSpeed; + innerRef.current.style.transform = `translateX(${translateValue}px)`; + raf.id = requestAnimationFrame(loop); + } + }; + + loop(); + + return () => cancelAnimationFrame(raf.id); + }, [scrollRight, maxScroll]); + + return ( +
+
setScrollLeft(true)} + onMouseLeave={() => setScrollLeft(false)}> + +
+
+
+ {uniqTags.map(tag => ( +
toggleTag(tag)} + key={tag}> + {isSelected(tag) && } + +
+ ))} +
+
+
setScrollRight(true)} + onMouseLeave={() => setScrollRight(false)}> + +
+ +
+ ); +}; + +export default StoryFilter; diff --git a/src/scripts/components/story-list-item/story-list-item.styl b/src/scripts/components/story-list-item/story-list-item.styl index d92919700..7174ff4fb 100644 --- a/src/scripts/components/story-list-item/story-list-item.styl +++ b/src/scripts/components/story-list-item/story-list-item.styl @@ -5,8 +5,6 @@ flex-direction: column justify-content: flex-end margin: 5px - height: 100% - height: 300px border: 2px solid $darkGrey1 border-radius: 4px background-size: cover @@ -34,6 +32,7 @@ .imageInfo display: flex flex-direction: column + margin-top: emCalc(130px) padding: 16px height: 40% background-color: $darkGrey1 diff --git a/src/scripts/components/story-tags/story-tags.styl b/src/scripts/components/story-tags/story-tags.styl index 3a8af549c..b4d02f97c 100644 --- a/src/scripts/components/story-tags/story-tags.styl +++ b/src/scripts/components/story-tags/story-tags.styl @@ -2,14 +2,18 @@ .tags display: flex + flex-wrap: wrap .tag + margin-top: 0.5em margin-right: 0.5em padding: 0.2em 1em border: 1px solid $darkGrey4 border-radius: 12px + color: $textDefault text-transform: uppercase font-size: 0.8em + cursor: pointer &:hover background: $darkGrey2 diff --git a/src/scripts/components/story-tags/story-tags.tsx b/src/scripts/components/story-tags/story-tags.tsx index 9de2c8aac..ad6b9704d 100644 --- a/src/scripts/components/story-tags/story-tags.tsx +++ b/src/scripts/components/story-tags/story-tags.tsx @@ -1,4 +1,5 @@ import React, {FunctionComponent} from 'react'; +import {FormattedMessage} from 'react-intl'; import {useDispatch} from 'react-redux'; import cx from 'classnames'; @@ -34,7 +35,7 @@ const StoryTags: FunctionComponent = ({tags, selected}) => { event.stopPropagation(); toggleTag(tag); }}> - {tag} + ))} diff --git a/src/scripts/components/url-sync/url-sync.tsx b/src/scripts/components/url-sync/url-sync.tsx index 81b75bad7..003ba45e0 100644 --- a/src/scripts/components/url-sync/url-sync.tsx +++ b/src/scripts/components/url-sync/url-sync.tsx @@ -3,7 +3,9 @@ import {useHistory, useLocation} from 'react-router-dom'; import {useSelector} from 'react-redux'; import {globeStateSelector} from '../../selectors/globe/globe-state'; -import {getParamString} from '../../libs/globe-url-parameter'; +import {selectedTagsSelector} from '../../selectors/story/selected-tags'; +import {getParamString as getGlobeParamString} from '../../libs/globe-url-parameter'; +import {getParamString as getTagsParamString} from '../../libs/tags-url-parameter'; import {selectedLayerIdsSelector} from '../../selectors/layers/selected-ids'; // syncs the query parameters of the url when values change in store @@ -11,11 +13,12 @@ const UrlSync: FunctionComponent = () => { const history = useHistory(); const location = useLocation(); const globeState = useSelector(globeStateSelector); + const selectedTags = useSelector(selectedTagsSelector); const {mainId, compareId} = useSelector(selectedLayerIdsSelector); // set globe query params in url when globe state changes useEffect(() => { - const globeValue = getParamString(globeState, mainId, compareId); + const globeValue = getGlobeParamString(globeState, mainId, compareId); if (!globeValue) { return; @@ -28,6 +31,22 @@ const UrlSync: FunctionComponent = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [globeState, mainId, compareId, history]); // we don't want to check for location.search changes + // set story tag query params in url when tags state changes + useEffect(() => { + const tagsValue = getTagsParamString(selectedTags); + const params = new URLSearchParams(location.search); + + if (!tagsValue) { + params.delete('tags'); + } else { + params.set('tags', tagsValue); + } + + history.replace({search: params.toString()}); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTags, history]); // we don't want to check for location.search changes + return null; }; diff --git a/storage/stories/stories-de.json b/storage/stories/stories-de.json index 8ac5cdc83..92e63e01a 100644 --- a/storage/stories/stories-de.json +++ b/storage/stories/stories-de.json @@ -4,20 +4,39 @@ "title": "Planetarer Wärmespeicher", "description": "", "image": "assets/sst_large_18.jpg", - "tags": ["a", "b"] + "tags": ["nature", "biomass", "university", "co2", "globalwarming"] }, { "id": "story2", "title": "Geschichte 2", "description": "Das ist Geschichte 2", "image": "assets/story2.jpeg", - "tags": ["a", "c"] + "tags": [ + "nature", + "biomass", + "school", + "ocean", + "water", + "weather", + "heating" + ] }, { "id": "story3", "title": "Geschichte 3", "description": "Das ist Geschichte 3", - "image": "assets/story3.jpeg" + "image": "assets/story3.jpeg", + "tags": [ + "satellite", + "aerosol", + "landcover", + "temperature", + "seasurface", + "earth", + "planet", + "wind", + "heating" + ] }, { "id": "story4", diff --git a/storage/stories/stories-en.json b/storage/stories/stories-en.json index 77d999af5..884e4e265 100644 --- a/storage/stories/stories-en.json +++ b/storage/stories/stories-en.json @@ -4,20 +4,39 @@ "title": "Planetary Heat Store", "description": "", "image": "assets/sst_large_18.jpg", - "tags": ["a", "b"] + "tags": ["nature", "biomass", "university", "co2", "globalwarming"] }, { "id": "story2", "title": "Story 2", "description": "This is story 2", "image": "assets/story2.jpeg", - "tags": ["a", "c"] + "tags": [ + "nature", + "biomass", + "school", + "ocean", + "water", + "weather", + "heating" + ] }, { "id": "story3", "title": "Story 3", "description": "This is story 3", - "image": "assets/story3.jpeg" + "image": "assets/story3.jpeg", + "tags": [ + "satellite", + "aerosol", + "landcover", + "temperature", + "seasurface", + "earth", + "planet", + "wind", + "heating" + ] }, { "id": "story4", diff --git a/tsconfig.json b/tsconfig.json index aa7acd9b8..9a6459875 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "moduleResolution": "node", "target": "es5", "jsx": "react", + "downlevelIteration": true, "resolveJsonModule": true } }