qw/timer (#23)
* Rename logo and add 32x32 version * Set timer icon if a timer is running * Do not query activities on initialization * Show timer in bubble if timed activity exists * Pass timed activity to App * Code cleanup * Show timer view and stop timer * Make hours optional * Use booked seconds instead of hours * Add type submit to form button * Define colors as sass variables⎄ * Style timer view * Show start timer submit label * Update view layouts and content * Update version and changelog * Dyanically set iframe height * Reduce h1 font size * Add svg webpack loader * Parse empty string (TimeInputParser) * Forward ref in Popup component * Start time on current day only, format buttons * Improve styling * Set standard height as iframe default height, validate form * Upgrade packages to supress react warning * Show activity form in popup after timer was stoped * Use stop-watch icon in timer view * Fix empty description * Close TimerView if timer stopped for current service * Style timerview * Improve timer view styling * qw/setting-time-tracking-hh-mm (#24) * Format duration depending on settingTimeTrackingHHMM * Fix formatDuation without second argument * Fix time format after updating bubble * Add tests for formatDuration
This commit is contained in:
@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
|
||||
import Spinner from "components/Spinner"
|
||||
import Form from "components/Form"
|
||||
import Calendar from "components/Calendar"
|
||||
import TimerView from "components/App/TimerView"
|
||||
import { observable, computed } from "mobx"
|
||||
import { Observer, observer } from "mobx-react"
|
||||
import { Spring, animated, config } from "react-spring/renderprops"
|
||||
@@ -41,9 +42,15 @@ class App extends Component {
|
||||
activities: PropTypes.array,
|
||||
schedules: PropTypes.array,
|
||||
projects: PropTypes.array,
|
||||
timedActivity: PropTypes.shape({
|
||||
customer_name: PropTypes.string.isRequired,
|
||||
assignment_name: PropTypes.string.isRequired,
|
||||
task_name: PropTypes.string.isRequired,
|
||||
timer_started_at: PropTypes.string.isRequired,
|
||||
seconds: PropTypes.number.isRequired,
|
||||
}),
|
||||
lastProjectId: PropTypes.number,
|
||||
lastTaskId: PropTypes.number,
|
||||
roundTimeEntries: PropTypes.bool,
|
||||
fromDate: PropTypes.string,
|
||||
toDate: PropTypes.string,
|
||||
errorType: PropTypes.string,
|
||||
@@ -54,7 +61,6 @@ class App extends Component {
|
||||
activities: [],
|
||||
schedules: [],
|
||||
projects: [],
|
||||
roundTimeEntries: false,
|
||||
}
|
||||
|
||||
@observable changeset = {}
|
||||
@@ -94,8 +100,8 @@ class App extends Component {
|
||||
task_id: this.task?.value,
|
||||
billable: this.billable,
|
||||
hours: "",
|
||||
seconds: this.changeset.hours && new TimeInputParser(this.changeset.hours).parseSeconds(),
|
||||
description: service?.description,
|
||||
seconds: new TimeInputParser(this.changeset.hours).parseSeconds(),
|
||||
description: service?.description || "",
|
||||
tag: "",
|
||||
}
|
||||
|
||||
@@ -104,6 +110,7 @@ class App extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("keydown", this.handleKeyDown)
|
||||
parent.postMessage({ __mocoBX: { iFrameHeight: window.document.body.scrollHeight } }, "*")
|
||||
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
|
||||
}
|
||||
|
||||
@@ -130,6 +137,15 @@ class App extends Component {
|
||||
this.changeset.date = formatDate(date)
|
||||
}
|
||||
|
||||
handleStopTimer = timedActivity => {
|
||||
const { service } = this.props
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: "stopTimer",
|
||||
payload: { timedActivity, service },
|
||||
})
|
||||
}
|
||||
|
||||
handleSubmit = event => {
|
||||
event.preventDefault()
|
||||
const { service } = this.props
|
||||
@@ -161,6 +177,7 @@ class App extends Component {
|
||||
loading,
|
||||
subdomain,
|
||||
projects,
|
||||
timedActivity,
|
||||
activities,
|
||||
schedules,
|
||||
fromDate,
|
||||
@@ -191,25 +208,29 @@ class App extends Component {
|
||||
<animated.div className="moco-bx-app-container" style={props}>
|
||||
<Header subdomain={subdomain} />
|
||||
<Observer>
|
||||
{() => (
|
||||
<>
|
||||
<Calendar
|
||||
fromDate={parseISO(fromDate)}
|
||||
toDate={parseISO(toDate)}
|
||||
activities={activities}
|
||||
schedules={schedules}
|
||||
selectedDate={new Date(this.changesetWithDefaults.date)}
|
||||
onChange={this.handleSelectDate}
|
||||
/>
|
||||
<Form
|
||||
changeset={this.changesetWithDefaults}
|
||||
projects={projects}
|
||||
errors={this.formErrors}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{() =>
|
||||
timedActivity ? (
|
||||
<TimerView timedActivity={timedActivity} onStopTimer={this.handleStopTimer} />
|
||||
) : (
|
||||
<>
|
||||
<Calendar
|
||||
fromDate={parseISO(fromDate)}
|
||||
toDate={parseISO(toDate)}
|
||||
activities={activities}
|
||||
schedules={schedules}
|
||||
selectedDate={new Date(this.changesetWithDefaults.date)}
|
||||
onChange={this.handleSelectDate}
|
||||
/>
|
||||
<Form
|
||||
changeset={this.changesetWithDefaults}
|
||||
projects={projects}
|
||||
errors={this.formErrors}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Observer>
|
||||
</animated.div>
|
||||
)}
|
||||
|
||||
45
src/js/components/App/TimerView.js
Normal file
45
src/js/components/App/TimerView.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Timer from "components/shared/Timer"
|
||||
import { parseISO } from "date-fns"
|
||||
import StopWatch from "components/shared/StopWatch"
|
||||
|
||||
export default function TimerView({ timedActivity, onStopTimer }) {
|
||||
const handleStopTimer = () => {
|
||||
onStopTimer(timedActivity)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="moco-bx-timer-view">
|
||||
<p>
|
||||
<span className="moco-bx-single-line text-secondary">{timedActivity.customer_name}</span>
|
||||
<br />
|
||||
<span className="moco-bx-single-line">{timedActivity.assignment_name}</span>
|
||||
<br />
|
||||
<span className="moco-bx-single-line">{timedActivity.task_name}</span>
|
||||
</p>
|
||||
<h2>{timedActivity.description}</h2>
|
||||
<Timer
|
||||
className="timer text-red"
|
||||
startedAt={parseISO(timedActivity.timer_started_at)}
|
||||
offset={timedActivity.seconds}
|
||||
style={{ fontSize: "36px", display: "inline-block" }}
|
||||
/>
|
||||
<button className="moco-bx-btn btn-stop-timer" onClick={handleStopTimer}>
|
||||
<StopWatch />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TimerView.propTypes = {
|
||||
timedActivity: PropTypes.shape({
|
||||
customer_name: PropTypes.string.isRequired,
|
||||
assignment_name: PropTypes.string.isRequired,
|
||||
task_name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
timer_started_at: PropTypes.string.isRequired,
|
||||
seconds: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
onStopTimer: PropTypes.func.isRequired,
|
||||
}
|
||||
@@ -1,22 +1,46 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import logoUrl from "images/logo.png"
|
||||
import mocoLogo from "images/moco-32x32.png"
|
||||
import mocoTimerLogo from "images/moco-timer-32x32.png"
|
||||
import { parseISO } from "date-fns"
|
||||
import { formatDuration } from "utils"
|
||||
import Timer from "./shared/Timer"
|
||||
|
||||
const Bubble = ({ bookedHours }) => (
|
||||
<div className="moco-bx-bubble-inner">
|
||||
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
|
||||
{bookedHours > 0 ? (
|
||||
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
const Bubble = ({ bookedSeconds, timedActivity, settingTimeTrackingHHMM }) => {
|
||||
const logo = timedActivity ? mocoTimerLogo : mocoLogo
|
||||
|
||||
return (
|
||||
<div className="moco-bx-bubble-inner">
|
||||
<img className="moco-bx-logo" src={chrome.extension.getURL(logo)} />
|
||||
{!timedActivity && bookedSeconds > 0 && (
|
||||
<span className="moco-bx-booked-hours">
|
||||
{formatDuration(bookedSeconds, { settingTimeTrackingHHMM, showSeconds: false })}
|
||||
</span>
|
||||
)}
|
||||
{timedActivity && (
|
||||
<Timer
|
||||
className="text-red"
|
||||
startedAt={parseISO(timedActivity.timer_started_at)}
|
||||
offset={timedActivity.seconds}
|
||||
style={{ marginBottom: "3px", fontSize: "12px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Bubble.propTypes = {
|
||||
bookedHours: PropTypes.number,
|
||||
bookedSeconds: PropTypes.number,
|
||||
timedActivity: PropTypes.shape({
|
||||
timer_started_at: PropTypes.string.isRequired,
|
||||
seconds: PropTypes.number.isRequired,
|
||||
}),
|
||||
settingTimeTrackingHHMM: PropTypes.bool,
|
||||
}
|
||||
|
||||
Bubble.defaultProps = {
|
||||
bookedHours: 0,
|
||||
bookedSeconds: 0,
|
||||
settingTimeTrackingHHMM: false,
|
||||
}
|
||||
|
||||
export default Bubble
|
||||
|
||||
@@ -3,25 +3,23 @@ import settingsUrl from "images/settings.png"
|
||||
|
||||
const InvalidConfigurationError = () => (
|
||||
<div className="moco-bx-error-container">
|
||||
<h1>Bitte Einstellungen aktualisieren</h1>
|
||||
<ol>
|
||||
<li>Internetadresse eintragen</li>
|
||||
<li>Persönlichen API-Schlüssel eintragen</li>
|
||||
</ol>
|
||||
<h1>MOCO verbinden</h1>
|
||||
<p>
|
||||
Dazu trägst Du in den Einstellungen Deine Account-Internetadresse und Deinen API-Schlüssel
|
||||
ein.
|
||||
</p>
|
||||
<img
|
||||
src={chrome.extension.getURL(settingsUrl)}
|
||||
alt="Browser extension configuration settings"
|
||||
style={{ cursor: "pointer", width: "185px", height: "195px" }}
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||
/>
|
||||
<button
|
||||
className="moco-bx-btn"
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||
>
|
||||
Einstellungen öffnen
|
||||
Weiter zu den Einstellungen
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
<img
|
||||
src={chrome.extension.getURL(settingsUrl)}
|
||||
alt="Browser extension configuration settings"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import logo from "images/logo.png"
|
||||
import logo from "images/moco-159x159.png"
|
||||
|
||||
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
|
||||
<div className="moco-bx-error-container">
|
||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||
<img
|
||||
className="moco-bx-logo"
|
||||
src={logo}
|
||||
style={{ width: "48px", height: "48px" }}
|
||||
alt="MOCO logo"
|
||||
/>
|
||||
<h1>Ups, es ist ein Fehler passiert!</h1>
|
||||
<p>Bitte überprüfe deine Internetverbindung.</p>
|
||||
<p>Wir wurden per Email benachrichtigt und untersuchen den Vorfall.</p>
|
||||
<br />
|
||||
<p>Fehlermeldung:</p>
|
||||
<pre>{message}</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from "react"
|
||||
import { isChrome } from "utils/browser"
|
||||
import logo from "images/logo.png"
|
||||
import logo from "images/moco-159x159.png"
|
||||
import firefoxAddons from "images/firefox_addons.png"
|
||||
|
||||
const UpgradeRequiredError = () => (
|
||||
<div className="moco-bx-error-container">
|
||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||
<h1>Upgrade erforderlich</h1>
|
||||
<img
|
||||
className="moco-bx-logo"
|
||||
src={logo}
|
||||
style={{ width: "48px", height: "48px" }}
|
||||
alt="MOCO logo"
|
||||
/>
|
||||
<h1>Bitte aktualisieren</h1>
|
||||
<p>Die installierte MOCO Browser-Erweiterung ist veraltet — bitte aktualisieren.</p>
|
||||
{isChrome() ? (
|
||||
<button
|
||||
@@ -17,9 +22,13 @@ const UpgradeRequiredError = () => (
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<p>Unter folgender URL:</p>
|
||||
<img className="firefox-addons" src={firefoxAddons} alt="about:addons" />
|
||||
<img
|
||||
className="firefox-addons"
|
||||
src={firefoxAddons}
|
||||
style={{ width: "292px", height: "40px" }}
|
||||
alt="about:addons"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Select from "components/Select"
|
||||
import { formatDate } from "utils"
|
||||
import cn from "classnames"
|
||||
import StopWatch from "components/shared/StopWatch"
|
||||
|
||||
class Form extends Component {
|
||||
static propTypes = {
|
||||
changeset: PropTypes.shape({
|
||||
project: PropTypes.object,
|
||||
task: PropTypes.object,
|
||||
assignment_id: PropTypes.number.isRequired,
|
||||
billable: PropTypes.bool.isRequired,
|
||||
date: PropTypes.string.isRequired,
|
||||
task_id: PropTypes.number.isRequired,
|
||||
description: PropTypes.string,
|
||||
remote_id: PropTypes.string,
|
||||
remote_service: PropTypes.string,
|
||||
remote_url: PropTypes.string,
|
||||
seconds: PropTypes.number,
|
||||
hours: PropTypes.string,
|
||||
}).isRequired,
|
||||
errors: PropTypes.object,
|
||||
@@ -20,9 +29,42 @@ class Form extends Component {
|
||||
inline: true,
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
isValid() {
|
||||
const { changeset } = this.props
|
||||
return ["assignment_id", "task_id", "hours"].map(prop => changeset[prop]).every(Boolean)
|
||||
return (
|
||||
["assignment_id", "task_id"].map(prop => changeset[prop]).every(Boolean) &&
|
||||
(changeset.date === formatDate(new Date()) || changeset.seconds > 0)
|
||||
)
|
||||
}
|
||||
|
||||
get isTimerStartable() {
|
||||
const {
|
||||
changeset: { seconds, date },
|
||||
} = this.props
|
||||
|
||||
return date === formatDate(new Date()) && seconds === 0
|
||||
}
|
||||
|
||||
buttonStyle() {
|
||||
const styleMap = {
|
||||
true: {
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: "60px",
|
||||
height: "60px",
|
||||
marginTop: "22px",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
},
|
||||
false: {
|
||||
border: "none",
|
||||
width: "50px",
|
||||
height: "36px",
|
||||
marginTop: "35px",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
},
|
||||
}
|
||||
|
||||
return styleMap[this.isTimerStartable]
|
||||
}
|
||||
|
||||
handleTextareaKeyDown = event => {
|
||||
@@ -96,8 +138,13 @@ class Form extends Component {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button className="moco-bx-btn" disabled={!this.isValid()}>
|
||||
OK
|
||||
<button
|
||||
type="submit"
|
||||
className="moco-bx-btn"
|
||||
disabled={!this.isValid()}
|
||||
style={this.buttonStyle()}
|
||||
>
|
||||
{this.isTimerStartable ? <StopWatch /> : "OK"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,11 @@ class Options extends Component {
|
||||
handleSubmit = _event => {
|
||||
this.isSuccess = false
|
||||
this.errorMessage = null
|
||||
setStorage({ subdomain: this.subdomain, apiKey: this.apiKey }).then(() => {
|
||||
setStorage({
|
||||
subdomain: this.subdomain,
|
||||
apiKey: this.apiKey,
|
||||
settingTimeTrackingHHMM: false,
|
||||
}).then(() => {
|
||||
const { version } = chrome.runtime.getManifest()
|
||||
const apiClient = new ApiClient({
|
||||
subdomain: this.subdomain,
|
||||
@@ -34,6 +38,9 @@ class Options extends Component {
|
||||
})
|
||||
apiClient
|
||||
.login()
|
||||
.then(({ data }) =>
|
||||
setStorage({ settingTimeTrackingHHMM: data.setting_time_tracking_hh_mm }),
|
||||
)
|
||||
.then(() => {
|
||||
this.isSuccess = true
|
||||
this.closeWindow()
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
import React, { Component } from "react"
|
||||
import React, { useEffect, useRef, forwardRef } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import queryString from "query-string"
|
||||
import { ERROR_UNKNOWN, ERROR_UNAUTHORIZED, ERROR_UPGRADE_REQUIRED, serializeProps } from "utils"
|
||||
import { isChrome } from "utils/browser"
|
||||
import { serializeProps } from "utils"
|
||||
|
||||
function getStyles(errorType) {
|
||||
return {
|
||||
width: "516px",
|
||||
height:
|
||||
errorType === ERROR_UNAUTHORIZED
|
||||
? "834px"
|
||||
: errorType === ERROR_UPGRADE_REQUIRED
|
||||
? isChrome()
|
||||
? "369px"
|
||||
: "461px"
|
||||
: errorType === ERROR_UNKNOWN
|
||||
? "550px"
|
||||
: "558px",
|
||||
}
|
||||
}
|
||||
const Popup = forwardRef((props, ref) => {
|
||||
const iFrameRef = useRef()
|
||||
|
||||
class Popup extends Component {
|
||||
static propTypes = {
|
||||
service: PropTypes.object,
|
||||
errorType: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
handleRequestClose = event => {
|
||||
const handleRequestClose = event => {
|
||||
if (event.target.classList.contains("moco-bx-popup")) {
|
||||
this.props.onRequestClose()
|
||||
props.onRequestClose()
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const handleMessage = event => {
|
||||
if (iFrameRef.current && event.data?.__mocoBX?.iFrameHeight > 300) {
|
||||
iFrameRef.current.style.height = `${event.data.__mocoBX.iFrameHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Document might lose focus when clicking the browser action.
|
||||
// Document might be out of focus when hitting the shortcut key.
|
||||
// This puts the focus back to the document and ensures that:
|
||||
@@ -41,41 +26,47 @@ class Popup extends Component {
|
||||
// - the ESC key closes the popup without closing anything else
|
||||
window.focus()
|
||||
document.activeElement?.blur()
|
||||
}
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
render() {
|
||||
const serializedProps = serializeProps([
|
||||
"loading",
|
||||
"service",
|
||||
"subdomain",
|
||||
"lastProjectId",
|
||||
"lastTaskId",
|
||||
"roundTimeEntries",
|
||||
"projects",
|
||||
"activities",
|
||||
"schedules",
|
||||
"lastProjectId",
|
||||
"lastTaskId",
|
||||
"fromDate",
|
||||
"toDate",
|
||||
"errorType",
|
||||
"errorMessage",
|
||||
])(this.props)
|
||||
const serializedProps = serializeProps([
|
||||
"loading",
|
||||
"service",
|
||||
"subdomain",
|
||||
"projects",
|
||||
"activities",
|
||||
"schedules",
|
||||
"timedActivity",
|
||||
"lastProjectId",
|
||||
"lastTaskId",
|
||||
"fromDate",
|
||||
"toDate",
|
||||
"errorType",
|
||||
"errorMessage",
|
||||
])(props)
|
||||
|
||||
const styles = getStyles(this.props.errorType)
|
||||
|
||||
return (
|
||||
<div className="moco-bx-popup" onClick={this.handleRequestClose}>
|
||||
<div className="moco-bx-popup-content" style={styles}>
|
||||
<iframe
|
||||
src={chrome.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
|
||||
width={styles.width}
|
||||
height={styles.height}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div ref={ref} className="moco-bx-popup" onClick={handleRequestClose}>
|
||||
<div className="moco-bx-popup-content" style={{ width: "516px" }}>
|
||||
<iframe
|
||||
ref={iFrameRef}
|
||||
src={chrome.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
|
||||
style={{ width: "516px", height: "576px", transition: "height 0.1s ease-in-out" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Popup.displayName = "Popup"
|
||||
|
||||
Popup.propTypes = {
|
||||
service: PropTypes.object,
|
||||
errorType: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default Popup
|
||||
|
||||
@@ -36,7 +36,7 @@ const customTheme = theme => ({
|
||||
const customStyles = props => ({
|
||||
control: (base, _state) => ({
|
||||
...base,
|
||||
borderColor: props.hasError ? "#FB3A2F" : base.borderColor,
|
||||
borderColor: props.hasError ? "#fb3a2f" : base.borderColor,
|
||||
}),
|
||||
valueContainer: base => ({
|
||||
...base,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import logoUrl from "images/logo.png"
|
||||
import logoUrl from "images/moco-159x159.png"
|
||||
|
||||
const Header = ({ subdomain }) => (
|
||||
<div className="moco-bx-logo__container">
|
||||
|
||||
11
src/js/components/shared/StopWatch.js
Normal file
11
src/js/components/shared/StopWatch.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
import stopWatch from "images/icons/stopwatch-light.svg"
|
||||
|
||||
export default function StopWatch() {
|
||||
return (
|
||||
<i
|
||||
dangerouslySetInnerHTML={{ __html: stopWatch }}
|
||||
style={{ width: "22px", color: "white", display: "inline-block" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
src/js/components/shared/Timer.js
Normal file
29
src/js/components/shared/Timer.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useState } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import { useInterval } from "./hooks"
|
||||
import { differenceInSeconds } from "date-fns"
|
||||
import { formatDuration } from "utils"
|
||||
|
||||
Timer.propTypes = {
|
||||
startedAt: PropTypes.instanceOf(Date).isRequired,
|
||||
offset: PropTypes.number,
|
||||
onTick: PropTypes.func,
|
||||
}
|
||||
|
||||
function Timer({ startedAt, offset = 0, onTick, ...domProps }) {
|
||||
const [timerLabel, setTimerLabel] = useState(formattedTimerLabel(startedAt, offset))
|
||||
|
||||
useInterval(() => {
|
||||
setTimerLabel(formattedTimerLabel(startedAt, offset))
|
||||
onTick && onTick()
|
||||
}, 1000)
|
||||
|
||||
return <span {...domProps}>{timerLabel}</span>
|
||||
}
|
||||
|
||||
function formattedTimerLabel(startedAt, offset) {
|
||||
const seconds = differenceInSeconds(new Date(), startedAt) + offset
|
||||
return formatDuration(seconds)
|
||||
}
|
||||
|
||||
export default Timer
|
||||
18
src/js/components/shared/hooks.js
Normal file
18
src/js/components/shared/hooks.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
export function useInterval(callback, delay) {
|
||||
const savedCallback = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
savedCallback.current()
|
||||
}
|
||||
|
||||
let id = setInterval(tick, delay)
|
||||
return () => clearInterval(id)
|
||||
}, [delay])
|
||||
}
|
||||
Reference in New Issue
Block a user