diff --git a/packages/components/assets/icons/icon-views.svg b/packages/components/assets/icons/icon-views.svg new file mode 100644 index 0000000000..da95992128 --- /dev/null +++ b/packages/components/assets/icons/icon-views.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/Icon/Icon.tsx b/packages/components/src/Icon/Icon.tsx index 089ea31a17..86325533f8 100644 --- a/packages/components/src/Icon/Icon.tsx +++ b/packages/components/src/Icon/Icon.tsx @@ -95,6 +95,7 @@ export const glyphs: IGlyphs = { useful: iconMap.useful, thunderbolt: , filter: , + view: iconMap.view, } type WrapperProps = IProps & VerticalAlignProps & SpaceProps diff --git a/packages/components/src/Icon/svgs.tsx b/packages/components/src/Icon/svgs.tsx index 4d6fa9a629..5c6ddff39a 100644 --- a/packages/components/src/Icon/svgs.tsx +++ b/packages/components/src/Icon/svgs.tsx @@ -4,6 +4,7 @@ import starActiveSVG from '../../assets/icons/icon-star-active.svg' import verifiedSVG from '../../assets/icons/icon-verified-badge.svg' import usefulSVG from '../../assets/icons/icon-useful.svg' import commentSVG from '../../assets/icons/icon-comment.svg' +import viewSVG from '../../assets/icons/icon-views.svg' const imgStyle = { maxWidth: '100%', @@ -16,4 +17,5 @@ export const iconMap = { verified: icon, useful: icon, comment: icon, + view: icon, } diff --git a/packages/components/src/Icon/types.ts b/packages/components/src/Icon/types.ts index b25a73f19f..24db17b822 100644 --- a/packages/components/src/Icon/types.ts +++ b/packages/components/src/Icon/types.ts @@ -40,5 +40,6 @@ export type availableGlyphs = | 'useful' | 'verified' | 'filter' + | 'view' export type IGlyphs = { [k in availableGlyphs]: JSX.Element } diff --git a/packages/components/src/ViewsCounter/ViewsCounter.tsx b/packages/components/src/ViewsCounter/ViewsCounter.tsx new file mode 100644 index 0000000000..9df37e4eed --- /dev/null +++ b/packages/components/src/ViewsCounter/ViewsCounter.tsx @@ -0,0 +1,31 @@ +import { Text, Flex } from 'theme-ui' +import { Icon } from '..' +import { useTheme } from '@emotion/react' + +export interface IProps { + viewsCount: number +} + +export const ViewsCounter = (props: IProps) => { + const theme: any = useTheme() + + return ( + + + + {props.viewsCount} + {props.viewsCount !== 1 ? ' views' : ' view'} + + + ) +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index f32ad1384b..500b7b84a4 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -23,6 +23,7 @@ export { ImageGallery } from './ImageGallery/ImageGallery' export { Loader } from './Loader/Loader' export { LinkifyText } from './LinkifyText/LinkifyText' export { UsefulStatsButton } from './UsefulStatsButton/UsefulStatsButton' +export { ViewsCounter } from './ViewsCounter/ViewsCounter' export { CategoryTag } from './CategoryTag/CategoryTag' export { FileInformation } from './FileInformation/FileInformation' export { OsmGeocoding } from './OsmGeocoding/OsmGeocoding' diff --git a/src/models/howto.models.tsx b/src/models/howto.models.tsx index 7c3a10daad..54c2479f8d 100644 --- a/src/models/howto.models.tsx +++ b/src/models/howto.models.tsx @@ -22,6 +22,7 @@ export interface IHowto extends IHowtoFormInput, IModerable { // Comments were added in V2, old howto's may not have the property comments?: IComment[] total_downloads?: number + total_views?: number mentions: UserMention[] previousSlugs?: string[] } diff --git a/src/models/research.models.tsx b/src/models/research.models.tsx index 4cd26bc372..9dd4c8d9e6 100644 --- a/src/models/research.models.tsx +++ b/src/models/research.models.tsx @@ -18,6 +18,7 @@ export namespace IResearch { export interface Item extends FormInput { updates: Update[] _createdBy: string + total_views?: number } /** A research item update */ @@ -27,6 +28,7 @@ export namespace IResearch { images: Array videoUrl?: string comments?: IComment[] + total_views?: number } export interface FormInput extends IModerable { diff --git a/src/pages/Howto/Content/Howto/Howto.tsx b/src/pages/Howto/Content/Howto/Howto.tsx index cf3174a741..c639b28b32 100644 --- a/src/pages/Howto/Content/Howto/Howto.tsx +++ b/src/pages/Howto/Content/Howto/Howto.tsx @@ -191,6 +191,7 @@ export class Howto extends React.Component< <> window.innerWidth ? 'column' : 'row' interface IProps { howto: IHowtoDB & { taglist: any } @@ -44,59 +52,48 @@ interface IProps { onUsefulClick: () => void } -interface IInjected extends IProps { - howtoStore: HowtoStore -} - -interface IState { - fileDownloadCount: number | undefined -} +const HowtoDescription = ({ howto, loggedInUser, ...props }: IProps) => { + const [fileDownloadCount, setFileDownloadCount] = useState( + howto.total_downloads, + ) + const [viewCount, setViewCount] = useState(howto.total_views) + const { stores } = useCommonStores() -@inject('howtoStore') -@observer -export default class HowtoDescription extends PureComponent { - private setFileDownloadCount = (val: number) => { - this.setState({ - fileDownloadCount: val, - }) + const incrementDownloadCount = async () => { + const updatedDownloadCount = await stores.howtoStore.incrementDownloadCount( + howto._id, + ) + setFileDownloadCount(updatedDownloadCount!) } - private incrementDownloadCount = async () => { - const updatedDownloadCount = - await this.injected.howtoStore.incrementDownloadCount( - this.props.howto._id, + + const incrementViewCount = async () => { + const sessionStorageArray = retrieveSessionStorageArray('howto') + + if (!sessionStorageArray.includes(howto._id)) { + const updatedViewCount = await stores.howtoStore.incrementViewCount( + howto._id, ) - this.setFileDownloadCount(updatedDownloadCount!) + addIDToSessionStorageArray('howto', howto._id) + setViewCount(updatedViewCount) + } } - private handleClick = async () => { - const howtoDownloadCooldown = retrieveHowtoDownloadCooldown( - this.props.howto._id, - ) + + const handleClick = async () => { + const howtoDownloadCooldown = retrieveHowtoDownloadCooldown(howto._id) if ( howtoDownloadCooldown && isHowtoDownloadCooldownExpired(howtoDownloadCooldown) ) { - updateHowtoDownloadCooldown(this.props.howto._id) - this.incrementDownloadCount() + updateHowtoDownloadCooldown(howto._id) + incrementDownloadCount() } else if (!howtoDownloadCooldown) { - addHowtoDownloadCooldown(this.props.howto._id) - this.incrementDownloadCount() - } - } - // eslint-disable-next-line - constructor(props: IProps) { - super(props) - this.state = { - fileDownloadCount: this.props.howto.total_downloads || 0, + addHowtoDownloadCooldown(howto._id) + incrementDownloadCount() } - this.handleClick = this.handleClick.bind(this) - } - - get injected() { - return this.props as IInjected } - private dateLastEditText(howto: IHowtoDB): string { + const dateLastEditText = (howto: IHowtoDB): string => { const lastModifiedDate = format(new Date(howto._modified), 'DD-MM-YYYY') const creationDate = format(new Date(howto._created), 'DD-MM-YYYY') if (lastModifiedDate !== creationDate) { @@ -106,239 +103,234 @@ export default class HowtoDescription extends PureComponent { } } - public render() { - const { howto, loggedInUser } = this.props + useEffect(() => { + incrementViewCount() + }, [howto._id]) - const iconFlexDirection = - emStringToPx(theme.breakpoints[0]) > window.innerWidth ? 'column' : 'row' - return ( + return ( + - - - - - - {this.props.votedUsefulCount !== undefined && ( - - + + - + Back - )} - {/* Check if logged in user is the creator of the how-to OR a super-admin */} - {loggedInUser && isAllowToEditContent(howto, loggedInUser) && ( - - - - )} - - - - - {this.dateLastEditText(howto)} - - - {/* HACK 2021-07-16 - new howtos auto capitalize title but not older */} - {capitalizeFirstLetter(howto.title)} - - - {howto.description} - - - - - - + + {props.votedUsefulCount !== undefined && ( + + - {howto.steps.length} steps - - - + )} + + + + + + {/* Check if pin should be moderated */} + {props.needsModeration && ( + + + + )} - - + - {howto.moderation !== 'accepted' && ( - - )} + + {dateLastEditText(howto)} + + + {/* HACK 2021-07-16 - new howtos auto capitalize title but not older */} + {capitalizeFirstLetter(howto.title)} + + + {howto.description} + + + + + + {howto.steps.length} steps + + + + {howto.time} + + + + {howto.difficulty_level} + + + + {howto.taglist && + howto.taglist.map((tag, idx) => ( + + ))} + + {((howto.files && howto.files.length > 0) || howto.fileLink) && ( + + {howto.fileLink && ( + + )} + {howto.files + .filter(Boolean) + .map( + (file, index) => + file && ( + + ), + )} + {typeof fileDownloadCount === 'number' && ( + + {fileDownloadCount} + {fileDownloadCount !== 1 ? ' downloads' : ' download'} + + )} + + )} - ) - } + + + {howto.moderation !== 'accepted' && ( + + )} + + + ) } + +export default HowtoDescription diff --git a/src/pages/Research/Content/ResearchArticle.tsx b/src/pages/Research/Content/ResearchArticle.tsx index 05a7f8cc51..2aa2290861 100644 --- a/src/pages/Research/Content/ResearchArticle.tsx +++ b/src/pages/Research/Content/ResearchArticle.tsx @@ -119,6 +119,7 @@ const ResearchArticle = observer((props: IProps) => { void } -const ResearchDescription: React.FC = ({ - research, - isEditable, - ...props -}) => { +const ResearchDescription = ({ research, isEditable, ...props }: IProps) => { const dateLastUpdateText = (research: IResearch.ItemDB): string => { const lastModifiedDate = format(new Date(research._modified), 'DD-MM-YYYY') const creationDate = format(new Date(research._created), 'DD-MM-YYYY') @@ -40,6 +44,23 @@ const ResearchDescription: React.FC = ({ return '' } } + const store = useResearchStore() + + const [viewCount, setViewCount] = useState(research.total_views) + + const incrementViewCount = async () => { + const sessionStorageArray = retrieveSessionStorageArray('research') + + if (!sessionStorageArray.includes(research._id)) { + const updatedViewCount = await store.incrementViewCount(research._id) + setViewCount(updatedViewCount) + addIDToSessionStorageArray('research', research._id) + } + } + + useEffect(() => { + incrementViewCount() + }, [research._id]) return ( = ({ }} > - + {props.votedUsefulCount !== undefined && ( - + = ({ /> )} + + + + + {/* Check if research should be moderated */} {props.needsModeration && ( diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 3f4088cb7b..3dda98a461 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -185,11 +185,39 @@ export class HowtoStore extends ModuleStore { total_downloads: totalDownloads! + 1, } - await this.updateHowtoItem(updatedHowto) + dbRef.set( + { + ...updatedHowto, + }, + { keep_modified_timestamp: true }, + ) + return updatedHowto.total_downloads } } + public async incrementViewCount(howToID: string) { + const dbRef = this.db.collection(COLLECTION_NAME).doc(howToID) + const howToData = await toJS(dbRef.get()) + const totalViews = howToData?.total_views || 0 + + if (howToData) { + const updatedHowto: IHowto = { + ...howToData, + total_views: totalViews! + 1, + } + + dbRef.set( + { + ...updatedHowto, + }, + { keep_modified_timestamp: true }, + ) + + return updatedHowto.total_views + } + } + public updateSearchValue(query: string) { this.searchValue = query } diff --git a/src/stores/Research/research.store.tsx b/src/stores/Research/research.store.tsx index 6629cdd39c..2bcf46ec24 100644 --- a/src/stores/Research/research.store.tsx +++ b/src/stores/Research/research.store.tsx @@ -146,6 +146,28 @@ export class ResearchStore extends ModuleStore { } } + public async incrementViewCount(id: string) { + const dbRef = this.db.collection(COLLECTION_NAME).doc(id) + const researchData = await toJS(dbRef.get()) + const totalViews = researchData?.total_views || 0 + + if (researchData) { + const updatedResearch: IResearchDB = { + ...researchData, + total_views: totalViews! + 1, + } + + dbRef.set( + { + ...updatedResearch, + }, + { keep_modified_timestamp: true }, + ) + + return updatedResearch.total_views + } + } + public deleteResearchItem(id: string) { this.db.collection('research').doc(id).delete() } diff --git a/src/stores/databaseV2/DocReference.tsx b/src/stores/databaseV2/DocReference.tsx index 6198f444a6..4773372968 100644 --- a/src/stores/databaseV2/DocReference.tsx +++ b/src/stores/databaseV2/DocReference.tsx @@ -59,9 +59,9 @@ export class DocReference { * If contains metadata fields (e.g. `_id`) * then this will be used instead of generated id */ - async set(data: T) { + async set(data: T, options?: { keep_modified_timestamp: boolean }) { const { serverDB, cacheDB } = this.clients - const dbDoc: DBDoc = this._setDocMeta(data) + const dbDoc: DBDoc = this._setDocMeta(data, options) await serverDB.setDoc(this.endpoint, dbDoc) await cacheDB.setDoc(this.endpoint, dbDoc) } @@ -90,14 +90,19 @@ export class DocReference { return this._setDocMeta(data) } - private _setDocMeta(data: any = {}): DBDoc { + private _setDocMeta(data: any = {}, options: any = {}): DBDoc { const d = data + const o = options + const modifiedTimestamp = o.keep_modified_timestamp + ? d._modified + : new Date().toISOString() + return { ...d, _created: d._created ? d._created : new Date().toISOString(), _deleted: d._deleted ? d._deleted : false, _id: this.id, - _modified: new Date().toISOString(), + _modified: modifiedTimestamp, } } diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts new file mode 100644 index 0000000000..ee22b050a7 --- /dev/null +++ b/src/utils/sessionStorage.ts @@ -0,0 +1,14 @@ +export const retrieveSessionStorageArray = (key: string) => { + const viewsArray: string | null = sessionStorage.getItem(key) + if (typeof viewsArray === 'string') { + return JSON.parse(viewsArray) + } else { + return [] + } +} + +export const addIDToSessionStorageArray = (key: string, value: string) => { + const sessionStorageArray = retrieveSessionStorageArray(key) + sessionStorageArray.push(value) + sessionStorage.setItem(key, JSON.stringify(sessionStorageArray)) +}