diff options
author | Alexander Kiryukhin <a.kiryukhin@mail.ru> | 2022-06-03 19:04:53 +0300 |
---|---|---|
committer | Alexander Kiryukhin <a.kiryukhin@mail.ru> | 2022-06-03 19:04:53 +0300 |
commit | 0e7b36c2d443306325f17bb8850f5bb6176202bf (patch) | |
tree | 86629d4fe05d73f2d77dad423cc37d5a612430f3 /src | |
parent | 15d75cdc37e1459f7d11d004005d4305a6377ffd (diff) |
Diffstat (limited to 'src')
-rw-r--r-- | src/App.css | 38 | ||||
-rw-r--r-- | src/App.js | 25 | ||||
-rw-r--r-- | src/App.test.js | 8 | ||||
-rw-r--r-- | src/components/App.js | 97 | ||||
-rw-r--r-- | src/components/TimePicker.js | 22 | ||||
-rw-r--r-- | src/components/Timer.js | 62 | ||||
-rw-r--r-- | src/components/Timers.js | 41 | ||||
-rw-r--r-- | src/hooks/interval.js | 26 | ||||
-rw-r--r-- | src/hooks/local.js | 31 | ||||
-rw-r--r-- | src/index.css | 3 | ||||
-rw-r--r-- | src/index.js | 9 | ||||
-rw-r--r-- | src/logo.svg | 1 | ||||
-rw-r--r-- | src/service-worker.js | 70 | ||||
-rw-r--r-- | src/serviceWorkerRegistration.js | 137 | ||||
-rw-r--r-- | src/setupTests.js | 5 | ||||
-rw-r--r-- | src/utils/clipboard.js | 30 | ||||
-rw-r--r-- | src/utils/configFromLink.js | 11 | ||||
-rw-r--r-- | src/utils/notification.js | 34 | ||||
-rw-r--r-- | src/utils/timerTemplate.js | 1 | ||||
-rw-r--r-- | src/utils/updateTimers.js | 23 |
20 files changed, 591 insertions, 83 deletions
diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 74b5e05..0000000 --- a/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.js b/src/App.js deleted file mode 100644 index 3784575..0000000 --- a/src/App.js +++ /dev/null @@ -1,25 +0,0 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( - <div className="App"> - <header className="App-header"> - <img src={logo} className="App-logo" alt="logo" /> - <p> - Edit <code>src/App.js</code> and save to reload. - </p> - <a - className="App-link" - href="https://reactjs.org" - target="_blank" - rel="noopener noreferrer" - > - Learn React - </a> - </header> - </div> - ); -} - -export default App; diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(<App />); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/components/App.js b/src/components/App.js new file mode 100644 index 0000000..4688bbb --- /dev/null +++ b/src/components/App.js @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { CopyTwoTone, PlusCircleFilled, ClearOutlined } from '@ant-design/icons'; +import { Button, Card, Col, Form, Input, Layout, message, Popconfirm, Row, Switch } from 'antd'; + +import useInterval from '../hooks/interval'; +import useLocalStorage from '../hooks/local'; +import { copyTextToClipboard } from '../utils/clipboard'; +import { configFromLink } from '../utils/configFromLink'; +import { defaultTimer } from '../utils/timerTemplate'; +import { updateTimers } from '../utils/updateTimers'; + +import Timers from './Timers'; + +const { Footer } = Layout; + +function App() { + const [browserNotifications, setNotifications] = useLocalStorage("notifications", false); // доступны ли нативные уведомления + const [sound, setSound] = useLocalStorage("sound", true); // включен ли звук + const [timers, setTimers] = useLocalStorage("timers", configFromLink()); // хранилище таймеров + const [url, setUrl] = useState(""); // генерируемая ссылка на кофигурацию + + // Действия при открытии приложения: + // 1. Инициализация конфигурации из адреса + // 2. Запрос разрешений на уведомления + useEffect(() => { + if (window.location.hash !== "") { + setTimers(configFromLink()); + window.location.hash = ""; + } + Notification.requestPermission().then((result) => { + if (result === 'granted') { + setNotifications(true); + } else { + setNotifications(false); + } + }); + // eslint-disable-next-line + }, []); + + // Формирование ссылки на конфигурацию при обновлении таймеров + useEffect(() => { + const d = timers.map(t => [t.name, t.initialTime]); + setUrl(btoa(unescape(encodeURIComponent(JSON.stringify(d))))); + }, [timers]); + + // Обновление таймеров + useInterval(updateTimers.bind(this, timers, setTimers, sound, browserNotifications), 100, true); // интервал опроса таймеров - 100мс потому что если вкладка с таймерами в фоне, браузер начинает заметлять работу js + + return <Layout> + <Row> + <Col xs={24} sm={12} md={12} lg={8} xl={6}> + <Card + title={<strong><img alt="Мультитаймер" src="/logo192.png" style={{ height: 32, width: 32 }} /> Мультитаймер</strong>} + bodyStyle={{ padding: 4 }} + style={{ padding: 4, margin: 4 }} + > + <Form layout='vertical'> + <Form.Item> + <Button type={'primary'} block onClick={() => setTimers([...timers, { ...defaultTimer }])} icon={<PlusCircleFilled />}>Добавить таймер</Button> + </Form.Item> + <Form.Item label="Звук таймера"> + <Switch checkedChildren="Включен" unCheckedChildren="Отключен" checked={sound} onChange={x => setSound(x)} /> + </Form.Item> + <Form.Item label="Ссылка на конфигурацию"> + <Input + readOnly + value={`https://timer.neonxp.dev/#${url}`} + addonAfter={<CopyTwoTone onClick={() => { + copyTextToClipboard(`https://timer.neonxp.dev/#${url}`) + message.success("Скопировано в буфер обмена") + }} />} + /> + </Form.Item> + <Form.Item> + <Popconfirm + title="Очистить таймеры?" + onConfirm={() => setTimers([])} + okText="Да" + cancelText="Нет"> + <Button type={'ghost'} block icon={<ClearOutlined />}>Очистить</Button> + </Popconfirm> + </Form.Item> + </Form> + </Card> + </Col> + <Timers + items={timers} + setTimers={setTimers} + /> + </Row> + <Footer> + Сделал в 2022г <a href="https://neonxp.dev/">Александр NeonXP Кирюхин</a>. + </Footer> + </Layout>; +} + +export default App; diff --git a/src/components/TimePicker.js b/src/components/TimePicker.js new file mode 100644 index 0000000..0419088 --- /dev/null +++ b/src/components/TimePicker.js @@ -0,0 +1,22 @@ +import { InputNumber } from "antd"; + +const TimePicker = ({ setTime, time, disabled }) => { + const seconds = parseInt(Math.floor(time) % 60); + const minutes = parseInt(Math.floor(time / 60) % 60); + const hours = parseInt(Math.floor(time / 3600)); + const updateHours = (hours) => { + setTime(parseInt(hours)*3600+parseInt(minutes)*60+parseInt(seconds)); + } + const updateMinutes = (minutes) => { + setTime(parseInt(hours)*3600+parseInt(minutes)*60+parseInt(seconds)); + } + const updateSeconds = (seconds) => { + setTime(parseInt(hours)*3600+parseInt(minutes)*60+parseInt(seconds)); + } + return <> + <InputNumber disabled={disabled} type="number" min={0} max={24} style={{width: '90px'}} value={hours} onChange={x => {updateHours(x)}} addonAfter={`ч`} /> + <InputNumber disabled={disabled} type="number" min={0} max={59} style={{width: '90px'}} value={minutes} onChange={x => {updateMinutes(x)}} addonAfter={`м`} /> + <InputNumber disabled={disabled} type="number" min={0} max={59} style={{width: '90px'}} value={seconds} onChange={x => {updateSeconds(x)}} addonAfter={`с`} /> + </>; +} +export default TimePicker; diff --git a/src/components/Timer.js b/src/components/Timer.js new file mode 100644 index 0000000..1333040 --- /dev/null +++ b/src/components/Timer.js @@ -0,0 +1,62 @@ +import { DeleteTwoTone, PlayCircleFilled, StopFilled, LeftCircleFilled, RightCircleFilled, DownCircleFilled, UpCircleFilled, EditFilled } from "@ant-design/icons"; +import { Button, Card, Col, Input, Progress, Space, Popconfirm, Grid } from "antd"; +import ButtonGroup from "antd/lib/button/button-group"; +import { useState } from "react"; +import TimePicker from "./TimePicker"; + +const { useBreakpoint } = Grid; + +const Timer = ({ name, started, start, stop, setName, initialTime, time, setTime, remove, index, first, last, moveTimer }) => { + const [edit, setEdit] = useState(false); + const toggleEdit = () => { + setEdit(!edit); + } + const screens = useBreakpoint(); + let seconds = ("0" + (Math.floor(time) % 60)).slice(-2); + let minutes = ("0" + (Math.floor(time / 60) % 60)).slice(-2); + let hours = ("0" + Math.floor(time / 3600)).slice(-2); + return <Col xs={24} sm={12} md={12} lg={8} xl={6}> + <Card + index={index} + title={ + edit + ? (<Input value={name} onChange={ev => setName(ev.target.value)} />) + : (<div style={{ height: 32, overflow: 'hidden', textOverflow: 'ellipsis', width: '100%' }}>{name}</div>) + } + actions={[ + <Button type='primary' block title='Запуск' onClick={start} disabled={started || initialTime === 0} icon={<PlayCircleFilled />}>Запуск</Button>, + <Button type='danger' block title='Стоп' onClick={stop} disabled={!started} icon={<StopFilled />}>Стоп</Button>, + ]} + bodyStyle={{ padding: 4 }} + style={{ padding: 4, margin: 4 }} + > + <Space align="baseline" style={{ justifyContent: "flex-end", display: "flex" }}> + {edit + ? (<ButtonGroup> + <Popconfirm + title="Удалить таймер?" + onConfirm={remove} + okText="Да" + cancelText="Нет"> + <Button icon={<DeleteTwoTone />}></Button> + </Popconfirm> + <Button onClick={toggleEdit} icon={<EditFilled />}></Button> + </ButtonGroup>) + : (<ButtonGroup> + <Button disabled={first} onClick={() => moveTimer(-1)} icon={screens.sm ? <LeftCircleFilled /> : <UpCircleFilled />}></Button> + <Button disabled={last} onClick={() => moveTimer(1)} icon={screens.sm ? <RightCircleFilled /> : <DownCircleFilled />}></Button> + <Button onClick={toggleEdit} icon={<EditFilled />}></Button> + </ButtonGroup>)} + </Space> + <Space direction="vertical" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> + {screens.sm + ? (<Progress type="circle" status="active" percent={100 - (time / initialTime * 100)} format={percent => `${hours}:${minutes}:${seconds}`} width={200} />) + : (<Progress size="large" status="active" percent={100 - (time / initialTime * 100)} format={percent => `${hours}:${minutes}:${seconds}`} width={200} style={{ width: "200px" }} />) + } + <TimePicker disabled={started} time={initialTime} setTime={setTime} /> + </Space> + </Card> + </Col>; +} + +export default Timer; diff --git a/src/components/Timers.js b/src/components/Timers.js new file mode 100644 index 0000000..10b2557 --- /dev/null +++ b/src/components/Timers.js @@ -0,0 +1,41 @@ +import Timer from "./Timer"; + +const Timers = ({ items, setTimers }) => { + const setTime = (idx, time) => { + setTimers(items.map((t, id) => id !== idx ? t : { ...t, time: time, initialTime: time })); + }; + const initialTimer = (idx) => { + setTimers(items.map((t, id) => id !== idx ? t : { ...t, started: true, time: t.initialTime, startedAt: (new Date()).getTime() })); + }; + const stopTimer = (idx) => { + setTimers(items.map((t, id) => id !== idx ? t : { ...t, started: false })); + }; + const setTimerName = (idx, name) => { + setTimers(items.map((t, id) => id !== idx ? t : { ...t, name })); + }; + const deleteTimer = (idx) => { + setTimers(items.filter((t, id) => id !== idx)); + }; + const moveTimer = (idx, offset) => { + [items[idx], items[idx + offset]] = [items[idx + offset], items[idx]]; + setTimers(items); + }; + return items.map((t, idx) => <Timer + key={`item-${idx}`} + index={idx} + first={idx === 0} + last={idx === (items.length-1)} + name={t.name} + started={t.started} + time={t.time || 0} + initialTime={t.initialTime || 0} + setTime={(time) => setTime(idx, time)} + start={() => initialTimer(idx)} + stop={() => stopTimer(idx)} + setName={(name) => setTimerName(idx, name)} + remove={() => deleteTimer(idx)} + moveTimer={(offset) => moveTimer(idx, offset)} + />); +} + +export default Timers; diff --git a/src/hooks/interval.js b/src/hooks/interval.js new file mode 100644 index 0000000..78f6e7b --- /dev/null +++ b/src/hooks/interval.js @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +const useInterval = (callback, interval, immediate) => { + const ref = useRef(); + useEffect(() => { + ref.current = callback; + }, [callback]); + + useEffect(() => { + let cancelled = false; + + const fn = () => { + ref.current(() => cancelled); + }; + + const id = setInterval(fn, interval); + if (immediate) fn(); + + return () => { + cancelled = true; + clearInterval(id); + }; + }, [interval, immediate]); + }; + + export default useInterval; diff --git a/src/hooks/local.js b/src/hooks/local.js new file mode 100644 index 0000000..4b6b2ca --- /dev/null +++ b/src/hooks/local.js @@ -0,0 +1,31 @@ +import { useState } from "react"; + +const useLocalStorage = (key, initialValue) => { + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === "undefined") { + return initialValue; + } + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.warn(error); + return initialValue; + } + }); + const setValue = (value) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + console.log(error); + } + }; + return [storedValue, setValue]; +} + +export default useLocalStorage; diff --git a/src/index.css b/src/index.css index ec2585e..da1e001 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import '~antd/dist/antd.css'; + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', @@ -5,6 +7,7 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background: #f0f2f5; } code { diff --git a/src/index.js b/src/index.js index d563c0f..2dec7c3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; -import App from './App'; -import reportWebVitals from './reportWebVitals'; +import App from './components/App'; +import * as serviceWorkerRegistration from './serviceWorkerRegistration'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( @@ -11,7 +11,4 @@ root.render( </React.StrictMode> ); -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +serviceWorkerRegistration.register(); diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
\ No newline at end of file diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 0000000..05ea0d3 --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,70 @@ +/* eslint-disable no-restricted-globals */ + +// This service worker can be customized! +// See https://developers.google.com/web/tools/workbox/modules +// for the list of available Workbox modules, or add any other +// code you'd like. +// You can also remove this file if you'd prefer not to use a +// service worker, and the Workbox build step will be skipped. + +import { clientsClaim } from 'workbox-core'; +import { ExpirationPlugin } from 'workbox-expiration'; +import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; +import { registerRoute } from 'workbox-routing'; +import { StaleWhileRevalidate } from 'workbox-strategies'; + +clientsClaim(); + +// Precache all of the assets generated by your build process. +// Their URLs are injected into the manifest variable below. +// This variable must be present somewhere in your service worker file, +// even if you decide not to use precaching. See https://cra.link/PWA +precacheAndRoute(self.__WB_MANIFEST); + +// Set up App Shell-style routing, so that all navigation requests +// are fulfilled with your index.html shell. Learn more at +// https://developers.google.com/web/fundamentals/architecture/app-shell +const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); +registerRoute( + // Return false to exempt requests from being fulfilled by index.html. + ({ request, url }) => { + // If this isn't a navigation, skip. + if (request.mode !== 'navigate') { + return false; + } // If this is a URL that starts with /_, skip. + + if (url.pathname.startsWith('/_')) { + return false; + } // If this looks like a URL for a resource, because it contains // a file extension, skip. + + if (url.pathname.match(fileExtensionRegexp)) { + return false; + } // Return true to signal that we want to use the handler. + + return true; + }, + createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') +); + +// An example runtime caching route for requests that aren't handled by the +// precache, in this case same-origin .png requests like those from in public/ +registerRoute( + // Add in any other file extensions or routing criteria as needed. + ({ url }) => url.origin === self.location.origin && (url.pathname.endsWith('.png') || url.pathname.endsWith('.mp3')), + new StaleWhileRevalidate({ + cacheName: 'images', + plugins: [ + // Ensure that once this runtime cache reaches a maximum size the + // least-recently used images are removed. + new ExpirationPlugin({ maxEntries: 50 }), + ], + }) +); + +// This allows the web app to trigger skipWaiting via +// registration.waiting.postMessage({type: 'SKIP_WAITING'}) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/src/serviceWorkerRegistration.js b/src/serviceWorkerRegistration.js new file mode 100644 index 0000000..087f59c --- /dev/null +++ b/src/serviceWorkerRegistration.js @@ -0,0 +1,137 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://cra.link/PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) + ); + + export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://cra.link/PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } + } + + function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://cra.link/PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch((error) => { + console.error('Error during service worker registration:', error); + }); + } + + function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then((response) => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then((registration) => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log('No internet connection found. App is running in offline mode.'); + }); + } + + export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister(); + }) + .catch((error) => { + console.error(error.message); + }); + } + } diff --git a/src/setupTests.js b/src/setupTests.js deleted file mode 100644 index 8f2609b..0000000 --- a/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/src/utils/clipboard.js b/src/utils/clipboard.js new file mode 100644 index 0000000..6d139c3 --- /dev/null +++ b/src/utils/clipboard.js @@ -0,0 +1,30 @@ +const fallbackCopyTextToClipboard = (text) => { + var textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); +} + +export const copyTextToClipboard = (text) => { + if (!navigator.clipboard) { + fallbackCopyTextToClipboard(text); + return; + } + navigator.clipboard.writeText(text).then(function () { + }, function (err) { + console.error('Async: Could not copy text: ', err); + }); +} diff --git a/src/utils/configFromLink.js b/src/utils/configFromLink.js new file mode 100644 index 0000000..853c07c --- /dev/null +++ b/src/utils/configFromLink.js @@ -0,0 +1,11 @@ +import { defaultTimer } from "./timerTemplate"; + +export const configFromLink = () => { + const config = window.location.hash; + if (config === "") { + return [{ ...defaultTimer }]; + } + return JSON + .parse(decodeURIComponent(escape(window.atob(config.replace("#", ""))))) + .map(t => ({ ...defaultTimer, name: t[0], initialTime: t[1], time: t[1] })); + } diff --git a/src/utils/notification.js b/src/utils/notification.js new file mode 100644 index 0000000..a248dc8 --- /dev/null +++ b/src/utils/notification.js @@ -0,0 +1,34 @@ +import { Button, notification } from "antd"; + +const audio = new Audio('alarm.mp3'); +audio.preload = "auto"; + +export const timerComplete = (sound, browserNotifications, timer) => { + timer.started = false; // останавливаем + timer.time = 0; + + if (sound) { + audio.currentTime = 0; + audio.play(); + } + + // уведомление + if (browserNotifications) { + const notif = new Notification("Мультитаймер", { + body: `Сработал ${timer.name}`, + icon: "/logo192.png", + }); + notif.onclick = () => { + audio.pause(); + }; + } else { + notification.open({ + message: `Сработал ${timer.name}`, + description: (sound && (<Button type="primary" onClick={() => audio.pause()}>Звук выкл.</Button>)), + duration: 12, + onClose: () => { + audio.pause(); + }, + }); + } +} diff --git a/src/utils/timerTemplate.js b/src/utils/timerTemplate.js new file mode 100644 index 0000000..99aa7bb --- /dev/null +++ b/src/utils/timerTemplate.js @@ -0,0 +1 @@ +export const defaultTimer = { name: "Новый таймер", started: false, time: 30 * 60, initialTime: 30 * 60 }; diff --git a/src/utils/updateTimers.js b/src/utils/updateTimers.js new file mode 100644 index 0000000..ed37901 --- /dev/null +++ b/src/utils/updateTimers.js @@ -0,0 +1,23 @@ +import { timerComplete } from "./notification"; + +export const updateTimers = (timers, setTimers, sound, browserNotifications) => { + const now = (new Date()).getTime() / 1000; + setTimers( + timers.map(t => { + if (!t.started) { + return t; + } + // количество секунд от текущего времени до того времени когда должен сработать таймер + const nt = Math.max(0, Math.round((t.startedAt / 1000 + t.initialTime) - now)); + // игнорируем повторные срабатывания + if (t.time !== nt) { + t.time = nt; + // таймер должен сработать + if (t.time <= 0) { + timerComplete(sound, browserNotifications, t); + } + } + return t; + }) + ); +} |