- {children}
-
- {isSelectShown && (
-
- {options.map((item, index) => (
-
onItemClick(item)}
- onKeyDown={(e) => onItemKeyDown(e, item)}
- className={cn("select-item", {
- "select-item--selected": item === current,
- })}
- >
- {item}
-
- ))}
+ return (
+
+ {children}
+
+ {isSelectShown && (
+
+ {options.map((item, index) => (
+
onItemClick(item)}
+ onKeyDown={(e) => onItemKeyDown(e, item)}
+ className={cn('select-item', {
+ 'select-item--selected': item === current,
+ })}
+ >
+ {item}
+
+ ))}
+
+ )}
- )}
-
- );
+ );
};
-SelectComponent.displayName = "SelectComponent";
+SelectComponent.displayName = 'SelectComponent';
diff --git a/src/components/settings-component/index.ts b/src/components/settings-component/index.ts
new file mode 100644
index 0000000..6eda67e
--- /dev/null
+++ b/src/components/settings-component/index.ts
@@ -0,0 +1 @@
+export { SettingsComponent } from './settings-component';
diff --git a/src/components/settings-component/settings-component.scss b/src/components/settings-component/settings-component.scss
index 8c9da50..b9469c6 100644
--- a/src/components/settings-component/settings-component.scss
+++ b/src/components/settings-component/settings-component.scss
@@ -1,4 +1,4 @@
-@import "../../app/style";
+@import "../../pages/app/style";
$settingsWidth: 182px;
$settingsHeight: 315px;
diff --git a/src/components/settings-component/settings-component.tsx b/src/components/settings-component/settings-component.tsx
index 799eee5..4844dea 100644
--- a/src/components/settings-component/settings-component.tsx
+++ b/src/components/settings-component/settings-component.tsx
@@ -1,132 +1,127 @@
-import * as React from "react";
+import * as React from 'react';
-import ru from "../../locales/ru.json";
-import en from "../../locales/en.json";
-import { useSettings } from "../../hooks";
-import { Settings } from "../../settings-context";
-import {
- BorderedHeader,
- CheckBoxComponent,
- RangeComponent,
- SelectComponent,
-} from "..";
+import ru from '../../locales/ru.json';
+import en from '../../locales/en.json';
+import { useSettings } from '../../hooks';
+import { Settings } from '../../settings-context';
+import { BorderedHeader, CheckboxComponent, RangeComponent, SelectComponent } from '..';
-import "./settings-component.scss";
+import './settings-component.scss';
interface Props {
- closeSettings: () => void;
- checkboxOnSoundPlay: (volume?: number) => void;
- checkboxOffSoundPlay: (volume?: number) => void;
+ closeSettings: () => void;
+ checkboxOnSoundPlay: (volume?: number) => void;
+ checkboxOffSoundPlay: (volume?: number) => void;
}
/* eslint-disable no-unused-expressions */
export const SettingsComponent: React.FC
= ({
- closeSettings,
- checkboxOnSoundPlay,
- checkboxOffSoundPlay,
+ closeSettings,
+ checkboxOnSoundPlay,
+ checkboxOffSoundPlay,
}: Props) => {
- const { settings, saveSettings } = useSettings();
+ const { settings, saveSettings } = useSettings();
- const handleCheckboxClick = (option: keyof Settings) => {
- saveSettings?.({ ...settings, [option]: !settings[option] });
- if (!settings.uiSound) {
- return;
- }
- if (settings[option]) {
- checkboxOffSoundPlay();
- } else {
- checkboxOnSoundPlay();
- }
- };
+ const handleCheckboxClick = (option: keyof Settings) => () => {
+ saveSettings?.({ ...settings, [option]: !settings[option] });
+ if (!settings.uiSound) {
+ return;
+ }
+ if (settings[option]) {
+ checkboxOffSoundPlay();
+ } else {
+ checkboxOnSoundPlay();
+ }
+ };
- const handleChangeLanguage = (nextLanguage: string) => {
- if (settings.currentLanguage === nextLanguage) {
- return;
- }
- if (nextLanguage === ru["ui.language"]) {
- saveSettings?.({
- ...settings,
- language: ru,
- currentLanguage: nextLanguage,
- });
- } else {
- saveSettings?.({
- ...settings,
- language: en,
- currentLanguage: nextLanguage,
- });
- }
- if (!settings.uiSound) {
- return;
- }
- checkboxOnSoundPlay();
- };
+ const handleChangeLanguage = (nextLanguage: string) => {
+ if (settings.currentLanguage === nextLanguage) {
+ return;
+ }
+ if (nextLanguage === ru['ui.language']) {
+ saveSettings?.({
+ ...settings,
+ language: ru,
+ currentLanguage: nextLanguage,
+ });
+ } else {
+ saveSettings?.({
+ ...settings,
+ language: en,
+ currentLanguage: nextLanguage,
+ });
+ }
+ if (!settings.uiSound) {
+ return;
+ }
+ checkboxOnSoundPlay();
+ };
- const handleChangeRange = (value: number) => {
- checkboxOnSoundPlay();
- saveSettings?.({ ...settings, musicVolume: value });
- };
+ const handleChangeRange = (value: number) => {
+ checkboxOnSoundPlay();
+ saveSettings?.({ ...settings, musicVolume: value });
+ };
- const chooseOption = (option: keyof Settings): React.ReactNode => {
- const { language } = settings;
- switch (typeof settings[option]) {
- case "boolean":
+ const chooseOption = (option: keyof Settings): React.ReactNode => {
+ const { language } = settings;
+ switch (typeof settings[option]) {
+ case 'boolean':
+ return (
+
+ );
+ case 'object':
+ return (
+
+ {language['ui.language']}
+
+ );
+ case 'number':
+ return (
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ const renderOption = (option: keyof Settings) => {
+ const { language } = settings;
+ const valueName = `ui.${option}` as keyof typeof language;
return (
-
- );
- case "object":
- return (
-
- {language["ui.language"]}
-
- );
- case "number":
- return (
-
+
+
{language[valueName]}
+ {chooseOption(option)}
+
);
+ };
- default:
- return null;
- }
- };
-
- const renderOption = (option: keyof Settings) => {
- const { language } = settings;
- const valueName = `ui.${option}` as keyof typeof language;
return (
-
-
{language[valueName]}
- {chooseOption(option)}
-
+
+
+ {settings.language['ui.main-menu']}
+
+
+ {renderOption('uiLanguage')}
+ {renderOption('musicVolume')}
+ {renderOption('uiSound')}
+
+
+
);
- };
-
- return (
-
-
- {settings.language["ui.main-menu"]}
-
-
- {renderOption("uiLanguage")}
- {renderOption("musicVolume")}
- {renderOption("uiSound")}
-
-
-
- );
};
-SettingsComponent.displayName = "SettingsComponent";
+SettingsComponent.displayName = 'SettingsComponent';
diff --git a/src/components/view-component/index.ts b/src/components/view-component/index.ts
new file mode 100644
index 0000000..21afc7b
--- /dev/null
+++ b/src/components/view-component/index.ts
@@ -0,0 +1 @@
+export { ViewComponent } from './view-component';
diff --git a/src/components/view-component/view-component.scss b/src/components/view-component/view-component.scss
index d5d64ac..6c65d43 100644
--- a/src/components/view-component/view-component.scss
+++ b/src/components/view-component/view-component.scss
@@ -1,4 +1,4 @@
-@import "../../app/style";
+@import "../../pages/app/style";
.view {
position: absolute;
diff --git a/src/components/view-component/view-component.tsx b/src/components/view-component/view-component.tsx
index 224e50d..1e62eeb 100644
--- a/src/components/view-component/view-component.tsx
+++ b/src/components/view-component/view-component.tsx
@@ -1,179 +1,54 @@
-import * as React from "react";
-import {
- FocusEvent,
- MouseEvent,
- TouchEvent,
- useCallback,
- useEffect,
- useState,
-} from "react";
-import cn from "classnames";
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import cn from 'classnames';
-import { Background } from "../../assets";
-import { ANIMATION_DURATION, DEFAULT_HEIGHT, DEFAULT_WIDTH } from "../../utils";
+import { Background } from '../../assets';
+import { ANIMATION_DURATION } from '../../utils';
+import { useViewScroll } from '../../hooks';
-import "./view-component.scss";
+import './view-component.scss';
interface Props {
- src: string;
+ src: string;
}
-interface Position {
- x: number;
- y: number;
-}
-
-const initialPosition = {
- x: 0,
- y: 0,
-};
-
export const ViewComponent: React.FC = ({ src }: Props) => {
- const [imageSrc, setImageSrc] = useState(Background);
- const [isLoaded, setLoaded] = useState(false);
- const [isDrag, setDrag] = useState(false);
- const [trackPosition, setTrackPosition] = useState(initialPosition);
- const [position, setPosition] = useState(initialPosition);
- const [lastPosition, setLastPosition] = useState(initialPosition);
- const [isBigScreen, setBigScreen] = useState(false);
+ const [imageSrc, setImageSrc] = useState(Background);
+ const [isLoaded, setLoaded] = useState(false);
- const handleResize = useCallback(() => {
- const { innerWidth, innerHeight } = window;
+ const { style, props } = useViewScroll();
- let width = 0;
- let height = 0;
- setBigScreen(false);
+ useEffect(() => {
+ setLoaded(false);
+ const timer = setTimeout(() => {
+ const image = new Image();
+ image.src = src;
+ image.onload = () => {
+ setImageSrc(src);
+ setLoaded(true);
+ };
+ }, ANIMATION_DURATION);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [src]);
- if (innerHeight < DEFAULT_HEIGHT && innerWidth < DEFAULT_WIDTH) {
- width = (innerWidth - DEFAULT_WIDTH) / 2;
- height = (innerHeight - DEFAULT_HEIGHT) / 2;
- } else {
- setBigScreen(true);
- }
- setPosition({ x: width, y: height });
- setLastPosition({ x: width, y: height });
- }, []);
-
- useEffect(() => {
- handleResize();
- setLoaded(false);
- const timer = setTimeout(() => {
- const image = new Image();
- image.src = src;
- image.onload = () => {
- setImageSrc(src);
- setLoaded(true);
- };
- }, ANIMATION_DURATION);
- return () => {
- clearTimeout(timer);
- };
- }, [src, handleResize]);
-
- useEffect(() => {
- window.addEventListener("resize", handleResize);
- return () => {
- window.removeEventListener("resize", handleResize);
- };
- }, [handleResize]);
-
- const limiter = (value: Position, width: number, height: number) => {
- const { x: xValue, y: yValue } = value;
-
- let x = lastPosition.x - trackPosition.x + xValue;
- let y = lastPosition.y - trackPosition.y + yValue;
-
- if (x > 0) {
- x = 0;
- } else if (x < -width) {
- x = -width;
- }
-
- if (y > 0) {
- y = 0;
- } else if (y < -height) {
- y = -height;
- }
-
- return { x, y };
- };
-
- const handleTouchMove = (e: TouchEvent) => {
- const { touches } = e;
- if (isBigScreen) {
- return;
- }
- const { innerWidth, innerHeight } = window;
- const width = DEFAULT_WIDTH - innerWidth;
- const height = DEFAULT_HEIGHT - innerHeight;
- const { clientX: x, clientY: y } = touches[0];
-
- const diff = limiter({ x, y }, width, height);
-
- setPosition(diff);
- };
-
- const handleTouchstart = (e: TouchEvent) => {
- const { touches } = e;
- e.nativeEvent.stopImmediatePropagation();
- const { clientX: x, clientY: y } = touches[0];
- setTrackPosition({ x, y });
- setDrag(true);
- };
-
- const handleMouseDown = (e: MouseEvent) => {
- const { clientX, clientY } = e;
- e.nativeEvent.stopImmediatePropagation();
- setTrackPosition({ x: clientX, y: clientY });
- setDrag(true);
- };
-
- const handleFree = (e: MouseEvent | FocusEvent | TouchEvent) => {
- e.nativeEvent.stopImmediatePropagation();
- setDrag(false);
- setLastPosition(position);
- };
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!isDrag) {
- return;
- }
- if (isBigScreen) {
- return;
- }
- const { innerWidth, innerHeight } = window;
- const width = DEFAULT_WIDTH - innerWidth;
- const height = DEFAULT_HEIGHT - innerHeight;
-
- const { clientX: x, clientY: y } = e;
- const diff = limiter({ x, y }, width, height);
- setPosition(diff);
- };
-
- return (
-
- );
+ return (
+
+ );
};
-ViewComponent.displayName = "ViewComponent";
+ViewComponent.displayName = 'ViewComponent';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index ba8b999..a168869 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1 +1,7 @@
-export { useSettings } from "./use-settings";
+export { useSettings } from './use-settings';
+export { useUiSound } from './use-ui-sound';
+export { usePanelScroll } from './use-panel-scroll';
+export { usePlaceView } from './use-place-view';
+export { useViewScroll } from './use-view-scroll';
+export { useDialog } from './use-dialog';
+export { useDialogStep } from './use-dialog-step';
diff --git a/src/hooks/use-dialog-step.ts b/src/hooks/use-dialog-step.ts
new file mode 100644
index 0000000..c515d8d
--- /dev/null
+++ b/src/hooks/use-dialog-step.ts
@@ -0,0 +1,49 @@
+import { useEffect, useRef, useState } from 'react';
+import { ANIMATION_DURATION, DIALOG_STEP_DURATION } from '../utils';
+
+interface dialogStepType {
+ step: string;
+ isStepShown: boolean;
+}
+
+interface Props {
+ text: string[];
+}
+
+export const useDialogStep = ({ text }: Props): dialogStepType => {
+ const [stepsCount] = useState(text.length);
+ const [step, setStep] = useState(0);
+ const [isStepShown, setStepShown] = useState(true);
+ const stepIntervalRef = useRef(null);
+ const stepShownTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ stepIntervalRef.current = setInterval(() => {
+ const nextStep = step + 1;
+ if (nextStep === stepsCount && stepIntervalRef.current) {
+ clearInterval(stepIntervalRef.current);
+ }
+
+ setStepShown(false);
+ stepShownTimeoutRef.current = setTimeout(() => {
+ setStep(nextStep);
+ setStepShown(true);
+ }, ANIMATION_DURATION / 2);
+ }, DIALOG_STEP_DURATION);
+
+ return () => {
+ if (stepIntervalRef.current) {
+ clearInterval(stepIntervalRef.current);
+ }
+
+ if (stepShownTimeoutRef.current) {
+ clearTimeout(stepShownTimeoutRef.current);
+ }
+ };
+ }, [step, stepsCount]);
+
+ return {
+ step: text[step],
+ isStepShown,
+ };
+};
diff --git a/src/hooks/use-dialog.ts b/src/hooks/use-dialog.ts
new file mode 100644
index 0000000..b3fd91b
--- /dev/null
+++ b/src/hooks/use-dialog.ts
@@ -0,0 +1,104 @@
+import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
+import { ANIMATION_DURATION, debounce, DIALOG_STEP_DURATION } from '../utils';
+
+interface dialogPositionType {
+ bottomPanelButtonRef: RefObject;
+ offsetTop: number;
+ handleShowDialog: () => void;
+ handleHideDialog: () => void;
+ isDialogShown: boolean;
+ isDialogMounted: boolean;
+ handleDialogMenuItemClick: () => void;
+}
+
+interface Props {
+ isBottomPanelShown: boolean;
+ stepsCount: number;
+}
+
+export const useDialog = ({ isBottomPanelShown, stepsCount }: Props): dialogPositionType => {
+ const bottomPanelButtonRef = useRef(null);
+ const [isMounted, setMounted] = useState(false);
+ const [isShown, setShown] = useState(false);
+ const [offsetTop, setOffsetTop] = useState(0);
+ const shownTimerRef = useRef(null);
+
+ const updateOffset = useCallback(() => {
+ if (!bottomPanelButtonRef.current) {
+ return;
+ }
+
+ const { top } = bottomPanelButtonRef.current.getBoundingClientRect();
+ setOffsetTop(top);
+ }, [bottomPanelButtonRef]);
+
+ useEffect(() => {
+ updateOffset();
+
+ const throttledUpdateOffset = debounce(updateOffset, 100);
+ window.addEventListener('resize', throttledUpdateOffset);
+ return () => {
+ window.removeEventListener('resize', throttledUpdateOffset);
+ };
+ }, [updateOffset]);
+
+ useEffect(() => {
+ const timer = setTimeout(updateOffset, ANIMATION_DURATION);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [updateOffset, isBottomPanelShown]);
+
+ useEffect(() => {
+ let mountTimer: NodeJS.Timeout;
+ let shownTimer: NodeJS.Timeout;
+ let mainTimer: NodeJS.Timeout;
+
+ if (isMounted) {
+ mainTimer = setTimeout(() => {
+ setShown(true);
+ shownTimer = setTimeout(() => {
+ setShown(false);
+ mountTimer = setTimeout(() => setMounted(false), ANIMATION_DURATION);
+ }, stepsCount * DIALOG_STEP_DURATION);
+ }, ANIMATION_DURATION);
+ }
+ return () => {
+ clearTimeout(mainTimer);
+ clearTimeout(shownTimer);
+ clearTimeout(mountTimer);
+ };
+ }, [isMounted, stepsCount]);
+
+ const handleShowDialog = useCallback(() => setMounted(true), []);
+ const handleHideDialog = useCallback(() => {
+ setShown(false);
+ shownTimerRef.current = setTimeout(() => setMounted(false), ANIMATION_DURATION);
+ }, []);
+
+ const handleDialogMenuItemClick = useCallback(() => {
+ if (isShown) {
+ handleHideDialog();
+ } else {
+ handleShowDialog();
+ }
+ }, [handleHideDialog, handleShowDialog, isShown]);
+
+ useEffect(() => {
+ return () => {
+ if (shownTimerRef.current) {
+ clearTimeout(shownTimerRef.current);
+ }
+ };
+ }, [shownTimerRef]);
+
+ return {
+ bottomPanelButtonRef,
+ offsetTop,
+ handleShowDialog,
+ handleHideDialog,
+ isDialogShown: isShown,
+ isDialogMounted: isMounted,
+ handleDialogMenuItemClick,
+ };
+};
diff --git a/src/hooks/use-panel-scroll.ts b/src/hooks/use-panel-scroll.ts
new file mode 100644
index 0000000..e8a1f9c
--- /dev/null
+++ b/src/hooks/use-panel-scroll.ts
@@ -0,0 +1,253 @@
+import {
+ FocusEvent,
+ MouseEvent,
+ RefObject,
+ TouchEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ WheelEvent,
+} from 'react';
+
+import {
+ ANIMATION_DURATION,
+ debounce,
+ Orientation,
+ PREVIEW_HEIGHT,
+ PREVIEW_WIDTH,
+ SPACE,
+} from '../utils';
+
+interface Props {
+ orientation: Orientation;
+ itemsCount: number;
+ panel: RefObject;
+ isShown: boolean;
+}
+
+type panelScrollType = {
+ panelProps: {
+ onMouseUp: (e: MouseEvent) => void;
+ onMouseDown: (e: MouseEvent) => void;
+ onMouseMove: (e: MouseEvent) => void;
+ onMouseLeave: (e: MouseEvent) => void;
+ onTouchStart: (e: TouchEvent) => void;
+ onTouchMove: (e: TouchEvent) => void;
+ onTouchEnd: (e: TouchEvent) => void;
+ onWheel: (e: WheelEvent) => void;
+ onBlur: (e: FocusEvent) => void;
+ ref: RefObject;
+ tabIndex: number;
+ };
+};
+
+const calculateOverflow = (isBottom: boolean, itemsCount: number) => {
+ const { innerWidth, innerHeight } = window;
+ const windowSize = isBottom ? innerWidth : innerHeight;
+ const containerSize = itemsCount * ((isBottom ? PREVIEW_WIDTH : PREVIEW_HEIGHT) + 15);
+ if (!(containerSize > windowSize)) {
+ return 0;
+ }
+ return Math.abs(containerSize - windowSize);
+};
+
+/* eslint-disable no-param-reassign */
+export const usePanelScroll = ({
+ orientation,
+ itemsCount,
+ panel,
+ isShown,
+}: Props): panelScrollType => {
+ const ref = useRef(null);
+ const [isDrag, setDrag] = useState(false);
+ const [trackPosition, setTrackPosition] = useState(0);
+ const [position, setPosition] = useState(0);
+ const [lastPosition, setLastPosition] = useState(0);
+ const [overflow, setOverflow] = useState(0);
+
+ const isBottom = useMemo(() => orientation === Orientation.bottom, [orientation]);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ if (ref.current && isShown) {
+ ref.current.focus();
+ }
+ }, ANIMATION_DURATION);
+
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, [ref, isShown]);
+
+ const resetPanel = useCallback(
+ (animate = true) => {
+ if (!panel.current) {
+ return;
+ }
+ if (animate) {
+ panel.current.style.transition = `transform 0.5s`;
+ }
+ panel.current.style.transform = `unset`;
+ setTrackPosition(0);
+ setPosition(0);
+ setLastPosition(0);
+ },
+ [panel],
+ );
+
+ useEffect(() => {
+ let timeout: NodeJS.Timeout;
+ const handleResize = debounce(() => {
+ resetPanel();
+ timeout = setTimeout(() => {
+ if (!panel.current) {
+ return;
+ }
+ panel.current.style.transition = 'unset';
+ }, ANIMATION_DURATION);
+
+ const overflowValue = calculateOverflow(isBottom, itemsCount);
+ setOverflow(overflowValue);
+ }, 100);
+
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [panel, resetPanel, isBottom, itemsCount]);
+
+ useEffect(() => {
+ if (!isShown) {
+ resetPanel(false);
+ }
+ }, [isShown, resetPanel]);
+
+ const changePosition = useCallback(() => {
+ if (!panel.current) {
+ return;
+ }
+ panel.current.style.transform = `translate${isBottom ? 'X' : 'Y'}(${-position}px)`;
+ }, [isBottom, panel, position]);
+
+ const limiter = useCallback(
+ (value: number, isWheel = false) => {
+ let diff = (!isWheel ? trackPosition : 0) - value + lastPosition;
+ if (diff > overflow + SPACE) {
+ diff = overflow + SPACE;
+ } else if (diff < 0) {
+ diff = 0;
+ }
+ return diff;
+ },
+ [lastPosition, trackPosition, overflow],
+ );
+
+ const handleFree = useCallback(
+ (e: MouseEvent | FocusEvent | TouchEvent) => {
+ e.nativeEvent.stopImmediatePropagation();
+ setDrag(false);
+ setLastPosition(position);
+ },
+ [position],
+ );
+
+ const onMouseUp = useCallback((e: MouseEvent) => handleFree(e), [handleFree]);
+
+ const onMouseDown = useCallback(
+ (e: MouseEvent) => {
+ const { clientX, clientY } = e;
+ e.nativeEvent.stopImmediatePropagation();
+ setTrackPosition(isBottom ? clientX : clientY);
+ setDrag(true);
+ },
+ [isBottom],
+ );
+
+ const onMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (!isDrag) {
+ return;
+ }
+ if (!overflow) {
+ return;
+ }
+
+ const { clientX, clientY } = e;
+ const value = isBottom ? clientX : clientY;
+ const diff = limiter(value);
+ setPosition(diff);
+ changePosition();
+ },
+ [changePosition, isBottom, isDrag, limiter, overflow],
+ );
+
+ const onMouseLeave = useCallback((e: MouseEvent) => handleFree(e), [handleFree]);
+
+ const onTouchStart = useCallback(
+ (e: TouchEvent) => {
+ const { touches } = e;
+ e.nativeEvent.stopImmediatePropagation();
+ setTrackPosition(isBottom ? touches[0].clientX : touches[0].clientY);
+ setDrag(true);
+ },
+ [isBottom],
+ );
+
+ const onTouchMove = useCallback(
+ (e: TouchEvent) => {
+ const { touches } = e;
+ const { clientX, clientY } = touches[0];
+ if (!overflow) {
+ return;
+ }
+
+ const value = isBottom ? clientX : clientY;
+ const diff = limiter(value);
+ setPosition(diff);
+ changePosition();
+ },
+ [changePosition, isBottom, limiter, overflow],
+ );
+
+ const onTouchEnd = useCallback((e: TouchEvent) => handleFree(e), [handleFree]);
+
+ const onWheel = useCallback(
+ (e: WheelEvent) => {
+ const { deltaY } = e;
+ if (!overflow) {
+ return;
+ }
+
+ const value = deltaY > 0 ? -80 : 80;
+ const diff = limiter(value, true);
+ setPosition(diff);
+ changePosition();
+ setLastPosition(position);
+ },
+ [changePosition, limiter, position, overflow],
+ );
+
+ const onBlur = useCallback((e: FocusEvent) => handleFree(e), [handleFree]);
+
+ return {
+ panelProps: {
+ onMouseUp,
+ onMouseDown,
+ onMouseMove,
+ onMouseLeave,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onWheel,
+ onBlur,
+ ref,
+ tabIndex: isShown ? 0 : -1,
+ },
+ };
+};
diff --git a/src/hooks/use-place-view.ts b/src/hooks/use-place-view.ts
new file mode 100644
index 0000000..a308702
--- /dev/null
+++ b/src/hooks/use-place-view.ts
@@ -0,0 +1,138 @@
+import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
+import { useHistory, useParams } from 'react-router-dom';
+
+import Sound from '../modules/sound';
+import { useSettings } from './use-settings';
+import places from '../assets';
+import { delay } from '../utils';
+
+type placeViewType = {
+ isLoading: boolean;
+ activePlace: number;
+ activeView: number;
+ isLeftPanelShown: boolean;
+ isBottomPanelShown: boolean;
+ hideLeftPanel: () => void;
+ hideBottomPanel: () => void;
+ onLeftPanelClick: (value: number) => void;
+ onBottomPanelClick: (value: number) => void;
+ closePanels: () => void;
+};
+
+interface Props {
+ panelOpenSound: Sound;
+ panelCloseSound: Sound;
+}
+
+export const usePlaceView = ({ panelOpenSound, panelCloseSound }: Props): placeViewType => {
+ const {
+ settings: { language, uiSound },
+ } = useSettings();
+
+ const { placeName, viewNumber } = useParams();
+ const { currentPlace, currentView } = useMemo(() => {
+ let placeIndex = places.findIndex((place) => place.name === placeName);
+ placeIndex = placeIndex === -1 ? 0 : placeIndex;
+ let viewIndex = Number(viewNumber) || 0;
+ viewIndex =
+ places[placeIndex].view.length > viewIndex
+ ? viewIndex
+ : places[placeIndex].view.length - 1;
+ return {
+ currentPlace: placeIndex,
+ currentView: viewIndex,
+ };
+ }, [placeName, viewNumber]);
+
+ const history = useHistory();
+
+ const [isLoading, setLoading] = useState(false);
+ const [isLeftPanelShown, setLeftPanelShown] = useState(false);
+ const [isBottomPanelShown, setBottomPanelShown] = useState(false);
+ const [activePlace, setActivePlace] = useState(currentPlace);
+ const [activeView, setActiveView] = useState(currentView);
+
+ const handleUiSoundPanels = useCallback(
+ (isOpponentOpened: boolean, currentPanel: boolean) => {
+ if (!uiSound) {
+ return;
+ }
+
+ if (isOpponentOpened) {
+ panelCloseSound.playSound();
+ }
+
+ if (currentPanel) {
+ panelCloseSound.playSound();
+ } else {
+ panelOpenSound.playSound();
+ }
+ },
+ [panelCloseSound, panelOpenSound, uiSound],
+ );
+
+ const hideLeftPanel = useCallback(() => {
+ handleUiSoundPanels(isBottomPanelShown, isLeftPanelShown);
+ setBottomPanelShown(false);
+ setLeftPanelShown(!isLeftPanelShown);
+ }, [handleUiSoundPanels, isBottomPanelShown, isLeftPanelShown]);
+
+ const hideBottomPanel = useCallback(() => {
+ handleUiSoundPanels(isLeftPanelShown, isBottomPanelShown);
+ setLeftPanelShown(false);
+ setBottomPanelShown(!isBottomPanelShown);
+ }, [handleUiSoundPanels, isBottomPanelShown, isLeftPanelShown]);
+
+ const delayedChange = useCallback(
+ (fn: (value: number) => void, value: number) => {
+ if (isLoading) {
+ return;
+ }
+ fn(value);
+ setLoading(true);
+ delay().then(() => {
+ setLoading(false);
+ });
+ },
+ [isLoading],
+ );
+
+ const onLeftPanelClick = useCallback(
+ (value: number) => {
+ delayedChange(setActivePlace, value);
+ setActiveView(0);
+ },
+ [delayedChange],
+ );
+
+ const onBottomPanelClick = useCallback(
+ (value: number) => {
+ delayedChange(setActiveView, value);
+ },
+ [delayedChange],
+ );
+
+ useLayoutEffect(() => {
+ history.push(`/${places[activePlace].name}/${activeView}`);
+ document.title = language[`place.${places[activePlace].name}` as keyof typeof language];
+ }, [activePlace, activeView, language, history]);
+
+ const closePanels = useCallback(() => {
+ handleUiSoundPanels(false, isLeftPanelShown || isBottomPanelShown);
+ setLeftPanelShown(false);
+ setBottomPanelShown(false);
+ }, [handleUiSoundPanels, isBottomPanelShown, isLeftPanelShown]);
+
+ return {
+ isLoading,
+ activePlace,
+ activeView,
+ isLeftPanelShown,
+ isBottomPanelShown,
+ hideLeftPanel,
+ hideBottomPanel,
+ onLeftPanelClick,
+ onBottomPanelClick,
+ closePanels,
+ };
+};
diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts
index 7938e3e..a13e93e 100644
--- a/src/hooks/use-settings.ts
+++ b/src/hooks/use-settings.ts
@@ -1,5 +1,4 @@
-import { useContext } from "react";
-import SettingsContext, { SettingsContextType } from "../settings-context";
+import { useContext } from 'react';
+import SettingsContext, { SettingsContextType } from '../settings-context';
-export const useSettings = (): SettingsContextType =>
- useContext(SettingsContext);
+export const useSettings = (): SettingsContextType => useContext(SettingsContext);
diff --git a/src/hooks/use-ui-sound.ts b/src/hooks/use-ui-sound.ts
new file mode 100644
index 0000000..e16fb40
--- /dev/null
+++ b/src/hooks/use-ui-sound.ts
@@ -0,0 +1,40 @@
+import { useMemo } from 'react';
+
+import Sound from '../modules/sound';
+import { soundLoad, UI_SOUND_VOLUME } from '../utils';
+
+import PanelOpenAudio from '../assets/audio/panel-open.ogg';
+import PanelCloseAudio from '../assets/audio/panel-close.ogg';
+
+import SettingsOpenAudio from '../assets/audio/menu-open.ogg';
+import SettingsCloseAudio from '../assets/audio/menu-close.ogg';
+
+import CheckBoxOnAudio from '../assets/audio/check-box-on.ogg';
+import CheckBoxOffAudio from '../assets/audio/check-box-off.ogg';
+
+type uiSoundType = {
+ panelOpenSound: Sound;
+ panelCloseSound: Sound;
+ settingsOpenSound: Sound;
+ settingsCloseSound: Sound;
+ checkboxOnSound: Sound;
+ checkboxOffSound: Sound;
+};
+
+export const useUiSound = (): uiSoundType => {
+ const panelOpenSound = useMemo(() => soundLoad(PanelOpenAudio, UI_SOUND_VOLUME), []);
+ const panelCloseSound = useMemo(() => soundLoad(PanelCloseAudio, UI_SOUND_VOLUME), []);
+ const settingsOpenSound = useMemo(() => soundLoad(SettingsOpenAudio, UI_SOUND_VOLUME), []);
+ const settingsCloseSound = useMemo(() => soundLoad(SettingsCloseAudio, UI_SOUND_VOLUME), []);
+ const checkboxOnSound = useMemo(() => soundLoad(CheckBoxOnAudio, UI_SOUND_VOLUME), []);
+ const checkboxOffSound = useMemo(() => soundLoad(CheckBoxOffAudio, UI_SOUND_VOLUME), []);
+
+ return {
+ panelOpenSound,
+ panelCloseSound,
+ settingsOpenSound,
+ settingsCloseSound,
+ checkboxOnSound,
+ checkboxOffSound,
+ };
+};
diff --git a/src/hooks/use-view-scroll.ts b/src/hooks/use-view-scroll.ts
new file mode 100644
index 0000000..4993ebb
--- /dev/null
+++ b/src/hooks/use-view-scroll.ts
@@ -0,0 +1,177 @@
+import {
+ FocusEvent,
+ MouseEvent,
+ TouchEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+
+import { debounce, DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../utils';
+
+interface Position {
+ x: number;
+ y: number;
+}
+
+const initialPosition: Position = {
+ x: 0,
+ y: 0,
+};
+
+type ViewScrollType = {
+ style: {
+ backgroundPosition: string;
+ backgroundSize: string;
+ };
+ props: {
+ onMouseMove: (event: MouseEvent) => void;
+ onMouseDown: (event: MouseEvent) => void;
+ onMouseUp: (event: MouseEvent) => void;
+ onMouseLeave: (event: MouseEvent) => void;
+ onTouchStart: (event: TouchEvent) => void;
+ onTouchMove: (event: TouchEvent) => void;
+ onTouchEnd: (event: TouchEvent) => void;
+ onBlur: (event: FocusEvent) => void;
+ };
+};
+
+export const useViewScroll = (): ViewScrollType => {
+ const [isDrag, setDrag] = useState(false);
+ const [trackPosition, setTrackPosition] = useState(initialPosition);
+ const [position, setPosition] = useState(initialPosition);
+ const [lastPosition, setLastPosition] = useState(initialPosition);
+ const [isBigScreen, setBigScreen] = useState(false);
+
+ const handleResize = useMemo(
+ () =>
+ debounce(() => {
+ const { innerWidth, innerHeight } = window;
+
+ let width = 0;
+ let height = 0;
+ setBigScreen(false);
+
+ if (innerHeight < DEFAULT_HEIGHT && innerWidth < DEFAULT_WIDTH) {
+ width = (innerWidth - DEFAULT_WIDTH) / 2;
+ height = (innerHeight - DEFAULT_HEIGHT) / 2;
+ } else {
+ setBigScreen(true);
+ }
+ setPosition({ x: width, y: height });
+ setLastPosition({ x: width, y: height });
+ }, 100),
+ [],
+ );
+
+ useEffect(() => {
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [handleResize]);
+
+ const limiter = useCallback(
+ (value: Position, width: number, height: number) => {
+ const { x: xValue, y: yValue } = value;
+
+ let x = lastPosition.x - trackPosition.x + xValue;
+ let y = lastPosition.y - trackPosition.y + yValue;
+
+ if (x > 0) {
+ x = 0;
+ } else if (x < -width) {
+ x = -width;
+ }
+
+ if (y > 0) {
+ y = 0;
+ } else if (y < -height) {
+ y = -height;
+ }
+
+ return { x, y };
+ },
+ [lastPosition.x, lastPosition.y, trackPosition.x, trackPosition.y],
+ );
+
+ const onTouchMove = useCallback(
+ (e: TouchEvent) => {
+ const { touches } = e;
+ if (isBigScreen) {
+ return;
+ }
+ const { innerWidth, innerHeight } = window;
+ const width = DEFAULT_WIDTH - innerWidth;
+ const height = DEFAULT_HEIGHT - innerHeight;
+ const { clientX: x, clientY: y } = touches[0];
+
+ const diff = limiter({ x, y }, width, height);
+
+ setPosition(diff);
+ },
+ [isBigScreen, limiter],
+ );
+
+ const onTouchStart = useCallback((e: TouchEvent) => {
+ const { touches } = e;
+ e.nativeEvent.stopImmediatePropagation();
+ const { clientX: x, clientY: y } = touches[0];
+ setTrackPosition({ x, y });
+ setDrag(true);
+ }, []);
+
+ const onMouseDown = useCallback((e: MouseEvent) => {
+ const { clientX, clientY } = e;
+ e.nativeEvent.stopImmediatePropagation();
+ setTrackPosition({ x: clientX, y: clientY });
+ setDrag(true);
+ }, []);
+
+ const handleFree = useCallback(
+ (e: MouseEvent | FocusEvent | TouchEvent) => {
+ e.nativeEvent.stopImmediatePropagation();
+ setDrag(false);
+ setLastPosition(position);
+ },
+ [position],
+ );
+
+ const onMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (!isDrag) {
+ return;
+ }
+ if (isBigScreen) {
+ return;
+ }
+ const { innerWidth, innerHeight } = window;
+ const width = DEFAULT_WIDTH - innerWidth;
+ const height = DEFAULT_HEIGHT - innerHeight;
+
+ const { clientX: x, clientY: y } = e;
+ const diff = limiter({ x, y }, width, height);
+ setPosition(diff);
+ },
+ [isBigScreen, isDrag, limiter],
+ );
+
+ return {
+ style: {
+ backgroundPosition: `${position.x}px ${position.y}px`,
+ backgroundSize: `${isBigScreen ? 'cover' : 'auto'}`,
+ },
+ props: {
+ onMouseMove,
+ onMouseDown,
+ onTouchStart,
+ onTouchMove,
+ onMouseUp: handleFree,
+ onTouchEnd: handleFree,
+ onMouseLeave: handleFree,
+ onBlur: handleFree,
+ },
+ };
+};
diff --git a/src/index.tsx b/src/index.tsx
index c9394c8..7d1112e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,23 +1,27 @@
-import * as React from "react";
-import ReactDom from "react-dom";
+import * as React from 'react';
+import ReactDom from 'react-dom';
+import { BrowserRouter as Router, Redirect, Route, Switch } from 'react-router-dom';
-import { Settings, SettingsProvider } from "./settings-context";
-import App from "./app";
+import { SettingsProvider } from './settings-context';
+import { bootstrapSettings, DEFAULT_PLACE, PATH_PLACES } from './utils';
+import { App, NotFound } from './pages';
-import ru from "./locales/ru.json";
-import en from "./locales/en.json";
+const Root = () => ;
-const defaultSettings: Settings = {
- language: ru,
- musicVolume: 1.0,
- currentLanguage: ru["ui.language"],
- uiLanguage: [ru["ui.language"], en["ui.language"]],
- uiSound: true,
+const Init: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ );
};
-ReactDom.render(
-
-
- ,
- document.getElementById("root")
-);
+Init.displayName = 'Init';
+
+ReactDom.render(, document.getElementById('root'));
diff --git a/src/locales/en.json b/src/locales/en.json
index 2140e41..adc307f 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -3,14 +3,22 @@
"ui.button.views": "Views",
"ui.button.close": "Close",
"ui.main-menu": "Main Menu",
+ "ui.dialog.button": "Okay",
"ui.uiSound": "UI Sound",
"ui.uiLanguage": "Language",
"ui.language": "English",
"ui.musicVolume": "Music",
+ "ui.dialog.welcome.title": "Dinastro EU-Дракономор",
+ "ui.dialog.welcome.text-1": "Welcome in World of Warcraft best places! To choose the place, you need to open the left panel and select a desired place. Every place has different views, you can select them from the bottom panel.",
+ "ui.dialog.welcome.text-2": "On the right corner you can find settings and help button. In the settings you can turn off UI sound and change volume. With help button you can play this dialog again.",
+ "ui.dialog.welcome.text-3": "With space button you can pause current playing, but any click on application will trigger playing.",
"place.stormwind-park": "Stormwind Park",
"place.halls-of-valor": "Halls Of Valor",
"place.pandaria": "Pandaria",
"place.dalaran": "Dalaran",
"place.crystalsong-forest": "Crystalsong forest",
- "place.nagrand": "Nagrand"
+ "place.nagrand": "Nagrand",
+ "place.grizzly-hills": "Grizzly Hills",
+ "place.boralus": "Boralus",
+ "error.404": "Place not found"
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index bf059cd..efcea01 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -3,14 +3,22 @@
"ui.button.views": "Виды",
"ui.button.close": "Закрыть",
"ui.main-menu": "Главное меню",
+ "ui.dialog.button": "Принять",
"ui.uiSound": "Звуки интерфейса",
"ui.uiLanguage": "Язык",
"ui.language": "Русский",
"ui.musicVolume": "Музыка",
+ "ui.dialog.welcome.title": "Динэстро Дракономор",
+ "ui.dialog.welcome.text-1": "Приветствую в лучших местах World of Warcraft! Чтобы выбрать место, необходимо открыть левую панель и выбрать желаемое. Каждое место обладает несколькими видами, их можно выбрать, открыв нижнюю панель.",
+ "ui.dialog.welcome.text-2": "В правом верхнем углу находятся настройки и кнопка помощи. В настройках можно отключить звуки интерфейса и изменить громкость. С помощью кнопки помощи можно вызвать это сообщение повторно.",
+ "ui.dialog.welcome.text-3": "С помощью клавиши пробел можно поставить проигрывание на паузу, однако любой клик по приложению вновь запустит музыку.",
"place.stormwind-park": "Парк Штормграда",
"place.halls-of-valor": "Чертоги Доблести",
"place.pandaria": "Пандария",
"place.dalaran": "Даларан",
"place.crystalsong-forest": "Лес хрустальной песни",
- "place.nagrand": "Награнд"
+ "place.nagrand": "Награнд",
+ "place.grizzly-hills": "Седые холмы",
+ "place.boralus": "Боралус",
+ "error.404": "Место не найдено"
}
diff --git a/src/modules/sound.ts b/src/modules/sound.ts
index 6a7a877..b3b38ac 100644
--- a/src/modules/sound.ts
+++ b/src/modules/sound.ts
@@ -1,49 +1,45 @@
/* eslint-disable no-console */
export default class Sound {
- public audio: HTMLAudioElement;
+ public audio: HTMLAudioElement;
- private volume: number;
+ private volume: number;
- constructor(file: string, volumeValue?: number) {
- const volume = this.validateVolume(volumeValue);
- this.audio = new Audio(file);
- this.audio.load();
- this.volume = volume;
- }
-
- public setVolume = (volume: number): Sound => {
- this.validateVolume(volume);
- this.volume = volume;
-
- return this;
- };
-
- public playSound = (volume: number = this.volume): void => {
- this.audio.volume = this.validateVolume(volume);
- if (!this.audio.readyState) {
- return;
+ constructor(file: string, volumeValue?: number) {
+ const volume = this.validateVolume(volumeValue);
+ this.audio = new Audio(file);
+ this.audio.load();
+ this.volume = volume;
}
- this.audio
- .play()
- .catch((error: Error) => console.error(`Error playback: ${error}`));
- };
- public playMusic = (volume: number = this.volume): void => {
- this.audio.volume = this.validateVolume(volume);
- if (!this.audio.readyState) {
- return;
- }
- this.audio
- .play()
- .catch((error: Error) => console.error(`Error playback: ${error}`));
- };
+ public setVolume = (volume: number): Sound => {
+ this.validateVolume(volume);
+ this.volume = volume;
- public pause = (): void => this.audio.pause();
+ return this;
+ };
- private validateVolume = (volumeValue = 1.0) => {
- if (volumeValue && (volumeValue < 0 || volumeValue > 1)) {
- throw Error('"Volume" must be an number between 0.0 and 1.0');
- }
- return volumeValue;
- };
+ public playSound = (volume: number = this.volume): void => {
+ this.audio.volume = this.validateVolume(volume);
+ if (!this.audio.readyState) {
+ return;
+ }
+ this.audio.play().catch((error: Error) => console.error(`Error playback: ${error}`));
+ };
+
+ public playMusic = (volume: number = this.volume): void => {
+ this.audio.volume = this.validateVolume(volume);
+ if (!this.audio.readyState) {
+ return;
+ }
+ this.audio.play().catch((error: Error) => console.error(`Error playback: ${error}`));
+ };
+
+ public pause = (): void => this.audio.pause();
+
+ private validateVolume = (volumeValue = 1.0) => {
+ if (volumeValue && (volumeValue < 0 || volumeValue > 1)) {
+ throw Error('"Volume" must be an number between 0.0 and 1.0');
+ }
+ return volumeValue;
+ };
}
diff --git a/src/pages/app/index.tsx b/src/pages/app/index.tsx
new file mode 100644
index 0000000..006ef21
--- /dev/null
+++ b/src/pages/app/index.tsx
@@ -0,0 +1,229 @@
+import * as React from 'react';
+import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ DialogModal,
+ MainMenuComponent,
+ MenuItemComponent,
+ MusicComponent,
+ PanelComponent,
+ PreviewComponent,
+ SettingsComponent,
+ ViewComponent,
+} from '../../components';
+import places from '../../assets';
+import { author, authorUrl, Orientation } from '../../utils';
+import { useDialog, usePlaceView, useSettings, useUiSound } from '../../hooks';
+import Sound from '../../modules/sound';
+
+import './style.scss';
+import { MenuItems } from '../../components/menu-item-component/menu-item-component';
+
+export const App: React.FC = () => {
+ const {
+ settings: { uiSound, musicVolume, language },
+ } = useSettings();
+
+ const [isSettingsShown, setSettingsShown] = useState(false);
+ const [isPlaying, setPlaying] = useState(false);
+
+ const {
+ panelOpenSound,
+ panelCloseSound,
+ settingsOpenSound,
+ settingsCloseSound,
+ checkboxOnSound,
+ checkboxOffSound,
+ } = useUiSound();
+
+ const [currentPlaying, setCurrentPlaying] = useState();
+ const {
+ isLoading,
+ activePlace,
+ activeView,
+ closePanels,
+ hideBottomPanel,
+ hideLeftPanel,
+ isBottomPanelShown,
+ isLeftPanelShown,
+ onBottomPanelClick,
+ onLeftPanelClick,
+ } = usePlaceView({ panelOpenSound, panelCloseSound });
+
+ const app = useRef(null);
+ const dialogText = [
+ language['ui.dialog.welcome.text-1'],
+ language['ui.dialog.welcome.text-2'],
+ language['ui.dialog.welcome.text-3'],
+ ];
+ const {
+ bottomPanelButtonRef,
+ offsetTop,
+ handleShowDialog,
+ handleHideDialog,
+ handleDialogMenuItemClick,
+ isDialogShown,
+ isDialogMounted,
+ } = useDialog({
+ isBottomPanelShown,
+ stepsCount: dialogText.length,
+ });
+
+ useEffect(() => {
+ if (app.current) {
+ app.current.focus();
+ }
+ handleShowDialog();
+ }, [app, handleShowDialog]);
+
+ useEffect(() => {
+ if (!currentPlaying) {
+ return;
+ }
+ currentPlaying.setVolume(musicVolume);
+ currentPlaying.playMusic();
+ }, [currentPlaying, musicVolume]);
+
+ const appClick = useCallback(() => {
+ if (currentPlaying) {
+ currentPlaying.playMusic();
+ }
+ }, [currentPlaying]);
+
+ const openCloseSettings = useCallback(() => {
+ setSettingsShown(!isSettingsShown);
+ if (app.current) {
+ app.current.focus();
+ }
+ if (!uiSound) {
+ return;
+ }
+ if (isSettingsShown) {
+ settingsCloseSound.playSound();
+ } else {
+ settingsOpenSound.playSound();
+ }
+ }, [app, isSettingsShown, uiSound, settingsCloseSound, settingsOpenSound]);
+
+ const handleOpenSettings = useCallback(
+ (e: KeyboardEvent) => {
+ switch (e.keyCode) {
+ case 27:
+ if (isLeftPanelShown || isBottomPanelShown) {
+ closePanels();
+ break;
+ }
+ openCloseSettings();
+ break;
+ case 32:
+ // TODO: ADD STATUS
+ if (!currentPlaying) {
+ return;
+ }
+ if (isPlaying) {
+ currentPlaying.pause();
+ setPlaying(false);
+ } else {
+ currentPlaying.playMusic();
+ }
+ break;
+ default:
+ break;
+ }
+ },
+ [
+ isLeftPanelShown,
+ isBottomPanelShown,
+ openCloseSettings,
+ currentPlaying,
+ isPlaying,
+ closePanels,
+ ],
+ );
+
+ // TODO: author focus scss round
+ return (
+
+
+
+
+
{author}
+
{`v${process.env.REACT_APP_VERSION}`}
+
+
+
+
+
+ {places.map((place, index) => (
+
+ ))}
+
+
+ {places[activePlace].preview.map((preview, index) => (
+
+ ))}
+
+ {isSettingsShown && (
+
+ )}
+
+ {isDialogMounted && (
+
+ )}
+
+ );
+};
+
+App.displayName = 'App';
diff --git a/src/app/style.scss b/src/pages/app/style.scss
similarity index 97%
rename from src/app/style.scss
rename to src/pages/app/style.scss
index 0272d91..8aecb17 100644
--- a/src/app/style.scss
+++ b/src/pages/app/style.scss
@@ -1,10 +1,11 @@
-@import "../assets/assets";
+@import "../../assets/assets";
$hoverColor: rgba(173, 154, 32, .75);
$hoverBox: 0 0 4px 2px $hoverColor;
$previewHeight: 180px;
$previewWidth: 320px;
$font: "Arial Narrow";
+$fontMorpheus: "Morpheus";
$fontSpacing: 1.2px;
$fontSize: 14px;
//$fontShadow: 2px 2px 2px #000;
diff --git a/src/pages/index.ts b/src/pages/index.ts
new file mode 100644
index 0000000..e150192
--- /dev/null
+++ b/src/pages/index.ts
@@ -0,0 +1,2 @@
+export { NotFound } from './not-found';
+export { App } from './app';
diff --git a/src/pages/not-found/index.tsx b/src/pages/not-found/index.tsx
new file mode 100644
index 0000000..bf17f12
--- /dev/null
+++ b/src/pages/not-found/index.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+import { useCallback } from 'react';
+
+import { useSettings } from '../../hooks';
+import { DialogBox } from '../../components/dialog-box';
+import PandarenVideo from '../../assets/pandaren.mp4';
+import { DEFAULT_PLACE } from '../../utils';
+
+import './style.scss';
+
+export const NotFound: React.FC = () => {
+ const {
+ settings: { language },
+ } = useSettings();
+
+ const handleClick = useCallback(() => {
+ const { origin } = window.location;
+ window.location.replace(`${origin}/${DEFAULT_PLACE}`);
+ }, []);
+
+ return (
+ <>
+
+
+
+
+ {language['error.404']}
+ >
+ );
+};
+
+NotFound.displayName = 'NotFound';
diff --git a/src/pages/not-found/style.scss b/src/pages/not-found/style.scss
new file mode 100644
index 0000000..c0450c9
--- /dev/null
+++ b/src/pages/not-found/style.scss
@@ -0,0 +1,25 @@
+$black: #000;
+
+.container {
+ z-index: 8;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100vw;
+ height: 100vh;
+ background-color: $black;
+
+ &-video {
+ z-index: 9;
+ width: auto;
+ height: 100%;
+
+ &-fallback {
+ position: absolute;
+ z-index: 8;
+ width: 100vw;
+ height: auto;
+ opacity: .3;
+ }
+ }
+}
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
index 32d064d..a2b005a 100644
--- a/src/react-app-env.d.ts
+++ b/src/react-app-env.d.ts
@@ -1,3 +1,4 @@
///
-declare module "*.ogg";
-declare module "*.mp3";
+declare module '*.ogg';
+declare module '*.mp3';
+declare module '*.mp4';
diff --git a/src/settings-context.tsx b/src/settings-context.tsx
index 7223658..956895a 100644
--- a/src/settings-context.tsx
+++ b/src/settings-context.tsx
@@ -1,57 +1,50 @@
-import * as React from "react";
-import { createContext, useState } from "react";
+import * as React from 'react';
+import { createContext, useState } from 'react';
-import ru from "./locales/ru.json";
-import en from "./locales/en.json";
+import ru from './locales/ru.json';
+import en from './locales/en.json';
export interface Settings {
- language: typeof ru | typeof en;
- musicVolume: number;
- currentLanguage: string;
- uiLanguage: string[];
- uiSound: boolean;
+ language: typeof ru | typeof en;
+ musicVolume: number;
+ currentLanguage: string;
+ uiLanguage: string[];
+ uiSound: boolean;
}
export interface SettingsContextType {
- settings: Settings;
- saveSettings?: (value: Settings) => void;
+ settings: Settings;
+ saveSettings?: (value: Settings) => void;
}
interface Props {
- children: React.ReactNode;
- settings: Settings;
+ children: React.ReactNode;
+ settings: Settings;
}
const defaultSettings: SettingsContextType = {
- settings: {
- language: ru,
- musicVolume: 1.0,
- currentLanguage: ru["ui.language"],
- uiLanguage: [ru["ui.language"], en["ui.language"]],
- uiSound: true,
- },
+ settings: {
+ language: ru,
+ musicVolume: 1.0,
+ currentLanguage: ru['ui.language'],
+ uiLanguage: [ru['ui.language'], en['ui.language']],
+ uiSound: true,
+ },
};
const SettingsContext = createContext(defaultSettings);
-export const SettingsProvider: React.FC = ({
- children,
- settings,
-}: Props) => {
- const [currentSettings, setCurrentSettings] = useState(
- settings || defaultSettings
- );
+export const SettingsProvider: React.FC = ({ children, settings }: Props) => {
+ const [currentSettings, setCurrentSettings] = useState(settings || defaultSettings);
- const saveSettings = (value: Settings) => {
- setCurrentSettings(value);
- };
+ const saveSettings = (value: Settings) => {
+ setCurrentSettings(value);
+ };
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
};
export default SettingsContext;
diff --git a/src/utils/bootstrap-settings.ts b/src/utils/bootstrap-settings.ts
new file mode 100644
index 0000000..700f898
--- /dev/null
+++ b/src/utils/bootstrap-settings.ts
@@ -0,0 +1,41 @@
+import { Settings } from '../settings-context';
+import en from '../locales/en.json';
+import ru from '../locales/ru.json';
+
+export function bootstrapSettings(): Settings {
+ const initialSettings: Settings = {
+ language: en,
+ musicVolume: 1.0,
+ currentLanguage: en['ui.language'],
+ uiLanguage: [en['ui.language'], ru['ui.language']],
+ uiSound: true,
+ };
+ const languageString =
+ (navigator.languages && navigator.languages[0]) ||
+ navigator.language ||
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ navigator.userLanguage;
+
+ const language = languageString.match(/\w*/i);
+ if (!language || !language.length) {
+ return initialSettings;
+ }
+
+ switch (language[0]) {
+ case 'ru':
+ return {
+ ...initialSettings,
+ language: ru,
+ currentLanguage: ru['ui.language'],
+ };
+ case 'en':
+ return {
+ ...initialSettings,
+ language: en,
+ currentLanguage: en['ui.language'],
+ };
+ default:
+ return initialSettings;
+ }
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 477169e..f5124e8 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -1,3 +1,5 @@
+import defaultPlace from '../assets/stormwind-park';
+
export const ANIMATION_DURATION = 500;
export const LOADING_DURATION = 800;
export const PREVIEW_WIDTH = 320;
@@ -7,3 +9,11 @@ export const UI_MUSIC_VOLUME = 1;
export const SPACE = 200;
export const DEFAULT_WIDTH = 1920;
export const DEFAULT_HEIGHT = 1080;
+
+export const DIALOG_STEP_DURATION = 7000;
+
+export const DEFAULT_PLACE = `${defaultPlace.name}/0`;
+export const PATH_PLACES = '/:placeName/:viewNumber';
+
+export const author = 'obergodmar';
+export const authorUrl = 'https://github.com/obergodmar';
diff --git a/src/utils/index.ts b/src/utils/index.ts
index eda5447..b38f675 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,34 +1,27 @@
-import { LOADING_DURATION } from "./constants";
-import Sound from "../modules/sound";
+import { LOADING_DURATION } from './constants';
+import Sound from '../modules/sound';
-export {
- PREVIEW_WIDTH,
- PREVIEW_HEIGHT,
- UI_MUSIC_VOLUME,
- UI_SOUND_VOLUME,
- LOADING_DURATION,
- ANIMATION_DURATION,
- DEFAULT_HEIGHT,
- DEFAULT_WIDTH,
- SPACE,
-} from "./constants";
+export * from './constants';
+export * from './types';
+
+export { bootstrapSettings } from './bootstrap-settings';
export const delay = (): Promise =>
- new Promise((resolve) => setTimeout(resolve, LOADING_DURATION));
+ new Promise((resolve) => setTimeout(resolve, LOADING_DURATION));
export const soundLoad = (soundFile: string, soundVolume: number): Sound =>
- new Sound(soundFile, soundVolume);
+ new Sound(soundFile, soundVolume);
export const randomNumber = (min: number, max: number): number =>
- Math.floor(Math.random() * (max - min)) + min;
+ Math.floor(Math.random() * (max - min)) + min;
// eslint-disable-next-line
export function debounce(fn: (args: any) => unknown, ms: number): any {
- let timer: NodeJS.Timeout;
- return (...args: [unknown]) => {
- if (timer) {
- clearTimeout(timer);
- }
- timer = setTimeout(() => fn.apply(this, args), ms);
- };
+ let timer: NodeJS.Timeout;
+ return (...args: [unknown]) => {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ timer = setTimeout(() => fn.apply(this, args), ms);
+ };
}
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 0000000..718c64c
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,4 @@
+export enum Orientation {
+ bottom = 'bottom',
+ left = 'left',
+}