codeforthailand/election-live

View on GitHub
src/components/MainLayout.js

Summary

Maintainability
D
1 day
Test Coverage
import { keyframes } from "@emotion/core"
import { Location } from "@reach/router"
import axios from "axios"
import { Link } from "gatsby"
import _ from "lodash"
import moment from "moment"
import React, { useEffect, useReducer, useRef, useState } from "react"
import semver from "semver"
import { DeveloperPanel, useLocalStorageFlag } from "../models/DeveloperOptions"
import {
  useLockedState,
  useStatus as useStatusAlertText,
  useSummaryData,
} from "../models/LiveDataSubscription"
import { DISPLAY_FONT, media, Responsive, WIDE_NAV_MIN_WIDTH } from "../styles"
import logo from "../styles/images/site-logo.png"
import { Debug } from "../util/Debug"
import ContentWrapper from "./ContentWrapper"
import DesktopScoreBarContainer from "./DesktopScoreBarContainer"
import Footer from "./Footer"
import Loading from "./Loading"
import NavBar, { menuMapping } from "./NavBar"
import Placeholder from "./Placeholder"
import VoteCounter from "./VoteCounter"
import ZoneMark from "./ZoneMark"
import { partyColor, getPartyById } from "../models/information"
import { appVersion } from "../util/appVersion"
import "moment/locale/th"

/**
 * @param {object} props
 * @param {import('./NavBar').NavBarSectionName} props.activeNavBarSection
 */
export default function MainLayout({ children, activeNavBarSection }) {
  const [navBarActive, toggleNavBar] = useReducer(state => !state, false)

  const [active, setActive] = useState(false)
  const [tooltipPayload, setTooltipPayload] = useState({})
  const [tooltipStyle, setTooltipStyle] = useState({})

  const onTooltipOpen = data => {
    const { x, y } = data.page
    setTooltipPayload(data)
    setTooltipStyle({ left: x + 20, top: y + 8 })
    setActive(true)
  }
  const onTooltipClose = () => {
    setActive(false)
  }
  const onTooltipClick = data => {
    active ? onTooltipOpen(data) : onTooltipClose(data)
  }
  const renderParty = () => {
    let detail
    let partyName
    if (tooltipPayload.party.id === 0) {
      partyName = tooltipPayload.party.name
    } else {
      partyName = (
        <div>
          {tooltipPayload.party.bundle ? null : (
            <ZoneMark
              color={partyColor(getPartyById(tooltipPayload.party.id))}
              isCompleted={true}
            />
          )}
          <span>พรรค{tooltipPayload.party.name}</span>
        </div>
      )
    }
    if (tooltipPayload.party.bundle) {
      detail = (
        <table>
          <tbody>
            {tooltipPayload.party.bundle.data
              .filter(party => party.count > 0)
              .map(party => (
                <tr>
                  <td>
                    <ZoneMark
                      color={partyColor(getPartyById(party.id))}
                      isCompleted={true}
                    />
                    <span>พรรค{party.name}</span>
                  </td>
                  <td>{party.count} ที่นั่ง</td>
                </tr>
              ))}
          </tbody>
        </table>
      )
    } else {
      detail = (
        <div css={{ fontSize: "16px" }}>
          {tooltipPayload.party.count} ที่นั่ง
        </div>
      )
    }
    return (
      <div>
        <div css={{ fontSize: "12px" }}>{tooltipPayload.title}</div>
        <div css={{ fontWeight: "bold", fontSize: "16px" }}>{partyName}</div>
        {detail}
      </div>
    )
  }

  useEffect(() => {
    const closeTooltip = e => setActive(false)
    document.body.addEventListener("click", closeTooltip, false)
    return () => {
      document.body.removeEventListener("click", closeTooltip)
    }
  })

  return (
    <div>
      <ContentWrapper background="black">
        <div
          css={{
            display: "flex",
            alignItems: "top",
          }}
        >
          <div
            css={{
              flex: "none",
              paddingTop: 24,
              paddingBottom: 20,
            }}
          >
            <Logo />
          </div>
          <div
            css={{
              flex: "1",
              marginLeft: "8px",
              height: 76,
              overflow: "hidden",
              [media(WIDE_NAV_MIN_WIDTH)]: {
                marginLeft: "24px",
              },
            }}
            onMouseLeave={onTooltipClose}
          >
            <DesktopScoreBarContainer
              onClick={onTooltipClick}
              onTooltipOpen={onTooltipOpen}
            />
          </div>
          <div
            css={{
              position: "absolute",
              width: "160px",
              zIndex: "1000",
              padding: "8px",
              color: "#000000",
              fontSize: "12px",
              border: "1px solid #eeeeee",
              borderRadius: "2px",
              backgroundColor: "#ffffff",
              boxShadow: "0 5px 8px rgba(0,0,0,0.12)",
            }}
            style={{
              display: active ? "block" : "none",
              maxWidth: "70vw",
              width: "200px",
              ...tooltipStyle,
            }}
            onMouseLeave={onTooltipClose}
          >
            {tooltipPayload.party ? renderParty() : "none"}
          </div>
          <div
            css={{
              position: "absolute",
              top: 0,
              right: "16px",
              width: "70px",
              [media(WIDE_NAV_MIN_WIDTH)]: {
                position: "relative",
                top: 0,
                right: 0,
                width: "180px",
              },
            }}
          >
            <VoteCounterContainer />
          </div>
        </div>
      </ContentWrapper>
      <div
        css={{
          height: 50,
          overflow: "hidden",
          [media(WIDE_NAV_MIN_WIDTH)]: { display: "none" },
        }}
      >
        <Responsive
          breakpoint={WIDE_NAV_MIN_WIDTH}
          narrow={
            <ContentWrapper background="#212121">
              <LatestDataIndicator />
              <div css={{ height: 40, color: "white", paddingTop: 10 }}>
                <div css={{ float: "right" }}>
                  <Hamburger onClick={toggleNavBar} active={navBarActive} />
                </div>
                <div
                  css={{
                    float: "right",
                    fontFamily: DISPLAY_FONT,
                    fontSize: "1.2rem",
                    marginRight: 10,
                  }}
                >
                  {menuMapping[activeNavBarSection]
                    ? menuMapping[activeNavBarSection].label
                    : "Menu"}
                </div>
              </div>
            </ContentWrapper>
          }
        />
      </div>
      <div
        data-active={navBarActive ? true : undefined}
        css={{
          display: "none",
          [media(WIDE_NAV_MIN_WIDTH)]: { display: "block" },
          "&[data-active]": { display: "block" },
        }}
      >
        <NavBar activeNavBarSection={activeNavBarSection} />
      </div>
      {children}
      <Location>
        {props => <CountdownCurtain location={props.location} />}
      </Location>
      <DeveloperPanel />
      <StatusAlert />
      <NewVersionAlert />
      <Footer />
    </div>
  )
}

function LatestDataIndicator() {
  const summaryState = useSummaryData()
  if (!summaryState.completed) return null
  const updatedAt = moment(summaryState.data.updatedAt)
    .locale("th")
    .format("DD MMMM YYYY HH:mm")

  return (
    <div
      css={{
        float: "left",
        color: "white",
        fontSize: "0.7rem",
        marginTop: 5,
        opacity: 0.8,
      }}
    >
      ข้อมูลล่าสุด <br />
      {updatedAt}
    </div>
  )
}

function VoteCounterContainer() {
  const summaryState = useSummaryData()
  if (!summaryState.completed) return null
  const progressArray = _(summaryState.data.zoneStatsMap)
    .values()
    .flatMap(x => _.values(x).map(z => z.progress))
    .value()
  const currentDate = new Date()
  const currentTime = `${("0" + currentDate.getHours()).slice(-2)}:${(
    "0" + currentDate.getMinutes()
  ).slice(-2)}`
  return (
    <VoteCounter
      percentage={Math.round(_.sum(progressArray) / progressArray.length)}
      lastUpdate={currentTime}
    />
  )
}

function Countdown() {
  const [date, setDate] = useState(new Date())
  const end = new Date("2019-03-24T18:00:00+07:00")
  const difference = end.getTime() - date.getTime()

  if (difference <= 0) {
    return false
  } else {
    useEffect(() => {
      const timerID = setInterval(() => setDate(new Date()), 1000)

      return function cleanup() {
        clearInterval(timerID)
      }
    })

    let seconds = Math.floor(difference / 1000)
    let minutes = Math.floor(seconds / 60)
    let hours = Math.floor(minutes / 60)

    const countdown = {
      hours: `${(hours %= 24)}`.padStart(2, "0"),
      minutes: `${(minutes %= 60)}`.padStart(2, "0"),
      seconds: `${(seconds %= 60)}`.padStart(2, "0"),
    }

    return (
      <div style={{ marginTop: "1em" }}>
        {countdown.hours} : {countdown.minutes} : {countdown.seconds}
      </div>
    )
  }
}

function CountdownCurtain({ location }) {
  const [skip] = useLocalStorageFlag("ELECT_DISABLE_CURTAIN")
  const locked = useLockedState()
  const ready = /^\/dev/.test(location.pathname) || skip || !locked
  if (!ready) {
    return (
      <div
        css={{
          background: "rgba(0,0,0,0.85)",
          color: "#fff",
          font: `30px ${DISPLAY_FONT}`,
          position: "fixed",
          top: 0,
          right: 0,
          bottom: 0,
          left: 0,
          zIndex: 998,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          textAlign: "center",
          animation: `3s ${curtainAnimation} linear`,
        }}
      >
        <div>
          <Loading size="large" />
          <img
            src={logo}
            width={250}
            css={{
              margin: "0 auto 15px auto",
              display: "block",
            }}
          />
          รอลุ้นผลการเลือกตั้งแบบเรียลไทม์ไปพร้อมกัน
          <br />
          <Countdown />
          {(location.hostname === "localhost" ||
            location.hostname === "127.0.0.1") && (
            <div style={{ marginTop: "1em" }}>
              <Placeholder>
                <div style={{ padding: 10 }}>
                  Hey developer! To disable this curtain, go to{" "}
                  <Link to="/dev">/dev</Link>
                </div>
              </Placeholder>
            </div>
          )}
        </div>
      </div>
    )
  }
  return null
}

function StatusAlert() {
  const status = useStatusAlertText()
  if (!status) return null
  return (
    <div
      css={{
        position: "absolute",
        top: 0,
        left: "50%",
        transform: "translateX(-50%)",
        padding: "4px 8px",
        background: "#F0324B",
        color: "white",
        fontSize: "20px",
        zIndex: 997,
      }}
    >
      {status}
    </div>
  )
}

function NewVersionAlert() {
  const [showAlert, setShowAlert] = useState(false)
  const debugRef = useRef()
  const debug = debugRef.current || Debug("elect:NewVersionAlert")
  useEffect(() => {
    let currentVersion = appVersion
    debugRef.current = debug
    const checkVersion = async () => {
      debug("Checking version...")
      try {
        const response = await axios.get("/version.info.json")
        if (semver.gt(response.data.version, currentVersion)) {
          debug(
            "Latest app version %s > %s",
            response.data.version,
            currentVersion
          )
          setShowAlert(true)
          currentVersion = response.data.version
        } else {
          debug(
            "Latest app version %s <= %s",
            response.data.version,
            currentVersion
          )
        }
      } catch (e) {
        debug("Failed to check version...", e)
      }
    }
    const timeout = setTimeout(checkVersion, 5000)
    const interval = setInterval(checkVersion, 90000)
    return () => {
      clearTimeout(timeout)
      clearInterval(interval)
    }
  }, [])
  if (!showAlert) return null
  return (
    <div
      css={{
        position: "absolute",
        top: 0,
        left: "50%",
        transform: "translateX(-50%)",
        padding: "4px 8px",
        background: "#F0324B",
        color: "white",
        fontSize: "20px",
        zIndex: 997,
      }}
    >
      เว็บนี้มีการอัพเดต{" "}
      <a css={{ color: "inherit" }} href="javascript:void(location.reload())">
        กรุณารีเฟรช
      </a>
    </div>
  )
}

const curtainAnimation = keyframes({
  "0%": { transform: "translateY(-120%)" },
  "100%": { transform: "translateY(0%)" },
})

function Logo() {
  return (
    <Link
      to="/"
      css={{
        display: "block",
        overflow: "hidden",
        width: 35,
        [media(WIDE_NAV_MIN_WIDTH)]: { width: "auto" },
      }}
    >
      <div
        alt="ELECT"
        css={{
          width: 35,
          height: 24,
          display: "block",
          backgroundSize: "cover",
          backgroundImage: `url(${require("../styles/images/site-logo-square.png")})`,
          [media(WIDE_NAV_MIN_WIDTH)]: {
            backgroundImage: `url(${require("../styles/images/site-logo.png")})`,
            width: 140,
            height: 32,
          },
        }}
      />
    </Link>
  )
}

function Hamburger({ onClick, active }) {
  // Hamburger from Codepen @naturalclar
  // https://codepen.io/naturalclar/pen/zEwvbg
  const styles = {
    container: {
      height: "20px",
      width: "20px",
      flexDirection: "column",
      justifyContent: "center",
      alignItems: "center",
      cursor: "pointer",
      paddingTop: "6px",
    },
    line: {
      height: "2px",
      width: "20px",
      background: "#FFF",
      transition: "all 0.2s ease",
    },
    lineTop: {
      transform: active ? "rotate(45deg)" : "none",
      transformOrigin: "top left",
      marginBottom: "5px",
    },
    lineMiddle: {
      opacity: active ? 0 : 1,
      transform: active ? "translateX(-16px)" : "none",
    },
    lineBottom: {
      transform: active ? "translateX(-1px) rotate(-45deg)" : "none",
      transformOrigin: "top left",
      marginTop: "5px",
    },
  }
  return (
    <div onClick={onClick}>
      {
        <div style={styles.container}>
          <div style={{ ...styles.line, ...styles.lineTop }} />
          <div style={{ ...styles.line, ...styles.lineMiddle }} />
          <div style={{ ...styles.line, ...styles.lineBottom }} />
        </div>
      }
    </div>
  )
}