From 52ce273761ee3d24413598dce17b60013822d9f3 Mon Sep 17 00:00:00 2001
From: esikkala <esko.ikkala@aalto.fi>
Date: Tue, 21 Dec 2021 14:04:28 +0200
Subject: [PATCH] Adapt to WarMemoirSampo configs

---
 .../facet_results/ResultClassRoute.js         |  38 ++
 .../components/main_layout/InstancePage.js    |   1 +
 src/client/components/main_layout/MuiIcon.js  |   6 +-
 .../components/main_layout/VideoPage.js       | 150 +++++++
 .../main_layout/VideoTableOfContents.js       | 400 ++++++++++++++++++
 .../components/main_layout/WordCloud.js       |  67 +++
 src/client/containers/SemanticPortal.js       |   7 +
 src/client/reducers/general/videoPlayer.js    |  14 +
 src/client/reducers/index.js                  |   2 +
 src/server/sparql/Mappers.js                  |  23 +
 src/server/sparql/Utils.js                    |  28 +-
 11 files changed, 729 insertions(+), 7 deletions(-)
 create mode 100644 src/client/components/main_layout/VideoPage.js
 create mode 100644 src/client/components/main_layout/VideoTableOfContents.js
 create mode 100644 src/client/components/main_layout/WordCloud.js
 create mode 100644 src/client/reducers/general/videoPlayer.js

diff --git a/src/client/components/facet_results/ResultClassRoute.js b/src/client/components/facet_results/ResultClassRoute.js
index 391b2110..31b2f013 100644
--- a/src/client/components/facet_results/ResultClassRoute.js
+++ b/src/client/components/facet_results/ResultClassRoute.js
@@ -9,6 +9,8 @@ const LeafletMap = lazy(() => import('./LeafletMap'))
 const Deck = lazy(() => import('./Deck'))
 const ApexCharts = lazy(() => import('./ApexCharts'))
 const Network = lazy(() => import('./Network'))
+const VideoPage = lazy(() => import('../main_layout/VideoPage'))
+const WordCloud = lazy(() => import('../main_layout/WordCloud'))
 // const BarChartRace = lazy(() => import('../../facet_results/BarChartRace'))
 const ExportCSV = lazy(() => import('./ExportCSV'))
 const Export = lazy(() => import('./Export'))
@@ -350,6 +352,42 @@ const ResultClassRoute = props => {
       )
       break
     }
+    case 'VideoPage': {
+      const videoPageProps = {
+        portalConfig,
+        perspectiveConfig: perspective,
+        layoutConfig,
+        screenSize,
+        resultClass,
+        perspectiveState,
+        properties: getVisibleRows(perspectiveState),
+        localID: props.localID,
+        routeProps: props.routeProps,
+        videoPlayerState: props.videoPlayerState,
+        updateVideoPlayerTime: props.updateVideoPlayerTime
+      }
+      routeComponent = (
+        <Route
+          path={path}
+          render={() =>
+            <VideoPage {...videoPageProps} />}
+        />
+      )
+      break
+    }
+    case 'WordCloud': {
+      const wordCloudProps = {
+        data: perspectiveState.instanceTableData[resultClassConfig.wordCloudProperty]
+      }
+      routeComponent = (
+        <Route
+          path={path}
+          render={() =>
+            <WordCloud {...wordCloudProps} />}
+        />
+      )
+      break
+    }
     case 'Export': {
       const { pageType = 'facetResults' } = resultClassConfig
       const exportResultClass = resultClassConfig.resultClass
diff --git a/src/client/components/main_layout/InstancePage.js b/src/client/components/main_layout/InstancePage.js
index 9ab80038..0dc9b2a0 100644
--- a/src/client/components/main_layout/InstancePage.js
+++ b/src/client/components/main_layout/InstancePage.js
@@ -159,6 +159,7 @@ class InstancePage extends React.Component {
                     defaultResultClass={resultClass}
                     resultClass={instancePageResultClass}
                     resultClassConfig={resultClassConfig}
+                    localID={this.state.localID}
                     {...this.props}
                   />
                 )
diff --git a/src/client/components/main_layout/MuiIcon.js b/src/client/components/main_layout/MuiIcon.js
index a24e4dc5..53c3bb86 100644
--- a/src/client/components/main_layout/MuiIcon.js
+++ b/src/client/components/main_layout/MuiIcon.js
@@ -11,7 +11,8 @@ import {
   BubbleChart,
   ShowChart,
   FormatAlignJustify,
-  ClearAll
+  ClearAll,
+  OndemandVideo
 } from '@material-ui/icons'
 import has from 'lodash'
 
@@ -28,7 +29,8 @@ const MuiIcon = props => {
     BubbleChart: BubbleChart,
     ShowChart: ShowChart,
     FormatAlignJustify: FormatAlignJustify,
-    ClearAll: ClearAll
+    ClearAll: ClearAll,
+    OndemandVideo: OndemandVideo
   }
   if (has(MuiIcons, props.iconName)) {
     const MuiIconComponent = MuiIcons[props.iconName]
diff --git a/src/client/components/main_layout/VideoPage.js b/src/client/components/main_layout/VideoPage.js
new file mode 100644
index 00000000..1c357cb2
--- /dev/null
+++ b/src/client/components/main_layout/VideoPage.js
@@ -0,0 +1,150 @@
+import React from 'react'
+import { makeStyles } from '@material-ui/core/styles'
+import Paper from '@material-ui/core/Paper'
+import Grid from '@material-ui/core/Grid'
+import { Typography } from '@material-ui/core'
+import InstancePageTable from './InstancePageTable'
+import Player from './Player'
+import VideoTableOfContents from './VideoTableOfContents'
+import { has } from 'lodash'
+
+const useStyles = makeStyles(theme => ({
+  root: {
+    width: '100%',
+    height: '100%',
+    display: 'flex',
+    justifyContent: 'center',
+    fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
+    backgroundColor: '#bdbdbd'
+  },
+  mainContainer: props => ({
+    margin: 0,
+    maxWidth: 1100,
+    // minHeight: 1100,
+    marginTop: theme.spacing(1),
+    // flexWrap: 'wrap-reverse',
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: `calc(100% - ${theme.spacing(2.5)}px)`
+    }
+  }),
+  gridItem: props => ({
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: '100%'
+    },
+    paddingTop: '0px !important',
+    paddingBottom: '0px !important'
+  }),
+  tableOfContents: props => ({
+    padding: theme.spacing(2),
+    overflow: 'auto',
+    top: theme.spacing(0.5),
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: 'calc(100% - 32px)'
+    }
+  }),
+  videoPlayerContainer: props => ({
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: '60%'
+    },
+    [theme.breakpoints.down('sm')]: {
+      maxWidth: 500
+    },
+    overflow: 'auto',
+    marginBottom: theme.spacing(1),
+    display: 'flex',
+    alignItems: 'center'
+  }),
+  tableContainer: props => ({
+    marginBottom: theme.spacing(1),
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: `calc(40% - ${theme.spacing(1)}px)`,
+      overflow: 'auto'
+    }
+  }),
+  wordCloud: props => ({
+    marginTop: theme.spacing(1),
+    padding: theme.spacing(2),
+    overflow: 'auto',
+    height: 200,
+    display: 'none',
+    [theme.breakpoints.up(props.layoutConfig.hundredPercentHeightBreakPoint)]: {
+      height: '40%',
+      display: 'block'
+    }
+  }),
+  wordCloudContainer: {
+    width: '100%'
+  },
+  tooltip: {
+    maxWidth: 500
+  },
+  tooltipContent: {
+    padding: theme.spacing(1)
+  },
+  tooltipList: {
+    listStylePosition: 'inside',
+    paddingLeft: 0
+  }
+}))
+
+const VideoPage = props => {
+  const classes = useStyles(props)
+  const { instanceTableData } = props.perspectiveState
+  const { portalConfig, perspectiveConfig, localID, resultClass, screenSize, layoutConfig } = props
+  let { properties } = props
+
+  const readyToRenderVideoPlayer = () => {
+    return `http://ldf.fi/warmemoirsampo/${localID}` === instanceTableData.id &&
+        has(instanceTableData, 'youTubeID')
+  }
+
+  if (!has(instanceTableData, 'warsaPage')) {
+    properties = properties.filter(prop => prop.id !== 'warsaPage')
+  }
+
+  return (
+    <div className={classes.root}>
+      <Grid className={classes.mainContainer} container spacing={1}>
+        <Grid className={classes.gridItem} item xs={12} sm={12} md={7}>
+          <Paper className={classes.videoPlayerContainer}>
+            {readyToRenderVideoPlayer() &&
+              <Player
+                resultClass={props.resultClass}
+                data={instanceTableData}
+                routeProps={props.routeProps}
+                videoPlayerState={props.videoPlayerState}
+                updateVideoPlayerTime={props.updateVideoPlayerTime}
+              />}
+          </Paper>
+          <Paper className={classes.tableContainer}>
+            <InstancePageTable
+              portalConfig={portalConfig}
+              perspectiveConfig={perspectiveConfig}
+              resultClass={resultClass}
+              data={instanceTableData}
+              properties={properties}
+              screenSize={screenSize}
+              layoutConfig={layoutConfig}
+            />
+          </Paper>
+        </Grid>
+        <Grid className={classes.gridItem} item xs={12} sm={12} md={5}>
+          <Paper className={classes.tableOfContents}>
+            <Typography variant='h6' component='h2'>Sisällysluettelo</Typography>
+            {has(instanceTableData, 'timeSlice') &&
+              <VideoTableOfContents
+                instanceTableData={instanceTableData}
+                toc={instanceTableData.timeSlice}
+                textFormat='plain-text-from-text-slice'
+                // textFormat='annotated-html-from-text-slice'
+                // textFormat='annotated-html-from-time-slice'
+                videoPlayerState={props.videoPlayerState}
+              />}
+          </Paper>
+        </Grid>
+      </Grid>
+    </div>
+  )
+}
+
+export default VideoPage
diff --git a/src/client/components/main_layout/VideoTableOfContents.js b/src/client/components/main_layout/VideoTableOfContents.js
new file mode 100644
index 00000000..6e7d2dc7
--- /dev/null
+++ b/src/client/components/main_layout/VideoTableOfContents.js
@@ -0,0 +1,400 @@
+import React from 'react'
+import { withStyles } from '@material-ui/core/styles'
+import Accordion from '@material-ui/core/Accordion'
+import AccordionDetails from '@material-ui/core/AccordionDetails'
+import AccordionSummary from '@material-ui/core/AccordionSummary'
+import Typography from '@material-ui/core/Typography'
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
+import Divider from '@material-ui/core/Divider'
+import Tooltip from '@material-ui/core/Tooltip'
+import { Link } from 'react-router-dom'
+import { has } from 'lodash'
+import parse from 'html-react-parser'
+import { arrayToObject } from '../../helpers/helpers'
+
+const styles = theme => ({
+  root: {
+    width: '100%',
+    marginTop: theme.spacing(1)
+  },
+  heading: {
+    fontSize: theme.typography.pxToRem(15),
+    flexBasis: '33.33%',
+    flexShrink: 0
+  },
+  secondaryHeadingContainer: {
+    display: 'flex',
+    alignItems: 'center'
+  },
+  secondaryHeading: {
+    fontSize: theme.typography.pxToRem(12),
+    color: theme.palette.text.secondary
+  },
+  timeLink: {
+    marginRight: theme.spacing(1)
+  },
+  activeAccordion: {
+    border: '2px solid red'
+  },
+  accordionDetailsRoot: {
+    flexDirection: 'column'
+  },
+  tocSubHeading: {
+    marginTop: theme.spacing(1)
+  },
+  tooltip: {
+    maxWidth: 500
+  },
+  tooltipContent: {
+    padding: theme.spacing(1)
+  }
+})
+
+class VideoTableOfContents extends React.Component {
+  constructor (props) {
+    super(props)
+    const {
+      mentionedPlace,
+      mentionedPerson,
+      mentionedOrganization,
+      mentionedUnit,
+      mentionedEvent,
+      mentionedProduct
+    } = props.instanceTableData
+    let namedEntities = [
+      ...(Array.isArray(mentionedPlace)
+        ? mentionedPlace
+        : [mentionedPlace]),
+      ...(Array.isArray(mentionedPerson)
+        ? mentionedPerson
+        : [mentionedPerson]),
+      ...(Array.isArray(mentionedOrganization)
+        ? mentionedOrganization
+        : [mentionedOrganization]),
+      ...(Array.isArray(mentionedUnit)
+        ? mentionedUnit
+        : [mentionedUnit]),
+      ...(Array.isArray(mentionedEvent)
+        ? mentionedEvent
+        : [mentionedEvent]),
+      ...(Array.isArray(mentionedProduct)
+        ? mentionedProduct
+        : [mentionedProduct])
+    ]
+    if (namedEntities !== null) {
+      namedEntities = arrayToObject({
+        array: namedEntities,
+        keyField: 'id'
+      })
+    }
+    this.state = {
+      expandedSet: new Set([]),
+      currentPart: null,
+      namedEntities
+    }
+  }
+
+  componentDidUpdate = (prevProps, prevState) => {
+    const currentPart = this.getCurrentPart()
+    if (this.props.videoPlayerState.videoPlayerTime !== prevProps.videoPlayerState.videoPlayerTime) {
+      this.setState({ currentPart })
+    }
+  }
+
+  getCurrentPart = () => {
+    const { videoPlayerTime } = this.props.videoPlayerState
+    let currentPart = null
+    let toc_ = this.props.toc
+    if (!Array.isArray(toc_)) {
+      toc_ = [this.props.toc]
+    }
+    for (const part of toc_) {
+      if (part.beginTimeInSeconds <= videoPlayerTime && part.endTimeInSeconds > videoPlayerTime) {
+        currentPart = part
+        break // there are errors in timecodes, choose only the first part that fits the condition
+      }
+    }
+    return currentPart
+  }
+
+  renderTooltip = (domNode, namedEntityID) => {
+    let tooltipContent = namedEntityID
+    if (has(this.state.namedEntities, namedEntityID)) {
+      const entity = this.state.namedEntities[namedEntityID]
+      const tooltipHeading = has(entity, 'wikipediaLink')
+        ? (
+          <p>
+            <a href={entity.wikipediaLink} target='_blank' rel='noopener noreferrer'>
+              {entity.prefLabel} (Wikipedia)
+            </a>
+          </p>
+          )
+        : (<p>{entity.prefLabel} (Wikipedia)</p>)
+      tooltipContent = (
+        <div className={this.props.classes.tooltipContent}>
+          {tooltipHeading}
+          <p>
+            {entity.description}
+          </p>
+        </div>
+      )
+    }
+    return (
+      <Tooltip
+        title={tooltipContent}
+        interactive
+        placement='top'
+        arrow
+        classes={{
+          tooltip: this.props.classes.tooltip
+        }}
+      >
+        <span
+          style={{
+            textDecoration: 'underline',
+            cursor: 'pointer'
+          }}
+        >
+          {domNode.children[0].data}
+        </span>
+      </Tooltip>
+    )
+  }
+
+  renderLink = (domNode, namedEntityID) => {
+    if (has(this.state.namedEntities, namedEntityID)) {
+      const entity = this.state.namedEntities[namedEntityID]
+      return (
+        <Link to={entity.dataProviderUrl}>
+          {domNode.children[0].data}
+        </Link>
+      )
+    } else {
+      return (
+        <>
+          {domNode.children[0].data}
+        </>
+      )
+    }
+  }
+
+  parseHTMLTextSlice = slice => {
+    const html = parse(slice.annotatedTextContent, {
+      replace: domNode => {
+        if (domNode.type === 'tag' && domNode.name === 'span' &&
+        has(domNode.attribs, 'data-link')) {
+          const namedEntityID = domNode.attribs['data-link']
+          return this.renderTooltip(domNode, namedEntityID)
+        }
+      }
+    })
+    return (
+      <li key={slice.order}>{html}</li>
+    )
+  }
+
+  parseHTMLTimeSlice = annotatedTextContent => {
+    const html = parse(annotatedTextContent, {
+      replace: domNode => {
+        if (domNode.type === 'tag' && domNode.name === 'span' &&
+        has(domNode.attribs, 'data-uri')) {
+          const namedEntityID = domNode.attribs['data-uri']
+          return this.renderLink(domNode, namedEntityID)
+        }
+      }
+    })
+    return (
+      <p>
+        {html}
+      </p>
+    )
+  }
+
+  handleAccordionOnChange = rowID => () => {
+    const { expandedSet } = this.state
+    if (expandedSet.has(rowID)) {
+      expandedSet.delete(rowID)
+    } else {
+      expandedSet.add(rowID)
+    }
+    this.setState({ expandedSet })
+  }
+
+  render () {
+    const { classes, toc, textFormat } = this.props
+    const { expandedSet } = this.state
+    let toc_ = toc
+    if (!Array.isArray(toc)) {
+      toc_ = [toc]
+    }
+    return (
+      <div className={classes.root}>
+        {toc_.map(row => {
+          const rowID = row.order
+          let isCurrent = false
+          if (this.state.currentPart && rowID === this.state.currentPart.order) {
+            isCurrent = true
+          }
+          const expanded = expandedSet.has(rowID) || isCurrent
+          const hasPlaceLinks = has(row, 'mentionedPlace')
+          const hasPersonLinks = has(row, 'mentionedPerson')
+          const hasUnitLinks = has(row, 'mentionedUnit')
+          const hasOrganizationLinks = has(row, 'mentionedOrganization')
+          const hasEventLinks = has(row, 'mentionedEvent')
+          const hasProductLinks = has(row, 'mentionedProduct')
+          const hasNamedEntityLinks =
+            hasPlaceLinks ||
+            hasPersonLinks ||
+            hasUnitLinks ||
+            hasOrganizationLinks ||
+            hasEventLinks ||
+            hasProductLinks
+          const hasTextSlices = has(row, 'textSlice')
+          if (hasPlaceLinks) {
+            if (Array.isArray(row.mentionedPlace)) {
+              row.mentionedPlace.forEach(place => {
+                if (Array.isArray(place.prefLabel)) {
+                  place.prefLabel = place.prefLabel[0]
+                }
+              })
+              row.mentionedPlace.sort((a, b) => a.prefLabel.localeCompare(b.prefLabel))
+            }
+          }
+          const timeSliceHasAnnotatedTextContent = has(row, 'annotatedTextContent')
+          return (
+            <Accordion
+              className={isCurrent ? classes.activeAccordion : null}
+              key={rowID}
+              expanded={expanded}
+              onChange={this.handleAccordionOnChange(rowID)}
+            >
+              <AccordionSummary
+                style={{
+                  root: {
+                    '&$expanded': { minHeight: 15 }
+                  },
+                  content: {
+                    '&$expanded': { marginBottom: 0 }
+                  }
+                }}
+                expandIcon={<ExpandMoreIcon />}
+                IconButtonProps={{
+                  disabled: isCurrent
+                }}
+                aria-label='Expand'
+                aria-controls={`${rowID}-content`}
+                id={`${rowID}-header`}
+              >
+                <Link
+                  className={classes.timeLink}
+                  to={{ hash: row.beginTimeInSeconds }}
+                  replace
+                  onClick={event => {
+                    if (expanded) {
+                      event.stopPropagation()
+                    }
+                  }}
+                  onFocus={event => event.stopPropagation()}
+                >
+                  <Typography className={classes.heading}>
+                    {row.beginTimeLabel}
+                  </Typography>
+                </Link>
+                {!expanded &&
+                  <div className={classes.secondaryHeadingContainer}>
+                    <Typography className={classes.secondaryHeading}>{row.prefLabel}</Typography>
+                  </div>}
+
+              </AccordionSummary>
+              <AccordionDetails
+                classes={{
+                  root: classes.accordionDetailsRoot
+                }}
+              >
+                <Typography>Haastattelijan muistiinpanot</Typography>
+                {textFormat === 'plain-text-from-text-slice' && hasTextSlices &&
+                  <ul>
+                    {Array.isArray(row.textSlice)
+                      ? row.textSlice.map(slice => <li key={slice.order}>{slice.textContent}</li>)
+                      : <li key={row.textSlice.order}>{row.textSlice.textContent}</li>}
+                  </ul>}
+                {textFormat === 'annotated-html-from-text-slice' && hasTextSlices &&
+                  <ul>
+                    {Array.isArray(row.textSlice)
+                      ? row.textSlice.map(slice => this.parseHTMLTextSlice(slice))
+                      : this.parseHTMLTextSlice(row.textSlice)}
+                  </ul>}
+                {textFormat === 'annotated-html-from-time-slice' && timeSliceHasAnnotatedTextContent &&
+                  this.parseHTMLTimeSlice(row.annotatedTextContent)}
+                {hasNamedEntityLinks &&
+                  <>
+                    <Divider />
+                    <Typography className={classes.tocSubHeading}>Automaattisesti tunnistetut</Typography>
+                    <ul>
+                      {hasPlaceLinks &&
+                        <li>paikat
+                          <ul>
+                            {Array.isArray(row.mentionedPlace)
+                              ? row.mentionedPlace.map(place =>
+                                <li key={place.id}><Link to={place.dataProviderUrl}>{place.prefLabel}</Link></li>)
+                              : <li key={row.mentionedPlace.id}><Link to={row.mentionedPlace.dataProviderUrl}>{row.mentionedPlace.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                      {hasPersonLinks &&
+                        <li>henkilöt
+                          <ul>
+                            {Array.isArray(row.mentionedPerson)
+                              ? row.mentionedPerson.map(person =>
+                                <li key={person.id}><Link to={person.dataProviderUrl}>{person.prefLabel}</Link></li>)
+                              : <li key={row.mentionedPerson.id}><Link to={row.mentionedPerson.dataProviderUrl}>{row.mentionedPerson.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                      {hasUnitLinks &&
+                        <li>joukko-osastot
+                          <ul>
+                            {Array.isArray(row.mentionedUnit)
+                              ? row.mentionedUnit.map(unit =>
+                                <li key={unit.id}><Link to={unit.dataProviderUrl}>{unit.prefLabel}</Link></li>)
+                              : <li key={row.mentionedUnit.id}><Link to={row.mentionedUnit.dataProviderUrl}>{row.mentionedUnit.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                      {hasOrganizationLinks &&
+                        <li>organisaatiot
+                          <ul>
+                            {Array.isArray(row.mentionedOrganization)
+                              ? row.mentionedOrganization.map(organization =>
+                                <li key={organization.id}><Link to={organization.dataProviderUrl}>{organization.prefLabel}</Link></li>)
+                              : <li key={row.mentionedOrganization.id}><Link to={row.mentionedOrganization.dataProviderUrl}>{row.mentionedOrganization.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                      {hasEventLinks &&
+                        <li>tapahtumat
+                          <ul>
+                            {Array.isArray(row.mentionedEvent)
+                              ? row.mentionedEvent.map(event =>
+                                <li key={event.id}><Link to={event.dataProviderUrl}>{event.prefLabel}</Link></li>)
+                              : <li key={row.mentionedEvent.id}><Link to={row.mentionedEvent.dataProviderUrl}>{row.mentionedEvent.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                      {hasProductLinks &&
+                        <li>nimikkeet
+                          <ul>
+                            {Array.isArray(row.mentionedProduct)
+                              ? row.mentionedProduct.map(product =>
+                                <li key={product.id}><Link to={product.dataProviderUrl}>{product.prefLabel}</Link></li>)
+                              : <li key={row.mentionedProduct.id}><Link to={row.mentionedProduct.dataProviderUrl}>{row.mentionedProduct.prefLabel}</Link></li>}
+                          </ul>
+                        </li>}
+                    </ul>
+                  </>}
+              </AccordionDetails>
+            </Accordion>
+          )
+        }
+        )}
+      </div>
+    )
+  }
+}
+
+export default withStyles(styles)(VideoTableOfContents)
diff --git a/src/client/components/main_layout/WordCloud.js b/src/client/components/main_layout/WordCloud.js
new file mode 100644
index 00000000..284335c8
--- /dev/null
+++ b/src/client/components/main_layout/WordCloud.js
@@ -0,0 +1,67 @@
+import React from 'react'
+import ReactWordcloud from 'react-wordcloud'
+import { makeStyles } from '@material-ui/core/styles'
+import Paper from '@material-ui/core/Paper'
+import 'tippy.js/dist/tippy.css'
+import 'tippy.js/animations/scale.css'
+
+const options = {
+  rotations: 0,
+  fontSizes: [14, 60],
+  deterministic: true
+}
+
+const useStyles = makeStyles(theme => ({
+  wordCloudOuterContainer: props => ({
+    height: '100%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    backgroundColor: '#bdbdbd'
+  }),
+  wordCloudInnerContainer: props => ({
+    width: '100%',
+    height: '100%',
+    [theme.breakpoints.down('md')]: {
+      minHeight: 400,
+      overflow: 'auto'
+    },
+    [theme.breakpoints.up('lg')]: {
+      width: '50%',
+      height: '50%'
+    }
+  })
+}))
+
+const Wordcloud = props => {
+  const { data, maxWords } = props
+  const classes = useStyles(props)
+
+  if (data == null) {
+    return (<></>)
+  }
+
+  data.forEach(el => {
+    el.weight = +el.weight
+  })
+
+  // sort without mutating the original array
+  let words = [...data].sort((a, b) => b.weight - a.weight)
+  if (words.length > maxWords) {
+    words.splice(maxWords)
+  }
+  words = words.map(item => ({ text: item.prefLabel, value: item.weight }))
+
+  return (
+    <div className={classes.wordCloudOuterContainer}>
+      <Paper className={classes.wordCloudInnerContainer}>
+        <ReactWordcloud
+          options={options}
+          words={words}
+        />
+      </Paper>
+    </div>
+  )
+}
+
+export default Wordcloud
diff --git a/src/client/containers/SemanticPortal.js b/src/client/containers/SemanticPortal.js
index 92a37798..2f1095ba 100644
--- a/src/client/containers/SemanticPortal.js
+++ b/src/client/containers/SemanticPortal.js
@@ -38,6 +38,7 @@ import {
   updatePerspectiveHeaderExpanded,
   loadLocales,
   animateMap,
+  updateVideoPlayerTime,
   clientFSToggleDataset,
   clientFSFetchResults,
   clientFSSortResults,
@@ -472,6 +473,8 @@ const SemanticPortal = props => {
                                   perspective={perspective}
                                   animationValue={props.animationValue}
                                   animateMap={props.animateMap}
+                                  videoPlayerState={props.videoPlayer}
+                                  updateVideoPlayerTime={props.updateVideoPlayerTime}
                                   screenSize={screenSize}
                                   rootUrl={rootUrlWithLang}
                                   apexChartsConfig={apexChartsConfig}
@@ -542,6 +545,8 @@ const SemanticPortal = props => {
                             perspective={perspective}
                             animationValue={props.animationValue}
                             animateMap={props.animateMap}
+                            videoPlayerState={props.videoPlayer}
+                            updateVideoPlayerTime={props.updateVideoPlayerTime}
                             screenSize={screenSize}
                             rootUrl={rootUrlWithLang}
                             apexChartsConfig={apexChartsConfig}
@@ -677,6 +682,7 @@ const mapStateToProps = state => {
   stateToProps.leafletMap = state.leafletMap
   stateToProps.fullTextSearch = state.fullTextSearch
   stateToProps.animationValue = state.animation.value
+  stateToProps.videoPlayer = state.videoPlayer
   stateToProps.options = state.options
   stateToProps.error = state.error
   return stateToProps
@@ -707,6 +713,7 @@ const mapDispatchToProps = ({
   updatePerspectiveHeaderExpanded,
   loadLocales,
   animateMap,
+  updateVideoPlayerTime,
   clientFSToggleDataset,
   clientFSFetchResults,
   clientFSClearResults,
diff --git a/src/client/reducers/general/videoPlayer.js b/src/client/reducers/general/videoPlayer.js
new file mode 100644
index 00000000..9233d9e7
--- /dev/null
+++ b/src/client/reducers/general/videoPlayer.js
@@ -0,0 +1,14 @@
+import { UPDATE_VIDEO_PLAYER_TIME } from '../../actions'
+
+export const INITIAL_STATE = {
+  videoPlayerTime: null
+}
+
+const videoPlayer = (state = INITIAL_STATE, action) => {
+  if (action.type === UPDATE_VIDEO_PLAYER_TIME) {
+    state = { ...state, videoPlayerTime: action.value }
+  }
+  return state
+}
+
+export default videoPlayer
diff --git a/src/client/reducers/index.js b/src/client/reducers/index.js
index 061b837b..0cf0a4de 100644
--- a/src/client/reducers/index.js
+++ b/src/client/reducers/index.js
@@ -10,6 +10,7 @@ import { createFullTextSearchReducer } from './general/fullTextSearch'
 import error from './general/error'
 import options from './general/options'
 import animation from './general/animation'
+import videoPlayer from './general/videoPlayer'
 import leafletMap from './general/leafletMap'
 import {
   resultsInitialState,
@@ -21,6 +22,7 @@ import {
 const reducers = {
   leafletMap,
   animation,
+  videoPlayer,
   options,
   error,
   toastr: toastrReducer
diff --git a/src/server/sparql/Mappers.js b/src/server/sparql/Mappers.js
index a927f3eb..04dc9b6e 100644
--- a/src/server/sparql/Mappers.js
+++ b/src/server/sparql/Mappers.js
@@ -358,6 +358,29 @@ const getChoroplethMapColor = ({ value, clusters }) => {
   return heatmapColor
 }
 
+export const createPaddedTimeCodes = ({ data, config }) => {
+  data.forEach(item => {
+    let target = item[config.target]
+    if (!Array.isArray(target)) {
+      target = [target]
+    }
+    target.forEach(targetItem => {
+      const { hours, minutes, seconds } = targetItem
+      if (hours == null || minutes == null || seconds == null) {
+        // console.log(targetItem)
+      } else {
+        const paddedTimecode = createPaddedTimeCode({ hours, minutes, seconds })
+        targetItem[config.timeCodeProperty] = paddedTimecode
+      }
+    })
+  })
+  return data
+}
+
+const createPaddedTimeCode = ({ hours, minutes, seconds }) => {
+  return `${hours}:${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}`
+}
+
 // const NS_PER_SEC = 1e9
 // const MS_PER_NS = 1e-6
 
diff --git a/src/server/sparql/Utils.js b/src/server/sparql/Utils.js
index 0163b4c2..7d92bc72 100644
--- a/src/server/sparql/Utils.js
+++ b/src/server/sparql/Utils.js
@@ -1,12 +1,14 @@
 import { readFile } from 'fs/promises'
 import { has } from 'lodash'
-// import { backendSearchConfig as oldBackendSearchConfig } from './findsampo/BackendSearchConfig'
-// import { findsPerspectiveConfig } from './findsampo/perspective_configs/FindsPerspectiveConfig'
+
+// import { backendSearchConfig as oldBackendSearchConfig } from './veterans/BackendSearchConfig'
+
+// import { videosConfig } from './veterans/perspective_configs/VideosConfig'
 // import { typesPerspectiveConfig } from './perspective_configs/TypesPerspectiveConfig'
 // import { periodsPerspectiveConfig } from './perspective_configs/PeriodsPerspectiveConfig'
 // import { coinsPerspectiveConfig } from './perspective_configs/CoinsPerspectiveConfig'
 
-// import { INITIAL_STATE } from '../../client/reducers/findsampo/findsFacets'
+// import { INITIAL_STATE } from '../../client/reducers/veterans/videosFacets'
 // import { INITIAL_STATE } from '../../client/reducers/findsampo/finds'
 
 export const createBackendSearchConfig = async () => {
@@ -36,6 +38,9 @@ export const createBackendSearchConfig = async () => {
       const instancePagePropertiesQueryBlock = sparqlQueries[instancePagePropertiesQueryBlockID]
       paginatedResultsConfig.propertiesQueryBlock = paginatedResultsPropertiesQueryBlock
       instanceConfig.propertiesQueryBlock = instancePagePropertiesQueryBlock
+      if (instanceConfig.postprocess) {
+        instanceConfig.postprocess.func = resultMappers[instanceConfig.postprocess.func]
+      }
       if (has(instanceConfig, 'instancePageResultClasses')) {
         for (const instancePageResultClass in instanceConfig.instancePageResultClasses) {
           const instancePageResultClassConfig = instanceConfig.instancePageResultClasses[instancePageResultClass]
@@ -78,6 +83,10 @@ export const createBackendSearchConfig = async () => {
     const instancePagePropertiesQueryBlockID = instanceConfig.propertiesQueryBlock
     const instancePagePropertiesQueryBlock = sparqlQueries[instancePagePropertiesQueryBlockID]
     instanceConfig.propertiesQueryBlock = instancePagePropertiesQueryBlock
+    console.log(instanceConfig)
+    if (instanceConfig.postprocess) {
+      instanceConfig.postprocess.func = resultMappers[instanceConfig.postprocess.func]
+    }
     let hasInstancePageResultClasses = false
     if (has(instanceConfig, 'instancePageResultClasses')) {
       for (const instancePageResultClass in instanceConfig.instancePageResultClasses) {
@@ -196,6 +205,9 @@ export const mergeFacetConfigs = (clientFacets, serverFacets) => {
       if (serverFacet.facetValueFilter && serverFacet.facetValueFilter !== '') {
         mergedFacet.facetValueFilter = serverFacet.facetValueFilter
       }
+      if (serverFacet.facetLabelFilter && serverFacet.facetLabelFilter !== '') {
+        mergedFacet.facetLabelFilter = serverFacet.facetLabelFilter
+      }
       if (has(serverFacet, 'literal')) {
         mergedFacet.literal = serverFacet.literal
       }
@@ -209,6 +221,9 @@ export const mergeFacetConfigs = (clientFacets, serverFacets) => {
       if (serverFacet.facetValueFilter && serverFacet.facetValueFilter !== '') {
         mergedFacet.facetValueFilter = serverFacet.facetValueFilter
       }
+      if (serverFacet.facetLabelFilter && serverFacet.facetLabelFilter !== '') {
+        mergedFacet.facetLabelFilter = serverFacet.facetLabelFilter
+      }
       mergedFacet.facetType = 'hierarchical'
       mergedFacet.predicate = serverFacet.predicate
       mergedFacet.parentProperty = serverFacet.parentProperty
@@ -249,7 +264,7 @@ export const mergeFacetConfigs = (clientFacets, serverFacets) => {
     mergedFacets[facetID] = orderedFacet
   }
   // console.log(mergedFacets)
-  // console.log(JSON.stringify(mergedFacets))
+  console.log(JSON.stringify(mergedFacets))
 }
 
 export const createExtraResultClassesForJSONConfig = async oldBackendSearchConfig => {
@@ -312,8 +327,11 @@ export const createExtraResultClassesForJSONConfig = async oldBackendSearchConfi
 }
 
 // createExtraResultClassesForJSONConfig(oldBackendSearchConfig)
-// mergeFacetConfigs(INITIAL_STATE.facets, findsPerspectiveConfig.facets)
+
+// mergeFacetConfigs(INITIAL_STATE.facets, videosConfig.facets)
+
 // console.log(JSON.stringify(INITIAL_STATE.properties))
+
 // "tabID": 0,
 // "tabPath": "",
 // "tabIcon": "",
-- 
GitLab