(null!);
+
+ const { x = 0, y = 30 } = position;
+ const { from = "top", dist = 10 } = entry;
+ const initPosition = calcPosition({ x, y, from, dist });
+
+ const checkClicksOutside = useCallback(
+ (e: MouseEvent) => {
+ if (!(e.target instanceof Node)) return;
+
+ const isOutSideMenu = !refDropdown.current?.contains(e.target);
+ const isOutSideBtn = !refBtn.current?.contains(e.target);
+
+ if (isOutSideMenu && isOutSideBtn) setOpen(false);
+ },
+ [setOpen],
+ );
+
+ useEffect(() => {
+ if (isOpen) document.addEventListener("mousedown", checkClicksOutside);
+ return () =>
+ document.removeEventListener("mousedown", checkClicksOutside);
+ }, [isOpen]);
+
+ const config = { duration: 250 };
+ const transitions = useTransition(isOpen, {
+ from: { opacity: 0, ...initPosition, config },
+ enter: { opacity: 1, x, y, config },
+ leave: { opacity: 0, ...initPosition, config },
+ });
+
+ return (
+ <>
+ setOpen((o) => !o)}>
+ {trigger}
+
+ {transitions(
+ (style, isOpen) =>
+ isOpen && (
+
+ {children}
+
+ ),
+ )}
+ >
+ );
+ },
+);
+
+export * from "./dropdownItem";
+export * from "./toggleButtonItem";
+export { Border } from "./styles";
diff --git a/src/components/dropdownMenu/styles.tsx b/src/components/dropdownMenu/styles.tsx
new file mode 100644
index 0000000..0137565
--- /dev/null
+++ b/src/components/dropdownMenu/styles.tsx
@@ -0,0 +1,38 @@
+import styled from "styled-components";
+import { animated } from "@react-spring/web";
+
+export const MenuButton = styled.div`
+ border: 0;
+ border-radius: 5px;
+ background-color: ${({ theme }) => theme.dropdown.input.bg.normal};
+ transition: 0.3s ease;
+ color: ${({ theme }) => theme.dropdown.input.icon};
+
+ &:hover {
+ background-color: ${({ theme }) => theme.dropdown.input.bg.hover};
+ }
+`;
+
+export type DropdownContainerProps = {
+ width?: number;
+};
+export const DropdownContainer = styled(animated.ol)`
+ box-sizing: border-box;
+ position: absolute;
+ width: ${({ width }) => width ?? 250}px;
+ border: 5px solid ${({ theme }) => theme.dropdown.bg};
+ border-radius: 7px;
+ box-shadow: 0px 3px 6px 2px rgba(0, 0, 0, 0.2);
+ background-color: ${({ theme }) => theme.dropdown.bg};
+ padding: 2px;
+ color: ${({ theme }) => theme.dropdown.text};
+ z-index: 100;
+`;
+
+export const Border = styled.hr`
+ background-color: ${({ theme }) => theme.dropdown.border};
+ height: 1px;
+ border: none;
+ margin: 7px 0px;
+ padding: 0 5px;
+`;
diff --git a/src/components/dropdownMenu/toggleButtonItem/index.tsx b/src/components/dropdownMenu/toggleButtonItem/index.tsx
new file mode 100644
index 0000000..b5ce7a1
--- /dev/null
+++ b/src/components/dropdownMenu/toggleButtonItem/index.tsx
@@ -0,0 +1,59 @@
+import React, {
+ ComponentProps,
+ ReactNode,
+ useCallback,
+ useMemo,
+ useState,
+} from "react";
+import { ToggleButton } from "../../toggleButton";
+import { DropdownItem } from "../dropdownItem";
+import { FlexEnd } from "./styles";
+
+type Contents = {
+ icon?: ReactNode;
+ text?: string;
+};
+
+type Props = {
+ contents: Contents | ((isOn: boolean) => Contents);
+ children?: ReactNode | ((isOn: boolean) => ReactNode);
+} & ComponentProps;
+
+export const ToggleButtonItem: React.FC = ({
+ contents: _contents,
+ children: _children,
+ onChange: _onChange,
+ initState = false,
+ disabled,
+}) => {
+ const [isOn, setOn] = useState(initState);
+ const { contents, children } = useMemo(
+ () => ({
+ contents: typeof _contents === "function" ? _contents(isOn) : _contents,
+ children: typeof _children === "function" ? _children(isOn) : _children,
+ }),
+ [_contents, isOn],
+ );
+
+ const onChange = useCallback(
+ (isOn: boolean) => {
+ setOn(isOn);
+ _onChange(isOn);
+ },
+ [_onChange],
+ );
+
+ return (
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/src/components/dropdownMenu/toggleButtonItem/styles.tsx b/src/components/dropdownMenu/toggleButtonItem/styles.tsx
new file mode 100644
index 0000000..4032a7c
--- /dev/null
+++ b/src/components/dropdownMenu/toggleButtonItem/styles.tsx
@@ -0,0 +1,8 @@
+import styled from "styled-components";
+
+export const FlexEnd = styled.div`
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx
new file mode 100644
index 0000000..d398fc8
--- /dev/null
+++ b/src/components/header/index.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import logo from "../../logo.png";
+import { SettingMenu } from "../settingMenu";
+import { Container, Icon, Title, TitleText, DropdownWrapper } from "./styles";
+
+export const Header: React.FC = () => {
+ return (
+
+
+
+ Vspo stream schedule
+
+
+
+
+
+ );
+};
diff --git a/src/components/header/styles.tsx b/src/components/header/styles.tsx
new file mode 100644
index 0000000..e919885
--- /dev/null
+++ b/src/components/header/styles.tsx
@@ -0,0 +1,51 @@
+import { breakpointMediaQueries } from "src/configs";
+import styled from "styled-components";
+
+export const Container = styled.div`
+ width: 100%;
+ margin: 25px 0;
+ /* position: sticky;
+ top: 0;
+ left: 0; */
+ display: flex;
+ align-items: center;
+ border-radius: 10px;
+ z-index: 10;
+`;
+
+export const Title = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-left: 40px;
+
+ ${breakpointMediaQueries.tablet`
+ justify-content: start;
+ margin-left: 0px;
+ `}
+`;
+
+export const Icon = styled.img`
+ width: 50px;
+ height: 50px;
+`;
+
+export const TitleText = styled.div`
+ margin-left: 10px;
+ margin-top: 8px;
+ font-size: 28px;
+ font-family: "Itim", cursive;
+ letter-spacing: -0.05em;
+ color: ${({ theme }) => theme.header.text};
+ display: none;
+
+ ${breakpointMediaQueries.tablet`
+ display: block;
+ `}
+`;
+
+export const DropdownWrapper = styled.div`
+ width: 40px;
+ display: flex;
+ justify-content: flex-end;
+`;
diff --git a/src/components/index.ts b/src/components/index.ts
index 3504bb0..784bb04 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,9 +1,7 @@
-export * from "./providers";
-export * from "./card";
-export * from "./buttons";
-
-export * from "./MainContainer";
-export * from "./StreamingTable";
-export * from "./DateBorder";
-export * from "./Header";
-export * from "./Background";
+export * from "./dropdownMenu";
+export * from "./header";
+export * from "./mainContainer";
+export * from "./marquee";
+export * from "./streamCard";
+export * from "./streamGrid";
+export * from "./toggleButton";
diff --git a/src/components/mainContainer/index.tsx b/src/components/mainContainer/index.tsx
new file mode 100644
index 0000000..c78b22e
--- /dev/null
+++ b/src/components/mainContainer/index.tsx
@@ -0,0 +1,127 @@
+import React, {
+ FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Stream } from "types";
+import { Background, Container, DailyStreamContainer } from "./styles";
+import { Header } from "../header";
+import { StreamGrid } from "../streamGrid";
+import { StreamGridHeader } from "../streamGridHeader";
+import { useDisplaySize, useSetting, useVspoStream } from "src/providers";
+import { toYYYYMMDD } from "src/utils";
+
+type DailyStream = {
+ date: string;
+ streams: Stream[];
+};
+
+const getPixel = (style: CSSStyleDeclaration, key: string): number => {
+ return Number(style.getPropertyValue(key).replace("px", ""));
+};
+
+const calcGridProperties = (
+ width: number,
+ cardWidth: number,
+ { gapRange = [20, 80] } = {},
+): {
+ column: number;
+ gap: number;
+ options?: {
+ gapRange: [number, number];
+ };
+} => {
+ const [minGap, maxGap] = gapRange;
+
+ const column = Math.floor((width + minGap) / (cardWidth + minGap));
+ if (column <= 1) return { column, gap: 0 };
+
+ const gap = Math.min((width - cardWidth * column) / (column - 1), maxGap);
+ return { column, gap };
+};
+
+export const MainContainer: FC = () => {
+ const [gridProperties, setGridProperties] = useState<{
+ column: number;
+ gap: number;
+ }>({ column: 0, gap: 0 });
+
+ const displaySize = useDisplaySize();
+ const { isDisplayHistory } = useSetting();
+
+ const streams = useVspoStream();
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = useRef(null!);
+ useEffect(() => {
+ const [cardWidth, gapRange] = displaySize.mobile
+ ? [160, [10, 40]]
+ : [320, [20, 80]];
+
+ const resize = () => {
+ const style = window.getComputedStyle(containerRef.current);
+ const width = getPixel(style, "width");
+ setGridProperties(calcGridProperties(width, cardWidth, { gapRange }));
+ };
+ resize();
+
+ window.addEventListener("resize", resize);
+ return () => window.removeEventListener("resize", resize);
+ }, [displaySize.mobile]);
+
+ const calcStreamGridMinHeight = useCallback(
+ (streamNum: number) => {
+ const [cardHeight, expandSize, gap] = displaySize.mobile
+ ? [90, 30, 20]
+ : [180, 60, 40];
+ const row = Math.ceil(streamNum / gridProperties.column);
+
+ return row * (cardHeight + gap) - gap + expandSize;
+ },
+ [gridProperties.column, displaySize.mobile],
+ );
+
+ const dailyStreams: DailyStream[] = useMemo(() => {
+ const now = Date.now();
+ const isEndedStream = (s: Stream) => s.endAt && s.endAt.getTime() <= now;
+
+ const dailyStreamObj = streams.reduce(
+ (result: Record, stream) => {
+ if (!isDisplayHistory.state && isEndedStream(stream)) return result;
+
+ const dateStr = toYYYYMMDD(stream.startAt);
+
+ if (result[dateStr]) result[dateStr].push(stream);
+ else result[dateStr] = [stream];
+
+ return result;
+ },
+ {},
+ );
+
+ return Object.entries(dailyStreamObj)
+ .sort((a, b) => (a[0] > b[0] ? 1 : -1))
+ .map(([date, streams]) => ({ date, streams }));
+ }, [streams, isDisplayHistory.state]);
+
+ return (
+
+
+
+ {dailyStreams.map(({ date, streams }) => (
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/mainContainer/styles.tsx b/src/components/mainContainer/styles.tsx
new file mode 100644
index 0000000..82acbc8
--- /dev/null
+++ b/src/components/mainContainer/styles.tsx
@@ -0,0 +1,33 @@
+import { breakpointMediaQueries } from "src/configs";
+import styled from "styled-components";
+
+export const Background = styled.div`
+ width: 100vw;
+ height: 100vh;
+ background-color: ${({ theme }) => theme.bg};
+ transition: background-color 0.3s ease;
+`;
+
+export const Container = styled.div`
+ width: 90%;
+ height: 100%;
+ margin: 0 auto;
+ padding: 0 5%;
+ background: rgba(240, 240, 240, 0.03);
+ box-shadow: 0px 0px 4px 4px rgba(0, 0, 0, 0.2);
+ overflow: scroll;
+ scrollbar-width: none;
+ display: flex;
+ flex-direction: column;
+ transition: width 0.3s ease-in-out;
+
+ ${breakpointMediaQueries.desktop`
+ width: 85%;
+ `}
+`;
+
+export const DailyStreamContainer = styled.div`
+ &:last-child {
+ padding-bottom: 30px;
+ }
+`;
diff --git a/src/components/marquee/Marquee.tsx b/src/components/marquee/Marquee.tsx
deleted file mode 100644
index 3f0ad51..0000000
--- a/src/components/marquee/Marquee.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import React, { ReactNode } from "react";
-import { useWindowSize } from "../../hooks";
-import { MarqueeForMobile } from "./MarqueeForMobile";
-import { MarqueeScroll } from "./MarqueeScroll";
-
-type Props = {
- children: ReactNode;
- isAnimate?: boolean;
- speed?: number;
-};
-
-export const Marquee: React.FC = ({
- children,
- isAnimate = false,
- speed = 0.05,
- ...props
-}) => {
- const { isMobile } = useWindowSize();
-
- return isMobile ? (
-
- {children}
-
- ) : (
-
- {children}
-
- );
-};
diff --git a/src/components/marquee/MarqueeForMobile.tsx b/src/components/marquee/MarqueeForMobile.tsx
deleted file mode 100644
index 10a4b69..0000000
--- a/src/components/marquee/MarqueeForMobile.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React, { ReactNode, useEffect, useRef, useState } from "react";
-import styled from "styled-components";
-import { animated, useSpring, useSpringRef } from "@react-spring/web";
-import { useWindowSize } from "../../hooks";
-
-const Container = styled.div`
- width: 100%;
- display: flex;
- overflow: hidden;
- mask-image: linear-gradient(
- to right,
- transparent,
- #fff 5%,
- #fff 95%,
- transparent
- );
-`;
-
-const Item = styled(animated.div)`
- white-space: nowrap;
- padding: 0 20% 0 3%;
-`;
-
-type Props = {
- children: ReactNode;
- isAnimate: boolean;
- speed?: number;
-};
-
-export const MarqueeForMobile: React.FC = ({
- children,
- isAnimate,
- speed = 0.05,
- ...props
-}) => {
- const { isPhoneSize } = useWindowSize();
- const refParent = useRef(null!);
- const refChild = useRef(null!);
- const rect = useRef(null!);
- const [canMarquee, setCanMarquee] = useState(false);
-
- const animation = useSpringRef();
- const transform = useSpring({
- ref: animation,
- from: {
- x: "0%",
- },
- });
-
- useEffect(() => {
- animation.start({
- from: {
- x: "0%",
- },
- immediate: true,
- });
- const parent = refParent.current.getBoundingClientRect();
- const child = refChild.current.getBoundingClientRect();
- rect.current = child;
- setCanMarquee(parent.width < child.width);
- }, [children, isPhoneSize]);
-
- useEffect(() => {
- animation.start({
- from: {
- x: "0%",
- },
- ...(canMarquee && isAnimate
- ? {
- to: {
- x: "-100%",
- },
- reset: true,
- loop: true,
- delay: 1500,
- config: {
- duration: (rect.current.width * 15) / speed,
- },
- }
- : { immediate: true }),
- });
- }, [canMarquee && isAnimate, speed]);
-
- return (
-
- -
- {children}
-
- {canMarquee && - {children}
}
-
- );
-};
diff --git a/src/components/marquee/MarqueeItem.tsx b/src/components/marquee/MarqueeItem.tsx
deleted file mode 100644
index 17b650c..0000000
--- a/src/components/marquee/MarqueeItem.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, { ReactNode, forwardRef, useLayoutEffect, useRef } from "react";
-import styled from "styled-components";
-import { useAnimationFrame, useWindowSize } from "../../hooks";
-import { mergeRefs } from "react-merge-refs";
-
-const Container = styled.div`
- white-space: nowrap;
- padding: 0 20% 0 3%;
-`;
-
-type Props = {
- children: ReactNode;
- isAnimate: boolean;
- speed?: number;
- waitTime?: number;
-};
-
-export const MarqueeItem = forwardRef(
- ({ children, isAnimate, speed = 1, waitTime = 1500 }, forwardedRef) => {
- const { isPhoneSize } = useWindowSize();
- const item = useRef(null!);
- const rect = useRef(null!);
- const start = useRef(null);
- const x = useRef(0);
-
- useLayoutEffect(() => {
- rect.current = item.current.getBoundingClientRect();
- }, [children, isPhoneSize]);
-
- useLayoutEffect(() => {
- item.current.style.transform = `translateX(0)`;
- x.current = 0;
- start.current = null;
- }, [isAnimate]);
-
- useAnimationFrame((timestamp) => {
- if (!(isAnimate && item.current && rect.current)) return;
-
- if (!start.current) start.current = timestamp;
-
- if (timestamp - start.current < waitTime) return;
-
- x.current -= speed;
- if (x.current < -rect.current.width) {
- x.current = 0;
- start.current = null;
- }
-
- item.current.style.transform = `translateX(${
- (x.current / rect.current.width) * 100
- }%)`;
- });
-
- return (
- {children}
- );
- }
-);
diff --git a/src/components/marquee/MarqueeScroll.tsx b/src/components/marquee/MarqueeScroll.tsx
deleted file mode 100644
index 0f005fd..0000000
--- a/src/components/marquee/MarqueeScroll.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React, { ReactNode, useLayoutEffect, useRef, useState } from "react";
-import { MarqueeItem } from "./MarqueeItem";
-import styled from "styled-components";
-import { useWindowSize } from "../../hooks";
-
-const Container = styled.div`
- width: 100%;
- display: flex;
- overflow: hidden;
- mask-image: linear-gradient(
- to right,
- transparent,
- #fff 5%,
- #fff 95%,
- transparent
- );
-`;
-
-type Props = {
- children: ReactNode;
- isAnimate: boolean;
- speed?: number;
-};
-
-export const MarqueeScroll: React.FC = ({
- children,
- isAnimate,
- speed = 1,
- ...props
-}) => {
- const { isPhoneSize } = useWindowSize();
- const parentRef = useRef(null!);
- const childRef = useRef(null!);
- const [canMarquee, setCanMarquee] = useState(false);
-
- useLayoutEffect(() => {
- setCanMarquee(
- parentRef.current.getBoundingClientRect().width <
- childRef.current.getBoundingClientRect().width
- );
- }, [children, isPhoneSize]);
-
- return (
-
-
- {children}
-
- {canMarquee && (
-
- {children}
-
- )}
-
- );
-};
diff --git a/src/components/marquee/index.ts b/src/components/marquee/index.ts
deleted file mode 100644
index 198a0d0..0000000
--- a/src/components/marquee/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { MarqueeScroll } from "./MarqueeScroll";
-export { MarqueeForMobile } from "./MarqueeForMobile";
-export { Marquee } from "./Marquee";
diff --git a/src/components/marquee/index.tsx b/src/components/marquee/index.tsx
new file mode 100644
index 0000000..b03626c
--- /dev/null
+++ b/src/components/marquee/index.tsx
@@ -0,0 +1,31 @@
+import React, { FC, useLayoutEffect, useRef, useState } from "react";
+import { Container, ContainerProps, MarqueeItem } from "./styles";
+
+type Props = {
+ children: string;
+} & ContainerProps;
+
+export const Marquee: FC = ({ children, ...props }: Props) => {
+ const parentRef = useRef(null!);
+ const childRef = useRef(null!);
+ const [canMarquee, setCanMarquee] = useState(false);
+
+ useLayoutEffect(() => {
+ // TODO marquee
+ // setCanMarquee(
+ // parentRef.current.getBoundingClientRect().width <
+ // childRef.current.getBoundingClientRect().width,
+ // );
+ }, [children]);
+
+ return (
+
+
+ {children}
+
+ {canMarquee && (
+ {children}
+ )}
+
+ );
+};
diff --git a/src/components/marquee/styles.tsx b/src/components/marquee/styles.tsx
new file mode 100644
index 0000000..d70b33e
--- /dev/null
+++ b/src/components/marquee/styles.tsx
@@ -0,0 +1,40 @@
+import styled, { css, CSSProperties, keyframes } from "styled-components";
+
+export type ContainerProps = {
+ fontSize?: CSSProperties["fontSize"];
+};
+export const Container = styled.div`
+ display: flex;
+ font-size: ${({ fontSize }) => fontSize ?? "20px"};
+ /* overflow: hidden; */
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #fff 5%,
+ #fff 95%,
+ transparent
+ );
+`;
+
+type MarqueeItemProps = {
+ isActive?: boolean;
+};
+const marqueeAnimation = keyframes`
+ from {
+ transform: translateX(0%);
+ }
+ to {
+ transform: translateX(-100%);
+ }
+`;
+export const MarqueeItem = styled.div`
+ white-space: nowrap;
+ padding: 0 20% 0 0;
+ margin-left: 3%;
+ ${({ isActive }) =>
+ isActive &&
+ css`
+ animation: ${marqueeAnimation} 10s linear infinite;
+ animation-delay: 1.5s;
+ `}
+`;
diff --git a/src/components/providers/ConfigProvider.tsx b/src/components/providers/ConfigProvider.tsx
deleted file mode 100644
index 1b61010..0000000
--- a/src/components/providers/ConfigProvider.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import React, { createContext, createRef } from "react";
-import { ChildrenNode } from "../../types";
-import { useBoolStateCache } from "../../hooks";
-
-type ConfigSetter = {
- setExpandAlways: React.Dispatch>;
- setMarquee: React.Dispatch>;
-};
-
-type Config = {
- scrollContainerRef: React.RefObject;
- isExpandAlways: boolean;
- isMarquee: boolean;
-};
-
-type ContextType = {
- config: Config;
- configSetter: ConfigSetter;
-};
-
-const initState = {
- config: {
- scrollContainerRef: null!,
- isExpandAlways: true,
- isMarquee: true,
- },
- configSetter: {
- setExpandAlways: null!,
- setMarquee: null!,
- },
-};
-
-export const ConfigContext = createContext(initState);
-const scrollContainerRef = createRef();
-
-export const ConfigProvider: React.FC = ({ children }) => {
- const [isExpandAlways, setExpandAlways] = useBoolStateCache(
- "isExpandAlways",
- initState.config.isExpandAlways
- );
-
- const [isMarquee, setMarquee] = useBoolStateCache(
- "isMarquee",
- initState.config.isMarquee
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx
deleted file mode 100644
index 4e5ea84..0000000
--- a/src/components/providers/ThemeProvider.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React, { createContext, useEffect, useMemo, useState } from "react";
-import {
- ChildrenNode,
- ColorTheme,
- ThemeTypes,
- ThemeContextType,
-} from "../../types";
-import themes from "../../theme";
-import { ThemeProvider as StyledThemeProvider } from "styled-components";
-
-const cacheKey = "themeType";
-
-export const ThemeContext = createContext({
- themeType: "light",
- theme: {} as ColorTheme,
- setThemeDark: (isOn) => {},
-});
-
-export const ThemeProvider: React.FC = ({ children }) => {
- const [themeType, setTheme] = useState(
- (localStorage.getItem(cacheKey) as ThemeTypes) ?? "light"
- );
-
- useEffect(() => {
- localStorage.setItem(cacheKey, themeType);
- }, [themeType]);
-
- const context = useMemo(
- () => ({
- themeType,
- theme: themes[themeType],
- setThemeDark: (isOn) => {
- setTheme(isOn ? "dark" : "light");
- },
- }),
- [themeType]
- );
-
- return (
-
-
- {children}
-
-
- );
-};
diff --git a/src/components/providers/VspoStreamingProvider.tsx b/src/components/providers/VspoStreamingProvider.tsx
deleted file mode 100644
index fbfecb8..0000000
--- a/src/components/providers/VspoStreamingProvider.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React, { createContext } from "react";
-import { ChildrenNode, StreamInfo } from "../../types";
-import { useStreamInfo } from "../../hooks";
-
-export const VspoStreamingContext = createContext([]);
-
-export const VspoStreamingProvider: React.FC = ({ children }) => {
- const youtubeStreamsInfo = useStreamInfo("youtube");
- const twitchStreamsInfo = useStreamInfo("twitch");
- const twitCastingStreamsInfo = useStreamInfo("twitCasting");
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/components/providers/WindowSizeProvider.tsx b/src/components/providers/WindowSizeProvider.tsx
deleted file mode 100644
index 0693ec1..0000000
--- a/src/components/providers/WindowSizeProvider.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-///
-import React, {
- createContext,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from "react";
-import { ChildrenNode, WindowSize, ClientType } from "../../types";
-import { breakpoints } from "../../configs";
-
-export const WindowSizeContext = createContext(null!);
-
-export const WindowSizeProvider: React.FC = ({ children }) => {
- const [size, setSize] = useState({ width: 0, height: 0 });
- const [isMobile, setIsMobile] = useState(false);
-
- const handleWindowSizeChange = () => {
- setSize({
- width: window.innerWidth,
- height: window.innerHeight,
- });
- };
- const checkMoble = useCallback(() => {
- if (navigator.userAgentData) {
- return navigator.userAgentData.mobile;
- } else {
- return (
- /android|ipod|ipad|iphone|macintosh/.test(
- navigator.userAgent.toLowerCase()
- ) && "ontouchend" in document
- );
- }
- }, []);
-
- useEffect(() => {
- setIsMobile(checkMoble());
-
- handleWindowSizeChange();
- window.addEventListener("resize", handleWindowSizeChange);
- return () => window.removeEventListener("resize", handleWindowSizeChange);
- }, []);
-
- const clientType = useMemo(
- () => ({
- isMobile,
- isPhoneSize: size.width < breakpoints.values.md,
- isTabletSize: breakpoints.values.md <= size.width,
- isDesktopSize: breakpoints.values.lg <= size.width,
- }),
- [size, isMobile]
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts
deleted file mode 100644
index 6954a10..0000000
--- a/src/components/providers/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { ThemeProvider } from "./ThemeProvider";
-export { VspoStreamingProvider } from "./VspoStreamingProvider";
-export { WindowSizeProvider } from "./WindowSizeProvider";
-export { ConfigProvider } from "./ConfigProvider";
diff --git a/src/components/settingMenu/MenuItem.tsx b/src/components/settingMenu/MenuItem.tsx
deleted file mode 100644
index e6304fe..0000000
--- a/src/components/settingMenu/MenuItem.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React, { CSSProperties, ReactNode } from "react";
-import styled, { css } from "styled-components";
-
-const Item = styled.li<{ hoverable: boolean }>`
- cursor: pointer;
- list-style: none;
- display: flex;
- transition: background-color 0.3s ease;
- padding: 7px 14px;
- border-radius: 7px;
-
- ${(p) =>
- p.hoverable &&
- css`
- &:hover {
- background-color: ${(p) => p.theme.hoverd.secondary};
- }
- `}
-`;
-
-const IconContainer = styled.div`
- width: 20px;
- display: flex;
- align-items: center;
- margin-right: 5px;
-`;
-
-const ItemText = styled.span``;
-
-type Props = {
- children?: ReactNode;
- icon?: ReactNode;
- text?: string;
- onClick?: () => void;
- style?: CSSProperties;
-};
-
-export const MenuItem: React.FC = ({
- children,
- icon,
- text,
- onClick,
- style,
-}) => {
- return (
- -
- {icon && {icon}}
- {text && {text}}
- {children}
-
- );
-};
diff --git a/src/components/settingMenu/SettingMenu.tsx b/src/components/settingMenu/SettingMenu.tsx
deleted file mode 100644
index b4e5fe8..0000000
--- a/src/components/settingMenu/SettingMenu.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { MenuItem } from "./MenuItem";
-import { ToggleButtonItem } from "./ToggleButtonItem";
-import { animated, useTransition } from "@react-spring/web";
-import styled from "styled-components";
-import { useConfig, useTheme, useWindowSize } from "../../hooks";
-
-import { BiMenu, BiExpandAlt } from "react-icons/bi";
-import { FaGithub } from "react-icons/fa";
-import { TbMoonFilled } from "react-icons/tb";
-import { TbMarquee2 } from "react-icons/tb";
-
-const MenuButton = styled(animated.button)`
- border: 0;
- border-radius: 5px;
- background-color: transparent;
- transition: 0.3s ease;
- color: ${(p) => p.theme.text.primary};
-
- &:hover {
- background-color: ${(p) => p.theme.hoverd.primary};
- }
-
- &:active {
- background-color: ${(p) => p.theme.hoverd.secondary};
- }
-`;
-
-const Container = styled(animated.ol)<{ right: number }>`
- position: absolute;
- top: 60px;
- right: ${(p) => p.right}px;
- z-index: 100;
- width: 250px;
- border: 5px solid ${(p) => p.theme.bg.secondary};
- border-radius: 7px;
- box-shadow: 0px 3px 6px 2px rgba(0, 0, 0, 0.2);
- background-color: ${(p) => p.theme.bg.secondary};
- padding: 2px;
-`;
-
-const Border = styled.hr`
- border-top: 1px solid ${(p) => p.theme.border.primary};
- margin: 7px 0px;
- padding: 0 5px;
-`;
-
-export const SettingMenu: React.FC = () => {
- const [isOpen, setOpen] = useState(false);
- const refOl = useRef(null!);
- const refBtn = useRef(null!);
- const { width, isMobile, isDesktopSize } = useWindowSize();
-
- const { themeType, setThemeDark } = useTheme();
- const { config, configSetter } = useConfig();
-
- const right = useMemo(
- () => width - refBtn.current?.getBoundingClientRect().right,
- [width]
- );
-
- const checkIfClickedOutside = useCallback(
- (e: MouseEvent) => {
- if (!(e.target instanceof Node)) return;
-
- const isOutSideMenu = refOl.current && !refOl.current.contains(e.target);
- const isOutSideBtn = refBtn.current && !refBtn.current.contains(e.target);
-
- if (isOutSideMenu && isOutSideBtn) setOpen(false);
- },
- [setOpen]
- );
-
- useEffect(() => {
- if (config.scrollContainerRef.current !== null) {
- config.scrollContainerRef.current.style.overflow = isOpen
- ? "hidden"
- : "scroll";
- }
-
- if (isOpen) document.addEventListener("click", checkIfClickedOutside);
- return () => document.removeEventListener("click", checkIfClickedOutside);
- }, [isOpen]);
-
- const transitions = useTransition(isOpen, {
- from: { opacity: 0, y: -10 },
- enter: { opacity: 1, y: 0 },
- leave: { opacity: 0, y: -10 },
- });
-
- return (
- <>
- setOpen((o) => !o)}>
-
-
- {transitions(
- (style, item) =>
- item && (
-
- }
- text="Github"
- onClick={() =>
- window.open("https://github.com/mnsinri/vspo-stream-schedule")
- }
- />
-
-
- ,
- text: "Dark theme",
- }}
- onChange={(isOn) => setThemeDark(isOn)}
- />
- ,
- text: "Expand always",
- }}
- onChange={(isOn) => configSetter.setExpandAlways(isOn)}
- />
- ,
- text: "Marquee title",
- }}
- onChange={(isOn) => configSetter.setMarquee(isOn)}
- />
-
- )
- )}
- >
- );
-};
diff --git a/src/components/settingMenu/ToggleButtonItem.tsx b/src/components/settingMenu/ToggleButtonItem.tsx
deleted file mode 100644
index 1a6069e..0000000
--- a/src/components/settingMenu/ToggleButtonItem.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React, { ReactNode, useEffect, useMemo, useState } from "react";
-import styled from "styled-components";
-import { ToggleButton } from "./ToggleButton";
-import { MenuItem } from "./MenuItem";
-
-const FlexEnd = styled.div`
- margin-left: auto;
- display: flex;
- align-items: center;
-`;
-
-type Contents = {
- children?: ReactNode;
- icon?: ReactNode;
- text?: string;
-};
-
-type FuncContents = (isOn: boolean) => Contents;
-
-type Props = {
- contents: Contents | FuncContents;
- onChange: (isOn: boolean) => void;
- initState?: boolean;
- disabled?: boolean;
-};
-
-export const ToggleButtonItem: React.FC = ({
- contents,
- onChange,
- initState = false,
- disabled,
-}) => {
- const [isOn, setOn] = useState(initState);
- const { children, icon, text } = useMemo(
- () => (typeof contents === "function" ? contents(isOn) : contents),
- [contents, isOn]
- );
-
- useEffect(() => {
- onChange(isOn);
- }, [isOn]);
-
- return (
-
- );
-};
diff --git a/src/components/settingMenu/index.ts b/src/components/settingMenu/index.ts
deleted file mode 100644
index 501d497..0000000
--- a/src/components/settingMenu/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from "./SettingMenu";
-export * from "./MenuItem";
-export * from "./ToggleButton";
-export * from "./ToggleButtonItem";
diff --git a/src/components/settingMenu/index.tsx b/src/components/settingMenu/index.tsx
new file mode 100644
index 0000000..de79125
--- /dev/null
+++ b/src/components/settingMenu/index.tsx
@@ -0,0 +1,119 @@
+import React, { FC, memo } from "react";
+import {
+ Border,
+ Dropdown,
+ DropdownItem,
+ ToggleButtonItem,
+} from "../dropdownMenu";
+import { Button } from "./styles";
+import { StreamerFilter } from "../streamerFilter";
+import { useDisplaySize, useSetting, useSettingDispatch } from "src/providers";
+import { BiExpandAlt, BiMenu } from "react-icons/bi";
+import { FaGithub } from "react-icons/fa";
+import { TbMoonFilled, TbMarquee2, TbHistory } from "react-icons/tb";
+import { IoIosArrowBack } from "react-icons/io";
+
+export const SettingMenu: FC = memo(() => {
+ const setting = useSetting();
+ const configDispatch = useSettingDispatch();
+ const { mobile } = useDisplaySize();
+
+ const MenuButton = memo(() => (
+
+ ));
+
+ const GithubLink = memo(() => (
+ ,
+ text: "Github",
+ }}
+ onClick={() =>
+ window.open("https://github.com/mnsinri/vspo-stream-schedule")
+ }
+ />
+ ));
+
+ const DropdownHeader = memo(({ text }: { text: string }) => (
+
+ ));
+
+ const ThemeSetting = memo(() => (
+ ,
+ text: "Dark theme",
+ }}
+ onChange={(payload) => configDispatch({ target: "isDarkTheme", payload })}
+ disabled={setting.isDarkTheme.isReadOnly}
+ />
+ ));
+
+ const ExpandSetting = memo(() => (
+ ,
+ text: "Expand always",
+ }}
+ onChange={(payload) =>
+ configDispatch({ target: "isExpandAlways", payload })
+ }
+ disabled={setting.isExpandAlways.isReadOnly}
+ />
+ ));
+
+ const MarqueeSetting = memo(() => (
+ ,
+ text: "Marquee title",
+ }}
+ onChange={(payload) =>
+ configDispatch({ target: "isMarqueeTitle", payload })
+ }
+ disabled={setting.isMarqueeTitle.isReadOnly}
+ />
+ ));
+
+ const HistorySetting = memo(() => (
+ ,
+ text: "Stream history",
+ }}
+ onChange={(payload: boolean) =>
+ configDispatch({ target: "isDisplayHistory", payload })
+ }
+ disabled={setting.isDisplayHistory.isReadOnly}
+ />
+ ));
+
+ return (
+ }>
+
+
+
+
+
+
+ {!mobile && }
+ ,
+ text: "Streamer",
+ }}
+ width={300}
+ position={{ x: -310, y: -50 }}
+ entry={{ from: "right" }}
+ />
+
+
+
+ );
+});
diff --git a/src/components/settingMenu/styles.tsx b/src/components/settingMenu/styles.tsx
new file mode 100644
index 0000000..4772930
--- /dev/null
+++ b/src/components/settingMenu/styles.tsx
@@ -0,0 +1,13 @@
+import styled from "styled-components";
+
+export const Button = styled.button`
+ border: 0;
+ border-radius: 5px;
+ background-color: ${({ theme }) => theme.dropdown.input.bg.normal};
+ transition: 0.3s ease;
+ color: ${({ theme }) => theme.dropdown.input.icon};
+
+ &:hover {
+ background-color: ${({ theme }) => theme.dropdown.input.bg.hover};
+ }
+`;
diff --git a/src/components/streamCard/index.tsx b/src/components/streamCard/index.tsx
new file mode 100644
index 0000000..e54468e
--- /dev/null
+++ b/src/components/streamCard/index.tsx
@@ -0,0 +1,106 @@
+import React, { FC, useCallback, useMemo, useState } from "react";
+import { useTheme } from "styled-components";
+import { iconColor } from "src/configs/colors";
+import { FaYoutube, FaTwitch } from "react-icons/fa";
+import { TbBroadcast } from "react-icons/tb";
+import { Stream } from "types";
+
+import { useHover, useInterval } from "src/hooks";
+import {
+ TextContainer,
+ Details,
+ StreamerIcon,
+ Name,
+ Card,
+ Thumbnail,
+ StreamInfo,
+ PlatformIconContainer,
+ StateText,
+} from "./styles";
+import { Marquee } from "../marquee";
+import { useDisplaySize, useSetting } from "src/providers";
+import { toJstHHMM } from "src/utils";
+
+type Props = {
+ stream: Stream;
+};
+
+type StreamState = "upcoming" | "live" | "ended";
+
+export const StreamCard: FC = ({ stream }) => {
+ const theme = useTheme();
+ const [streamState, setStreamState] = useState("upcoming");
+ const { hovered, hoverParams } = useHover();
+
+ const { isExpandAlways: expand } = useSetting();
+ const isExpand = useMemo(
+ () => hovered || expand.state,
+ [hovered, expand.state],
+ );
+
+ const displaySize = useDisplaySize();
+ const titleFontSize = useMemo(
+ () => (displaySize.mobile ? "11px" : "20px"),
+ [displaySize.mobile],
+ );
+
+ const isLive = streamState === "live";
+ const isEnded = streamState === "ended";
+
+ const checkLiveState = (): StreamState => {
+ const now = Date.now();
+ const { endAt, startAt } = stream;
+
+ if (endAt && endAt.getTime() <= now) return "ended";
+ if (startAt.getTime() < now) return "live";
+ return "upcoming";
+ };
+ useInterval(() => {
+ const state = checkLiveState();
+ if (streamState !== state) setStreamState(state);
+
+ return state === "ended";
+ }, 3000);
+
+ const scheduledTimeText = useMemo(() => {
+ if (isLive) return "LIVE";
+ return toJstHHMM(stream.startAt);
+ }, [streamState, stream.startAt.toString()]);
+
+ const PlatformIcon = useCallback(() => {
+ const color = isLive ? iconColor[stream.platform] : theme.card.text;
+ switch (stream.platform) {
+ case "youtube":
+ return ;
+ case "twitch":
+ return ;
+ case "twitCasting":
+ return ;
+ }
+ }, [streamState, stream.platform, theme.card.text]);
+
+ return (
+ window.open(stream.url)}
+ >
+
+
+
+
+
+
+ {scheduledTimeText}
+
+
+
+
+
+
+ {stream.streamerName}
+
+
+
+ );
+};
diff --git a/src/components/streamCard/styles.tsx b/src/components/streamCard/styles.tsx
new file mode 100644
index 0000000..a4347b9
--- /dev/null
+++ b/src/components/streamCard/styles.tsx
@@ -0,0 +1,170 @@
+import { breakpointMediaQueries } from "src/configs";
+import styled, { css, keyframes } from "styled-components";
+
+const baseTransition = css`
+ transition: 0.2s ease;
+`;
+
+type StyleProps = {
+ isExpand: boolean;
+};
+
+export const Card = styled.div`
+ width: 160px;
+ height: ${({ isExpand }) => (isExpand ? 120 : 90)}px;
+ border-radius: 5px;
+ background-color: ${({ theme }) => theme.card.bg};
+ color: ${({ theme }) => theme.card.text};
+ position: relative;
+ overflow: hidden;
+ filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25));
+ ${baseTransition}
+
+ @starting-style {
+ opacity: 0;
+ }
+
+ ${({ isExpand }) => breakpointMediaQueries.tablet`
+ width: 320px;
+ height: ${isExpand ? 240 : 180}px;
+ border-radius: 10px;
+ `}
+`;
+
+export const Thumbnail = styled.img`
+ width: 100%;
+ aspect-ratio: 1.777777778;
+ position: relative;
+ z-index: 1;
+`;
+
+export const StreamInfo = styled.div`
+ width: ${({ isExpand }) => (isExpand ? 50 : 20)}px;
+ height: 20px;
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ background-color: ${({ theme }) => theme.card.bg};
+ border-radius: 10px;
+ box-shadow: inset 0px 2px 2px rgba(0, 0, 0, 0.25);
+ z-index: 10;
+ ${baseTransition}
+
+ ${({ isExpand }) => breakpointMediaQueries.tablet`
+ width: ${isExpand ? 85 : 30}px;
+ height: 28px;
+ gap: 4px;
+ border-radius: 15px;
+ box-shadow: inset 0px 3px 3px rgba(0, 0, 0, 0.25);
+ `}
+`;
+
+export const PlatformIconContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+
+ ${breakpointMediaQueries.tablet`
+ font-size: 20px;
+ `}
+`;
+
+const stateTextFadeIn = keyframes`
+ 0%, 30% {
+ opacity: 0;
+ transform: translateX(10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+`;
+export const StateText = styled.div`
+ font-weight: bold;
+ font-size: 11px;
+ animation: ${stateTextFadeIn} 0.3s ease-in-out;
+ ${({ isExpand }) =>
+ !isExpand &&
+ css`
+ display: none;
+ `};
+
+ ${breakpointMediaQueries.tablet`
+ font-size: 16px;
+ `}
+`;
+
+export const Details = styled.div`
+ height: 30px;
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ padding: 0 3px;
+
+ ${breakpointMediaQueries.tablet`
+ height: 60px;
+ gap: 6px;
+ padding: 0 6px;
+ `}
+`;
+
+export const StreamerIcon = styled.img`
+ height: 25px;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ object-fit: cover;
+ z-index: 1;
+
+ ${breakpointMediaQueries.tablet`
+ height: 50px;
+ `}
+`;
+
+const textContainerFadeIn = keyframes`
+ 0%,
+ 25% {
+ opacity: 0;
+ display: none;
+ transform: translateY(-10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+`;
+export const TextContainer = styled.div`
+ width: 125px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ ${({ isExpand }) =>
+ isExpand &&
+ css`
+ animation: ${textContainerFadeIn} 0.3s ease-in-out;
+ `};
+
+ ${breakpointMediaQueries.tablet`
+ width: 250px;
+ `}
+`;
+
+export const Name = styled.div`
+ font-size: 7.5px;
+ transform-origin: 0 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ ${breakpointMediaQueries.tablet`
+ font-size: 15px;
+ `}
+`;
diff --git a/src/components/streamGrid/index.tsx b/src/components/streamGrid/index.tsx
new file mode 100644
index 0000000..cd818b9
--- /dev/null
+++ b/src/components/streamGrid/index.tsx
@@ -0,0 +1,41 @@
+import React, { FC, useMemo } from "react";
+import { Stream } from "types";
+import { Container, ColumnContainer, DummyCard } from "./styles";
+import { StreamCard } from "../streamCard";
+
+type Props = {
+ streams: Stream[];
+ column: number;
+ gap: number;
+ minHeight: number;
+};
+export const StreamGrid: FC = ({ streams, column, gap, minHeight }) => {
+ const streamsMatrix = useMemo(() => {
+ const sortedStreams = [...streams].sort(
+ (a, b) =>
+ a.startAt.getTime() - b.startAt.getTime() ||
+ a.streamerName.localeCompare(b.streamerName),
+ );
+
+ return Array.from({ length: column }, (_, i) => {
+ const st = sortedStreams.filter((_, j) => j % column === i);
+ return st.length > 0 ? st : null;
+ });
+ }, [streams, column]);
+
+ return (
+
+ {streamsMatrix.map((columnStreams, i) => (
+
+ {columnStreams ? (
+ columnStreams.map((stream) => (
+
+ ))
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+};
diff --git a/src/components/streamGrid/styles.tsx b/src/components/streamGrid/styles.tsx
new file mode 100644
index 0000000..afc998b
--- /dev/null
+++ b/src/components/streamGrid/styles.tsx
@@ -0,0 +1,28 @@
+import styled from "styled-components";
+import { breakpointMediaQueries } from "src/configs";
+
+export const Container = styled.div<{ gap: number; minHeight: number }>`
+ min-height: ${({ minHeight }) => minHeight}px;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ gap: ${({ gap }) => gap}px;
+`;
+
+export const ColumnContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ ${breakpointMediaQueries.tablet`
+ gap: 40px;
+ `}
+`;
+
+export const DummyCard = styled.div`
+ width: 160px;
+
+ ${breakpointMediaQueries.tablet`
+ width: 320px;
+ `}
+`;
diff --git a/src/components/streamGridHeader/index.tsx b/src/components/streamGridHeader/index.tsx
new file mode 100644
index 0000000..9e43352
--- /dev/null
+++ b/src/components/streamGridHeader/index.tsx
@@ -0,0 +1,58 @@
+import React, { FC, useMemo } from "react";
+import { useTheme } from "styled-components";
+import { Bar, Container, DateLabel, Icon } from "./styles";
+import { toYYYYMMDD } from "src/utils";
+
+type Props = {
+ dateString: string;
+};
+export const StreamGridHeader: FC = ({ dateString }) => {
+ const theme = useTheme();
+
+ const parseToViewDate = (dateString: string) => {
+ const today = new Date();
+ if (toYYYYMMDD(today) === dateString) {
+ return "Today";
+ }
+
+ const tomorrow = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 1,
+ );
+ if (toYYYYMMDD(tomorrow) === dateString) {
+ return "Tomorrow";
+ }
+
+ const yesterday = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() - 1,
+ );
+ if (toYYYYMMDD(yesterday) === dateString) {
+ return "Yesterday";
+ }
+
+ return dateString;
+ };
+
+ const barParams = useMemo(() => {
+ const calcHeight = (max: number) => max - 7 * Math.random();
+ return [
+ { height: calcHeight(30), bgColor: theme.cardHeader.icon[0] },
+ { height: calcHeight(20), bgColor: theme.cardHeader.icon[1] },
+ { height: calcHeight(16), bgColor: theme.cardHeader.icon[2] },
+ ];
+ }, [theme]);
+
+ return (
+
+
+ {barParams.map((param) => (
+
+ ))}
+
+ {parseToViewDate(dateString)}
+
+ );
+};
diff --git a/src/components/streamGridHeader/styles.tsx b/src/components/streamGridHeader/styles.tsx
new file mode 100644
index 0000000..716e200
--- /dev/null
+++ b/src/components/streamGridHeader/styles.tsx
@@ -0,0 +1,35 @@
+import styled from "styled-components";
+
+export const Container = styled.div`
+ display: flex;
+ height: 50px;
+ margin: 25px 0;
+`;
+
+export const Icon = styled.div`
+ display: flex;
+ gap: 5px;
+ width: 30px;
+ aspect-ratio: 1;
+`;
+
+export const Bar = styled.div<{ height: number; bgColor: string }>`
+ width: 5px;
+ height: ${({ height }) => height}px;
+ margin-top: auto;
+ border-radius: 0 5px 0 3px;
+ background-color: ${({ bgColor }) => bgColor};
+ transition: 0.5s ease-out;
+
+ @starting-style {
+ height: 0px;
+ }
+`;
+
+export const DateLabel = styled.div`
+ font-size: 48px;
+ font-family: "Itim", cursive;
+ letter-spacing: -0.02em;
+ margin-top: 5px;
+ color: ${({ theme }) => theme.cardHeader.text};
+`;
diff --git a/src/components/streamerFilter/index.tsx b/src/components/streamerFilter/index.tsx
new file mode 100644
index 0000000..0ab9d44
--- /dev/null
+++ b/src/components/streamerFilter/index.tsx
@@ -0,0 +1,113 @@
+import React, { ComponentProps, FC, memo, useMemo } from "react";
+import {
+ Button,
+ Container,
+ PreviewContainer,
+ PreviewStreamerIcon,
+ StreamerIcon,
+} from "./styles";
+import { Dropdown, DropdownItem } from "../dropdownMenu";
+import {
+ useDisplaySize,
+ useVspoStreamer,
+ useVspoStreamFilter,
+} from "src/providers";
+import { MdFilterListOff } from "react-icons/md";
+import { Streamer } from "types";
+
+type DropdownItemContents = {
+ triggerContents: ComponentProps["contents"];
+};
+type Props = Omit, "trigger"> &
+ DropdownItemContents;
+type StreamerIcons = Streamer["youtube"]["icon"][];
+
+const StreamerFilterTrigger = memo(
+ ({
+ triggerContents,
+ icons,
+ }: DropdownItemContents & { icons: StreamerIcons }) => {
+ return (
+
+
+ {icons.slice(0, 7).map((src, n, arr) => (
+
+ ))}
+
+
+ );
+ },
+);
+
+export const StreamerFilter: FC = ({
+ triggerContents,
+ ...dropdownProps
+}) => {
+ const streamers = useVspoStreamer();
+ const { filter, streamerIds } = useVspoStreamFilter();
+ const { mobile } = useDisplaySize();
+
+ const filteredStreamerIcons = streamerIds.reduce(
+ (result: StreamerIcons, streamerId) => {
+ // TODO: use Map
+ const streamer = streamers.find(({ id }) => id === streamerId);
+ return result.concat(streamer?.youtube.icon ?? []);
+ },
+ [],
+ );
+
+ const sortedStreamers = useMemo(
+ () => [...streamers].sort((a, b) => a.id.localeCompare(b.id)),
+ [streamers],
+ );
+
+ const checkClicked = (id: string) => streamerIds.includes(id);
+ const onClick = (id: string) => {
+ const type = checkClicked(id)
+ ? "removeStreamerFilter"
+ : "addStreamerFilter";
+ filter({ type, payload: [id] });
+ };
+ const onClear = () => {
+ filter({ type: "clearStreamerFilter" });
+ };
+
+ const Contents = (isMobile: boolean) => (
+ <>
+
+
+
+
+ {sortedStreamers.map((s) => (
+ onClick(s.id)}
+ />
+ ))}
+
+ >
+ );
+
+ if (mobile) return Contents(mobile);
+
+ return (
+
+ }
+ >
+ {Contents(mobile)}
+
+ );
+};
diff --git a/src/components/streamerFilter/styles.tsx b/src/components/streamerFilter/styles.tsx
new file mode 100644
index 0000000..9a9099f
--- /dev/null
+++ b/src/components/streamerFilter/styles.tsx
@@ -0,0 +1,94 @@
+import { breakpointMediaQueries } from "src/configs";
+import styled from "styled-components";
+
+export const Container = styled.div`
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ width: auto;
+ padding: 7px 14px;
+ overflow-y: scroll;
+ scrollbar-width: none;
+ max-height: 120px;
+
+ ${breakpointMediaQueries.tablet`
+ width: 254px;
+ overflow-y: hidden;
+ max-height: max-content;
+ `}
+`;
+
+export const Button = styled.button`
+ margin-left: auto;
+
+ border: 0;
+ border-radius: 10px;
+ background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.normal};
+ transition: 0.3s ease;
+ color: ${({ theme }) => theme.dropdown.input.icon};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 5px;
+
+ &:hover {
+ background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.hover};
+ }
+
+ &:active {
+ background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.active};
+ }
+`;
+
+export const StreamerIcon = styled.img<{ isClicked: boolean }>`
+ height: 40px;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ object-fit: cover;
+ cursor: pointer;
+ transition: 0.1s ease-in-out;
+ border: 2px outset
+ ${({ theme, isClicked }) =>
+ isClicked ? theme.cardHeader.icon[0] : "transparent"};
+ box-shadow: ${({ isClicked }) =>
+ isClicked
+ ? "none"
+ : `-1px -1px 3px rgba(240, 240, 240, 0.5),
+ 1.5px 1.5px 3px rgba(15, 15, 15, 0.5)`};
+
+ &:hover {
+ border-radius: 40%;
+ }
+
+ ${({ theme, isClicked }) => breakpointMediaQueries.tablet`
+ height: 50px;
+ border: 3px outset ${isClicked ? theme.cardHeader.icon[0] : "transparent"};
+ box-shadow: ${
+ isClicked
+ ? "none"
+ : `-2px -2px 6px rgba(240, 240, 240, 0.5),
+ 3px 3px 6px rgba(15, 15, 15, 0.5)`
+ };
+ `}
+`;
+
+export const PreviewContainer = styled.div`
+ width: 100%;
+ height: 22px;
+ position: relative;
+ overflow-x: hidden;
+`;
+
+export const PreviewStreamerIcon = styled.img<{ n: number }>`
+ position: absolute;
+ top: 0;
+ right: ${({ n }) => 13 * n}px;
+ z-index: ${({ n }) => n};
+ height: calc(100% - 2px);
+ aspect-ratio: 1;
+ border: 1px solid ${({ theme }) => theme.dropdown.bg};
+ border-radius: 50%;
+ object-fit: cover;
+ transition: 0.2s ease-in-out;
+ opacity: 1;
+`;
diff --git a/src/components/settingMenu/ToggleButton.tsx b/src/components/toggleButton/index.tsx
similarity index 51%
rename from src/components/settingMenu/ToggleButton.tsx
rename to src/components/toggleButton/index.tsx
index e61b5cd..ddc02c4 100644
--- a/src/components/settingMenu/ToggleButton.tsx
+++ b/src/components/toggleButton/index.tsx
@@ -1,36 +1,7 @@
-import { animated, useSpring } from "@react-spring/web";
import React, { useCallback, useMemo, useState } from "react";
-import { useShakeAnimation } from "../../hooks/useShakeAnimation";
-import styled from "styled-components";
-import { useTheme } from "../../hooks";
-
-const Container = styled(animated.div)<{ width: number; height: number }>`
- position: relative;
- border-radius: ${(p) => p.height / 2 - 1}px;
- height: ${(p) => p.height}px;
- width: ${(p) => p.width}px;
-`;
-
-const Feild = styled(animated.input)<{ cursor: string }>`
- appearance: none;
- outline: none;
- border: none;
- border-radius: inherit;
- margin: 0;
- padding: 0;
- width: 100%;
- height: 100%;
- transition: 0.3s ease;
- cursor: ${(p) => p.cursor};
-`;
-
-const Knob = styled(animated.label)`
- position: absolute;
- pointer-events: none;
- background-color: #fafafa;
- border-radius: inherit;
- z-index: 1;
-`;
+import { useSpring } from "@react-spring/web";
+import { useTheme } from "styled-components";
+import { Area, Container, Knob } from "./styles";
type Props = {
onChange: (on: boolean) => void;
@@ -45,26 +16,14 @@ export const ToggleButton: React.FC = ({
initState = false,
disabled = false,
}) => {
- const { theme } = useTheme();
+ const theme = useTheme();
const [isOn, setOn] = useState(initState);
const rect = useMemo(
() => ({
width: size * 1.8,
height: size,
}),
- [size]
- );
-
- const [shakeStyle, shakeAnim] = useShakeAnimation(5);
-
- const onClick = useCallback(
- (e: React.MouseEvent) => {
- if (!disabled) return;
-
- e.preventDefault();
- shakeAnim();
- },
- [disabled, shakeAnim]
+ [size],
);
const onChangeStatus = useCallback(
@@ -77,17 +36,19 @@ export const ToggleButton: React.FC = ({
setOn(e.target.checked);
onChange(e.target.checked);
},
- [disabled, onChange]
+ [disabled, onChange],
);
- const { x, backgroundColor, opacity } = useSpring({
+ const { x, btnBg, knobBg, opacity } = useSpring({
from: {
x: initState ? 1 : 0,
- backgroundColor: initState ? theme.vspo.primary : "#c7cbdf",
+ btnBg: theme.dropdown.item.toggle(initState).bg.normal,
+ knobBg: theme.dropdown.item.toggle(initState).icon,
opacity: disabled ? 0.5 : 1,
},
x: isOn ? 1 : 0,
- backgroundColor: isOn ? theme.vspo.primary : "#c7cbdf",
+ btnBg: theme.dropdown.item.toggle(isOn).bg.normal,
+ knobBg: theme.dropdown.item.toggle(isOn).icon,
opacity: disabled ? 0.5 : 1,
config: {
duration: 150,
@@ -122,16 +83,15 @@ export const ToggleButton: React.FC = ({
}, [size]);
return (
-
-
-
+
+
);
};
diff --git a/src/components/toggleButton/styles.tsx b/src/components/toggleButton/styles.tsx
new file mode 100644
index 0000000..f1f789f
--- /dev/null
+++ b/src/components/toggleButton/styles.tsx
@@ -0,0 +1,36 @@
+import styled from "styled-components";
+import { animated } from "@react-spring/web";
+
+export const Container = styled(animated.div)<{
+ width: number;
+ height: number;
+}>`
+ position: relative;
+ border-radius: ${(p) => p.height / 2 - 1}px;
+ height: ${(p) => p.height}px;
+ width: ${(p) => p.width}px;
+`;
+
+export const Area = styled(animated.input)`
+ appearance: none;
+ outline: none;
+ border: none;
+ border-radius: inherit;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ transition: 0.3s ease;
+ cursor: pointer;
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+`;
+
+export const Knob = styled(animated.label)`
+ position: absolute;
+ pointer-events: none;
+ background-color: #fafafa;
+ border-radius: inherit;
+`;
diff --git a/src/configs/breakpoints.ts b/src/configs/breakpoints.ts
index d5a9171..b05c217 100644
--- a/src/configs/breakpoints.ts
+++ b/src/configs/breakpoints.ts
@@ -1,74 +1,30 @@
-import {
- CSSObject,
- FlattenSimpleInterpolation,
- SimpleInterpolation,
- css,
-} from "styled-components";
-import {
- BreakpointMediaQueries,
- BreakpointValues,
- Breakpoints,
-} from "../types";
+import { BreakpointMediaQueries, Breakpoints, BreakpointKey } from "types";
+import { css } from "styled-components";
-const values: BreakpointValues = {
- xs: 0,
- sm: 576,
- md: 768,
- lg: 992,
- xl: 1200,
- xxl: 1600,
+export const breakpoints: Breakpoints = {
+ mobile: 0,
+ tablet: 768,
+ desktop: 1024,
};
-const mediaQueries: BreakpointMediaQueries = {
- xs: (
- xs: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- ${css(xs, ...interpolations)}
- `,
- sm: (
- sm: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- @media (min-width: ${values.sm}px) {
- ${css(sm, ...interpolations)}
- }
- `,
- md: (
- md: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- @media (min-width: ${values.md}px) {
- ${css(md, ...interpolations)}
- }
- `,
- lg: (
- lg: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- @media (min-width: ${values.lg}px) {
- ${css(lg, ...interpolations)}
- }
+export const breakpointMediaQueries: BreakpointMediaQueries = {
+ mobile: (mobile, ...interpolations) => css`
+ ${css(mobile, ...interpolations)}
`,
- xl: (
- xl: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- @media (min-width: ${values.xl}px) {
- ${css(xl, ...interpolations)}
+ tablet: (tablet, ...interpolations) => css`
+ @media (min-width: ${breakpoints.tablet}px) {
+ ${css(tablet, ...interpolations)}
}
`,
- xxl: (
- xxl: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
- ): FlattenSimpleInterpolation => css`
- @media (min-width: ${values.xxl}px) {
- ${css(xxl, ...interpolations)}
+ desktop: (desktop, ...interpolations) => css`
+ @media (min-width: ${breakpoints.desktop}px) {
+ ${css(desktop, ...interpolations)}
}
`,
};
-export const breakpoints: Breakpoints = {
- values,
- mediaQueries,
+export const calcBreakPoint = (width: number): BreakpointKey => {
+ if (breakpoints.desktop <= width) return "desktop";
+ if (breakpoints.tablet <= width) return "tablet";
+ return "mobile";
};
diff --git a/src/colors/blue.ts b/src/configs/colors/blue.ts
similarity index 83%
rename from src/colors/blue.ts
rename to src/configs/colors/blue.ts
index 6d5b191..6227c0b 100644
--- a/src/colors/blue.ts
+++ b/src/configs/colors/blue.ts
@@ -1,4 +1,4 @@
-const blue = {
+export const blue = {
50: "#ebeaf9",
100: "#ccc9ef",
200: "#aaa6e4",
@@ -10,5 +10,3 @@ const blue = {
800: "#482aa2",
900: "#3d128b",
};
-
-export default blue;
diff --git a/src/configs/colors/common.ts b/src/configs/colors/common.ts
new file mode 100644
index 0000000..a7baf4e
--- /dev/null
+++ b/src/configs/colors/common.ts
@@ -0,0 +1,4 @@
+export const common = {
+ black: "#000",
+ white: "#fff",
+};
diff --git a/src/colors/grey.ts b/src/configs/colors/grey.ts
similarity index 66%
rename from src/colors/grey.ts
rename to src/configs/colors/grey.ts
index 9ae57db..2d66710 100644
--- a/src/colors/grey.ts
+++ b/src/configs/colors/grey.ts
@@ -1,5 +1,4 @@
-// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/colors/grey.js
-const grey = {
+export const grey = {
50: "#fafafa",
100: "#f5f5f5",
200: "#eeeeee",
@@ -15,5 +14,3 @@ const grey = {
A400: "#bdbdbd",
A700: "#616161",
};
-
-export default grey;
diff --git a/src/colors/icon.ts b/src/configs/colors/iconColor.ts
similarity index 66%
rename from src/colors/icon.ts
rename to src/configs/colors/iconColor.ts
index 30ec18c..1072539 100644
--- a/src/colors/icon.ts
+++ b/src/configs/colors/iconColor.ts
@@ -1,7 +1,5 @@
-const icon = {
+export const iconColor = {
youtube: "#ff0000",
twitch: "#9146FF",
twitCasting: "#0092fa",
};
-
-export default icon;
diff --git a/src/configs/colors/index.ts b/src/configs/colors/index.ts
new file mode 100644
index 0000000..683473c
--- /dev/null
+++ b/src/configs/colors/index.ts
@@ -0,0 +1,5 @@
+export { common } from "./common";
+export { grey } from "./grey";
+export { pink } from "./pink";
+export { blue } from "./blue";
+export { iconColor } from "./iconColor";
diff --git a/src/colors/pink.ts b/src/configs/colors/pink.ts
similarity index 83%
rename from src/colors/pink.ts
rename to src/configs/colors/pink.ts
index a3639d2..cd489ca 100644
--- a/src/colors/pink.ts
+++ b/src/configs/colors/pink.ts
@@ -1,4 +1,4 @@
-const pink = {
+export const pink = {
50: "#fee6ef",
100: "#fec1d8",
200: "#fe98be",
@@ -10,5 +10,3 @@ const pink = {
800: "#c02b6a",
900: "#992462",
};
-
-export default pink;
diff --git a/src/configs/index.ts b/src/configs/index.ts
index 93e0723..608e1e0 100644
--- a/src/configs/index.ts
+++ b/src/configs/index.ts
@@ -1,8 +1,3 @@
-import { easings, SpringConfig } from "@react-spring/web";
-
-export { breakpoints } from "./breakpoints";
-
-export const springConfig: SpringConfig = {
- duration: 500,
- easing: easings.easeInOutSine,
-};
+export * from "./colors";
+export * from "./theme";
+export * from "./breakpoints";
diff --git a/src/configs/theme/dark.ts b/src/configs/theme/dark.ts
new file mode 100644
index 0000000..93ffa5c
--- /dev/null
+++ b/src/configs/theme/dark.ts
@@ -0,0 +1,65 @@
+import { DefaultTheme } from "styled-components";
+import * as colors from "../colors";
+
+export const darkTheme: DefaultTheme = {
+ bg: colors.grey[900],
+ card: {
+ text: colors.grey[100],
+ bg: colors.grey[800],
+ },
+ cardHeader: {
+ icon: {
+ 0: colors.pink[300],
+ 1: colors.blue[400],
+ 2: colors.pink[300],
+ },
+ text: colors.grey[100],
+ },
+ header: {
+ text: colors.grey[100],
+ },
+ dropdown: {
+ input: {
+ icon: colors.grey[100],
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[700],
+ },
+ },
+ border: colors.grey[400],
+ text: colors.grey[100],
+ bg: colors.grey[800],
+ item: {
+ default: {
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[700],
+ },
+ },
+ toggle: (isOn) =>
+ isOn
+ ? {
+ icon: colors.grey[200],
+ bg: {
+ normal: colors.pink[300],
+ },
+ }
+ : {
+ icon: colors.grey[200],
+ bg: {
+ normal: colors.pink[300],
+ },
+ },
+ },
+ filter: {
+ clear: {
+ icon: colors.grey[100],
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[600],
+ active: colors.grey[700],
+ },
+ },
+ },
+ },
+};
diff --git a/src/configs/theme/index.ts b/src/configs/theme/index.ts
new file mode 100644
index 0000000..5bb219a
--- /dev/null
+++ b/src/configs/theme/index.ts
@@ -0,0 +1,8 @@
+import { Themes } from "styled-components";
+import { lightTheme } from "./light";
+import { darkTheme } from "./dark";
+
+export const themes: Themes = {
+ light: lightTheme,
+ dark: darkTheme,
+};
diff --git a/src/configs/theme/light.ts b/src/configs/theme/light.ts
new file mode 100644
index 0000000..db865ee
--- /dev/null
+++ b/src/configs/theme/light.ts
@@ -0,0 +1,65 @@
+import { DefaultTheme } from "styled-components";
+import * as colors from "../colors";
+
+export const lightTheme: DefaultTheme = {
+ bg: colors.grey[50],
+ card: {
+ text: colors.grey[900],
+ bg: colors.common.white,
+ },
+ cardHeader: {
+ icon: {
+ 0: colors.blue[400],
+ 1: colors.pink[300],
+ 2: colors.blue[400],
+ },
+ text: colors.grey[900],
+ },
+ header: {
+ text: colors.grey[900],
+ },
+ dropdown: {
+ input: {
+ icon: colors.grey[900],
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[200],
+ },
+ },
+ border: colors.grey[500],
+ text: colors.grey[900],
+ bg: colors.grey[100],
+ item: {
+ default: {
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[200],
+ },
+ },
+ toggle: (isOn) =>
+ isOn
+ ? {
+ icon: colors.grey[50],
+ bg: {
+ normal: colors.blue[400],
+ },
+ }
+ : {
+ icon: colors.grey[50],
+ bg: {
+ normal: colors.blue[100],
+ },
+ },
+ },
+ filter: {
+ clear: {
+ icon: colors.grey[900],
+ bg: {
+ normal: "transparent",
+ hover: colors.grey[300],
+ active: colors.grey[200],
+ },
+ },
+ },
+ },
+};
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index ce1c1e3..cd64044 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,9 +1,3 @@
-export { useVspoStreams } from "./useVspoStreams";
+export { useInterval } from "./useInterval";
export { useHover } from "./useHover";
-export { useTheme } from "./useTheme";
-export { useDB } from "./useDB";
-export { useStreamInfo } from "./useStreamInfo";
-export { useWindowSize } from "./useWindowSize";
-export { useConfig } from "./useConfig";
-export { useBoolStateCache } from "./useBoolStateCache";
-export { useAnimationFrame } from "./useAnimationFrame";
+export { useStreamFilter } from "./useStreamFilter";
diff --git a/src/hooks/useAnimationFrame.ts b/src/hooks/useAnimationFrame.ts
deleted file mode 100644
index 744d26c..0000000
--- a/src/hooks/useAnimationFrame.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useEffect, useRef } from "react";
-
-export const useAnimationFrame = (
- callback = (timestamp: DOMHighResTimeStamp) => {}
-) => {
- const ref = useRef(0);
- const rafCallback = useRef<(timestamp: DOMHighResTimeStamp) => void>(null!);
-
- useEffect(() => {
- rafCallback.current = callback;
- }, [callback]);
-
- useEffect(() => {
- const loop = (timestamp: DOMHighResTimeStamp) => {
- rafCallback.current(timestamp);
- ref.current = requestAnimationFrame(loop);
- };
- ref.current = requestAnimationFrame(loop);
- return () => {
- ref.current && cancelAnimationFrame(ref.current);
- };
- }, []);
-};
diff --git a/src/hooks/useBoolStateCache.ts b/src/hooks/useBoolStateCache.ts
deleted file mode 100644
index 3791277..0000000
--- a/src/hooks/useBoolStateCache.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useEffect, useState } from "react";
-
-export const useBoolStateCache = (key: string, initState: boolean) => {
- const [value, setter] = useState(
- (() => {
- const item = localStorage.getItem(key);
- return item == null ? initState : item.toLocaleLowerCase() === "true";
- })()
- );
-
- useEffect(() => {
- localStorage.setItem(key, value.toString());
- }, [value]);
-
- return [value, setter] as const;
-};
diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts
deleted file mode 100644
index e3c8a7e..0000000
--- a/src/hooks/useConfig.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "react";
-import { ConfigContext } from "../components/providers/ConfigProvider";
-
-export const useConfig = () => {
- const context = useContext(ConfigContext);
-
- if (typeof context === "undefined") {
- throw new Error("useConfig must be within a VspoStreamingProvider");
- }
-
- return context;
-};
diff --git a/src/hooks/useDB.ts b/src/hooks/useDB.ts
deleted file mode 100644
index 48d5b22..0000000
--- a/src/hooks/useDB.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useEffect, useState } from "react";
-import { database } from "../Firebase";
-import { get, onValue, ref } from "firebase/database";
-
-const cacheVersion = "vspo";
-const eventName = "OnUnmounted";
-const event = new CustomEvent(eventName);
-
-export const useDB = (
- path: string,
- cacheAvailableTime: number //sec
-): T[] => {
- const [value, setValue] = useState([]);
- const resp = ref(database, path);
- cacheAvailableTime *= 1000;
-
- const doCache = (data: T[]) => {
- const newCache = {
- data,
- timestamp: new Date().toString(),
- };
-
- caches.open(cacheVersion).then((cache) => {
- cache.put(path, new Response(JSON.stringify(newCache)));
- });
-
- // console.log(`[useDB:${path}] Cached`);
- };
-
- useEffect(() => {
- (async () => {
- //check cache
- const cache = await caches
- .match(path)
- .then((r) => r?.text())
- .then((r) => JSON.parse(r ?? "null"));
-
- //if cache is available
- if (
- cache?.timestamp &&
- Date.now() - Date.parse(cache.timestamp) <= cacheAvailableTime
- ) {
- // console.log(`[useDB:${path}] Use Cache`);
- setValue(cache.data);
- } else {
- // console.log(`[useDB:${path}] Get from DB`);
- const data = await get(resp);
- if (data.exists()) {
- const val = data.val() as T[];
- doCache(val);
- setValue([...val]);
- }
- }
-
- //subscribe DB listener after 2 min
- const timerId = setTimeout(() => {
- const unsubscriber = onValue(resp, (snap) => {
- // console.log(`[useDB:${path}] OnValue`);
- if (snap.exists()) {
- const val = snap.val() as T[];
- doCache(val);
- setValue([...val]);
- }
- });
-
- //called on unmounted
- const handleOnce = () => {
- unsubscriber();
- document.removeEventListener(eventName, handleOnce);
- // console.log(`[useDB:${path}] Remove listener onValue`);
- };
- document.addEventListener(eventName, handleOnce);
- }, 2 * 60 * 1000);
-
- //called on unmounted
- const handleOnce = () => {
- clearTimeout(timerId);
- document.removeEventListener(eventName, handleOnce);
- // console.log(`[useDB:${path}] Remove listener timeout`);
- };
- document.addEventListener(eventName, handleOnce);
-
- // console.log(`[useDB:${path}] init`);
- })();
-
- return () => {
- document.dispatchEvent(event);
- // console.log(`[useDB:${path}] dispatch events on unmounted`);
- };
- }, []);
-
- return value;
-};
diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts
index 95fd44b..64baf17 100644
--- a/src/hooks/useHover.ts
+++ b/src/hooks/useHover.ts
@@ -1,11 +1,12 @@
-import { useState } from "react";
+import { useState, PointerEvent } from "react";
export const useHover = () => {
const [hovered, setHover] = useState(false);
+
return {
hovered,
- hoverSpread: {
- onPointerOver: (e: any) => {
+ hoverParams: {
+ onPointerOver: (e: PointerEvent) => {
e.stopPropagation();
setHover(true);
},
diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts
new file mode 100644
index 0000000..c6d4249
--- /dev/null
+++ b/src/hooks/useInterval.ts
@@ -0,0 +1,13 @@
+import { useEffect } from "react";
+
+export const useInterval = (fn: () => boolean, ms: number) => {
+ useEffect(() => {
+ const timerId = setInterval(() => {
+ if (fn()) clearInterval(timerId);
+ }, ms);
+
+ if (fn()) clearInterval(timerId);
+
+ return () => clearInterval(timerId);
+ }, []);
+};
diff --git a/src/hooks/useShakeAnimation.ts b/src/hooks/useShakeAnimation.ts
deleted file mode 100644
index 4edac04..0000000
--- a/src/hooks/useShakeAnimation.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useSpring, useSpringRef } from "@react-spring/web";
-
-export const useShakeAnimation = (x: number) => {
- const api = useSpringRef();
- const style = useSpring({
- ref: api,
- x: 0,
- });
-
- const shake = () => {
- api.start({
- x: 0,
- from: { x },
- config: {
- mass: 1,
- tension: 500,
- friction: 15,
- },
- });
- };
-
- return [style, shake] as const;
-};
diff --git a/src/hooks/useStreamFilter.ts b/src/hooks/useStreamFilter.ts
new file mode 100644
index 0000000..f03647c
--- /dev/null
+++ b/src/hooks/useStreamFilter.ts
@@ -0,0 +1,55 @@
+import { useCallback, useMemo, useEffect } from "react";
+import { Stream, Streamer } from "types";
+import { deduplication } from "src/utils";
+import { useSetting, useSettingDispatch } from "src/providers";
+
+type StreamerFilterAction = {
+ type: "addStreamerFilter" | "removeStreamerFilter" | "clearStreamerFilter";
+ payload?: Streamer["id"][];
+};
+
+type FilterAction = StreamerFilterAction;
+
+export const useStreamFilter = (streams: Stream[]) => {
+ const {
+ filter: { streamerIds },
+ } = useSetting();
+ const settingDispatch = useSettingDispatch();
+
+ const filter = useCallback(
+ (action: FilterAction): void => {
+ if (action.type === "addStreamerFilter") {
+ const ids = deduplication(streamerIds.concat(action.payload ?? []));
+ settingDispatch({ target: "streamerIds", payload: ids });
+ return;
+ }
+
+ if (action.type === "removeStreamerFilter") {
+ const ids = streamerIds.filter(
+ (id) => !(action.payload ?? []).includes(id),
+ );
+ settingDispatch({ target: "streamerIds", payload: ids });
+ return;
+ }
+
+ if (action.type === "clearStreamerFilter") {
+ settingDispatch({ target: "streamerIds", payload: [] });
+ return;
+ }
+ },
+ [streamerIds],
+ );
+
+ useEffect(() => {
+ if (streamerIds.length > 0)
+ filter({ type: "addStreamerFilter", payload: streamerIds });
+ }, []);
+
+ const filteredStreams = useMemo(() => {
+ return streams.filter((s) => {
+ return streamerIds.length === 0 || streamerIds.includes(s.streamerId);
+ });
+ }, [streams, streamerIds]);
+
+ return { filteredStreams, streamerIds, filter };
+};
diff --git a/src/hooks/useStreamInfo.ts b/src/hooks/useStreamInfo.ts
deleted file mode 100644
index 029e66d..0000000
--- a/src/hooks/useStreamInfo.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useDB } from "./useDB";
-import { ChannelDTO, StreamDTO, StreamInfo, Service } from "../types";
-import { getFormattedDate, parseToJST } from "../utils";
-import { useMemo } from "react";
-
-const DB_DATA_PATH = process.env.REACT_APP_DB_DATA_PATH;
-
-export const useStreamInfo = (
- service: Service,
- channelCacheAvailableTime = 24 * 60 * 60, //sec
- streamCacheAvailableTime = 5 * 60 //sec
-): StreamInfo[] => {
- const channels = useDB(
- `${DB_DATA_PATH}/${service}/channels`,
- channelCacheAvailableTime
- );
-
- const streams = useDB(
- `${DB_DATA_PATH}/${service}/streams`,
- streamCacheAvailableTime
- );
-
- const streamsInfo = useMemo(
- () =>
- streams.reduce((state, s) => {
- const ch = channels.find((c) => c.id === s.channelId);
-
- if (ch !== undefined) {
- state.push({
- id: s.id,
- title: s.title,
- thumbnail: s.thumbnail,
- url: s.url,
- startAt: s.startAt,
- scheduledDate: getFormattedDate(parseToJST(Date.parse(s.startAt))),
- channelId: ch.id,
- name: ch.name,
- icon: ch.thumbnail,
- gameName: s.gameName,
- service,
- });
- }
-
- return state;
- }, []),
- [streams]
- );
-
- return streamsInfo;
-};
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
deleted file mode 100644
index a1acbcd..0000000
--- a/src/hooks/useTheme.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "react";
-import { ThemeContext } from "../components/providers/ThemeProvider";
-
-export const useTheme = () => {
- const context = useContext(ThemeContext);
-
- if (typeof context === "undefined") {
- throw new Error("useTheme must be within a ThemeProvider");
- }
-
- return context;
-};
diff --git a/src/hooks/useVspoStreams.ts b/src/hooks/useVspoStreams.ts
deleted file mode 100644
index 69d5e0d..0000000
--- a/src/hooks/useVspoStreams.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "react";
-import { VspoStreamingContext } from "../components/providers/VspoStreamingProvider";
-
-export const useVspoStreams = () => {
- const context = useContext(VspoStreamingContext);
-
- if (typeof context === "undefined") {
- throw new Error("useVspoStreams must be within a VspoStreamingProvider");
- }
-
- return context;
-};
diff --git a/src/hooks/useWindowSize.ts b/src/hooks/useWindowSize.ts
deleted file mode 100644
index 48db1e6..0000000
--- a/src/hooks/useWindowSize.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "react";
-import { WindowSizeContext } from "../components/providers/WindowSizeProvider";
-
-export const useWindowSize = () => {
- const context = useContext(WindowSizeContext);
-
- if (typeof context === "undefined") {
- throw new Error("useWindowSize must be within a VspoStreamingProvider");
- }
-
- return context;
-};
diff --git a/src/index.css b/src/index.css
index 0b71428..aa57bd4 100644
--- a/src/index.css
+++ b/src/index.css
@@ -3,22 +3,8 @@
body {
margin: 0;
padding: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
+ font-family: -apple-system, "Zen Kaku Gothic New", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-
overflow: hidden;
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-body::-webkit-scrollbar {
- display: none;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
}
diff --git a/src/index.js b/src/index.js
index be0d40b..ab9a00d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,14 +1,12 @@
-import './Firebase';
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import './index.css';
-import reportWebVitals from './reportWebVitals';
-import { App } from './App';
+import "./firebase";
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "./index.css";
+import reportWebVitals from "./reportWebVitals";
+import { App } from "./app";
-const root = ReactDOM.createRoot(document.getElementById('root'));
-root.render(
-
-);
+const root = ReactDOM.createRoot(document.getElementById("root"));
+root.render();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
diff --git a/src/providers/deviceTypeProvider/context.ts b/src/providers/deviceTypeProvider/context.ts
new file mode 100644
index 0000000..735747e
--- /dev/null
+++ b/src/providers/deviceTypeProvider/context.ts
@@ -0,0 +1,6 @@
+import { createContext } from "react";
+import { DisplaySizeInfo } from "types";
+
+export const displaySizeContext = createContext(
+ {} as DisplaySizeInfo,
+);
diff --git a/src/providers/deviceTypeProvider/index.ts b/src/providers/deviceTypeProvider/index.ts
new file mode 100644
index 0000000..1ab035b
--- /dev/null
+++ b/src/providers/deviceTypeProvider/index.ts
@@ -0,0 +1,2 @@
+export * from "./provider";
+export * from "./use";
diff --git a/src/providers/deviceTypeProvider/provider.tsx b/src/providers/deviceTypeProvider/provider.tsx
new file mode 100644
index 0000000..ea0d670
--- /dev/null
+++ b/src/providers/deviceTypeProvider/provider.tsx
@@ -0,0 +1,47 @@
+import React, { ReactNode, useCallback, useEffect, useReducer } from "react";
+import { displaySizeContext } from "./context";
+import { DisplaySizeInfo } from "types";
+import { calcBreakPoint } from "src/configs";
+
+type Props = {
+ children: ReactNode;
+};
+
+const baseState: DisplaySizeInfo = {
+ mobile: false,
+ tablet: false,
+ desktop: false,
+};
+
+export const DisplaySizeProvider = ({ children }: Props) => {
+ const displaySizeReducer = useCallback(
+ (state: DisplaySizeInfo, width: number): DisplaySizeInfo => {
+ const type = calcBreakPoint(width);
+ if (state[type]) return state;
+
+ return { ...baseState, [type]: true };
+ },
+ [],
+ );
+
+ const [displaySizeInfo, dispatch] = useReducer(
+ displaySizeReducer,
+ displaySizeReducer(baseState, window.innerWidth),
+ );
+
+ useEffect(() => {
+ const resize = () => {
+ dispatch(window.innerWidth);
+ };
+
+ resize();
+ window.addEventListener("resize", resize);
+ return () => window.removeEventListener("resize", resize);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/providers/deviceTypeProvider/use.ts b/src/providers/deviceTypeProvider/use.ts
new file mode 100644
index 0000000..72ea254
--- /dev/null
+++ b/src/providers/deviceTypeProvider/use.ts
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { displaySizeContext } from "./context";
+
+export const useDisplaySize = () => useContext(displaySizeContext);
diff --git a/src/providers/index.ts b/src/providers/index.ts
new file mode 100644
index 0000000..04ff9d5
--- /dev/null
+++ b/src/providers/index.ts
@@ -0,0 +1,4 @@
+export * from "./vspoStreamProvider";
+export * from "./settingProvider";
+export * from "./deviceTypeProvider";
+export * from "./themeProvider";
diff --git a/src/providers/settingProvider/context.ts b/src/providers/settingProvider/context.ts
new file mode 100644
index 0000000..670dbe8
--- /dev/null
+++ b/src/providers/settingProvider/context.ts
@@ -0,0 +1,22 @@
+import { createContext } from "react";
+import { Setting, Streamer } from "types";
+
+export type SettingAction = {
+ target: Extract<
+ keyof Setting,
+ "isDarkTheme" | "isExpandAlways" | "isMarqueeTitle" | "isDisplayHistory"
+ >;
+ payload: boolean;
+};
+
+export type StreamerFilterAction = {
+ target: Extract;
+ payload: Streamer["id"][];
+};
+
+export type Action = SettingAction | StreamerFilterAction;
+
+export const settingContext = createContext({} as Setting);
+export const settingDispatchContext = createContext>(
+ () => console.error("Dispatched action outside of an settingDispatchContext"),
+);
diff --git a/src/providers/settingProvider/index.ts b/src/providers/settingProvider/index.ts
new file mode 100644
index 0000000..1ab035b
--- /dev/null
+++ b/src/providers/settingProvider/index.ts
@@ -0,0 +1,2 @@
+export * from "./provider";
+export * from "./use";
diff --git a/src/providers/settingProvider/provider.tsx b/src/providers/settingProvider/provider.tsx
new file mode 100644
index 0000000..f76f8a2
--- /dev/null
+++ b/src/providers/settingProvider/provider.tsx
@@ -0,0 +1,98 @@
+import React, { ReactNode, useCallback, useEffect, useReducer } from "react";
+import { DeepPartial, Setting } from "types";
+import { Action, settingContext, settingDispatchContext } from "./context";
+import { checkMobile } from "src/utils";
+
+type Props = {
+ children: ReactNode;
+};
+
+const storageKey = "setting";
+const isMobile = checkMobile();
+
+const getLocalSetting = (): DeepPartial => {
+ try {
+ return JSON.parse(localStorage.getItem(storageKey) ?? "{}");
+ } catch {
+ return {};
+ }
+};
+
+const setLocalSetting = (setting: Setting) => {
+ localStorage.setItem(storageKey, JSON.stringify(setting));
+};
+
+export const getInitSetting = () => {
+ const localSetting = getLocalSetting();
+
+ const setting: Setting = {
+ isDarkTheme: {
+ state: localSetting.isDarkTheme?.state ?? false,
+ isReadOnly: false,
+ },
+ isExpandAlways: {
+ state: isMobile || (localSetting.isExpandAlways?.state ?? false),
+ isReadOnly: isMobile,
+ },
+ isMarqueeTitle: {
+ state: localSetting.isMarqueeTitle?.state ?? false,
+ isReadOnly: true, // TODO marquee
+ },
+ isDisplayHistory: {
+ state: localSetting.isDisplayHistory?.state ?? false,
+ isReadOnly: false,
+ },
+ filter: {
+ streamerIds: localSetting.filter?.streamerIds ?? [],
+ },
+ };
+
+ return setting;
+};
+
+export const SettingProvider = ({ children }: Props) => {
+ const settingReducer = useCallback(
+ (prev: Setting, { target, payload }: Action) => {
+ if (
+ "isDarkTheme" === target ||
+ "isExpandAlways" === target ||
+ "isMarqueeTitle" === target ||
+ "isDisplayHistory" === target
+ ) {
+ const setting = prev[target];
+ if (setting.isReadOnly) return prev;
+
+ return { ...prev, [target]: { ...setting, state: payload } };
+ }
+
+ if ("streamerIds" === target) {
+ return { ...prev, filter: { [target]: payload } };
+ }
+
+ return prev;
+ },
+ [],
+ );
+
+ const [setting, dispatch] = useReducer(settingReducer, getInitSetting());
+
+ useEffect(() => {
+ const onUnmount = () => {
+ setLocalSetting(setting);
+ };
+ window.addEventListener("beforeunload", onUnmount);
+
+ return () => {
+ setLocalSetting(setting);
+ window.removeEventListener("beforeunload", onUnmount);
+ };
+ }, [setting]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/providers/settingProvider/use.ts b/src/providers/settingProvider/use.ts
new file mode 100644
index 0000000..a9135af
--- /dev/null
+++ b/src/providers/settingProvider/use.ts
@@ -0,0 +1,5 @@
+import { useContext } from "react";
+import { settingContext, settingDispatchContext } from "./context";
+
+export const useSetting = () => useContext(settingContext);
+export const useSettingDispatch = () => useContext(settingDispatchContext);
diff --git a/src/providers/themeProvider/index.tsx b/src/providers/themeProvider/index.tsx
new file mode 100644
index 0000000..c381b2a
--- /dev/null
+++ b/src/providers/themeProvider/index.tsx
@@ -0,0 +1,19 @@
+import React, { FC, ReactNode, useMemo } from "react";
+import { ThemeProvider as TProvider } from "styled-components";
+import { themes } from "src/configs";
+import { useSetting } from "../settingProvider";
+
+type Props = {
+ children: ReactNode;
+};
+
+export const ThemeProvider: FC = ({ children }) => {
+ const { isDarkTheme } = useSetting();
+
+ const theme = useMemo(
+ () => (isDarkTheme.state ? themes["dark"] : themes["light"]),
+ [isDarkTheme.state],
+ );
+
+ return {children};
+};
diff --git a/src/providers/vspoStreamProvider/context.ts b/src/providers/vspoStreamProvider/context.ts
new file mode 100644
index 0000000..9208fb3
--- /dev/null
+++ b/src/providers/vspoStreamProvider/context.ts
@@ -0,0 +1,9 @@
+import { createContext } from "react";
+import { useStreamFilter } from "src/hooks";
+import { Stream, Streamer } from "types";
+
+export const vspoStreamContext = createContext([]);
+export const vspoStreamerContext = createContext([]);
+export const vspoStreamFilterContext = createContext<
+ Pick, "filter" | "streamerIds">
+>(null!);
diff --git a/src/providers/vspoStreamProvider/index.ts b/src/providers/vspoStreamProvider/index.ts
new file mode 100644
index 0000000..1ab035b
--- /dev/null
+++ b/src/providers/vspoStreamProvider/index.ts
@@ -0,0 +1,2 @@
+export * from "./provider";
+export * from "./use";
diff --git a/src/providers/vspoStreamProvider/provider.tsx b/src/providers/vspoStreamProvider/provider.tsx
new file mode 100644
index 0000000..6df0774
--- /dev/null
+++ b/src/providers/vspoStreamProvider/provider.tsx
@@ -0,0 +1,132 @@
+import React, { ReactNode, useEffect, useMemo, useState } from "react";
+import { collection, onSnapshot } from "firebase/firestore";
+import { firestore } from "src/firebase";
+import {
+ vspoStreamContext,
+ vspoStreamerContext,
+ vspoStreamFilterContext,
+} from "./context";
+import {
+ Channel,
+ Stream,
+ Streamer,
+ StreamerMap,
+ StreamerResponse,
+ StreamResponse,
+} from "types";
+import { useStreamFilter } from "src/hooks";
+
+type Props = {
+ children: ReactNode;
+};
+
+const parseToStream = (
+ streamRes: StreamResponse,
+ streamerId: string,
+ channel: Channel,
+): Stream => {
+ const endAt = streamRes.endTime ? new Date(streamRes.endTime) : undefined;
+
+ return {
+ id: streamRes.id,
+ title: streamRes.title,
+ thumbnail: streamRes.thumbnail,
+ url: streamRes.url,
+ streamerId,
+ streamerName: channel.name,
+ icon: channel.icon,
+ platform: streamRes.platform,
+ startAt: new Date(streamRes.scheduledStartTime),
+ endAt,
+ };
+};
+
+const parseToStreamer = (streamerRes: StreamerResponse): Streamer => {
+ const entries = Object.entries(streamerRes).map(
+ ([key, { id, name, icon }]) => [key, { id, name, icon }],
+ );
+
+ return Object.fromEntries(entries);
+};
+
+export const VspoStreamProvider = ({ children }: Props) => {
+ const [streamResponses, setStreamsResponse] = useState([]);
+ const [streamerMap, setStreamerMap] = useState({});
+
+ useEffect(() => {
+ const streamCollectionName = process.env.REACT_APP_STREAM_COLLECTION_NAME;
+ const streamerCollectionName =
+ process.env.REACT_APP_STREAMER_COLLECTION_NAME;
+
+ if (!streamCollectionName || !streamerCollectionName) {
+ throw new Error(
+ "Environment variable not found: REACT_APP_STREAM_COLLECTION_NAME, REACT_APP_STREAMER_COLLECTION_NAME",
+ );
+ }
+
+ const unSubStream = onSnapshot(
+ collection(firestore, streamCollectionName),
+ (snapshot) => {
+ setStreamsResponse((prev) => {
+ const newStreams = snapshot.docs.map(
+ (doc) => doc.data() as StreamResponse,
+ );
+ return [
+ ...newStreams,
+ ...prev.filter((s) => !newStreams.some(({ id }) => id === s.id)),
+ ];
+ });
+ },
+ );
+
+ const unSubStreamer = onSnapshot(
+ collection(firestore, streamerCollectionName),
+ (snapshot) => {
+ const map = Object.fromEntries(
+ snapshot.docs.map((doc) => {
+ return [doc.id, parseToStreamer(doc.data() as StreamerResponse)];
+ }),
+ );
+ setStreamerMap(map);
+ },
+ );
+
+ return () => {
+ unSubStreamer();
+ unSubStream();
+ };
+ }, []);
+
+ const streams = useMemo(() => {
+ return streamResponses.reduce((results: Stream[], streamRes) => {
+ const streamerId = streamRes.streamerId;
+ const channel = streamerMap[streamerId][streamRes.platform];
+
+ if (!channel) {
+ console.error(`streamerId is not found: ${streamerId}`);
+ return results;
+ }
+
+ return results.concat(parseToStream(streamRes, streamerId, channel));
+ }, []);
+ }, [streamResponses, streamerMap]);
+
+ const { filteredStreams, ...filterContext } = useStreamFilter(streams);
+
+ const streamers = useMemo(() => {
+ return Object.entries(streamerMap).map(([id, channels]) => ({
+ ...channels,
+ id,
+ }));
+ }, [streamerMap]);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/src/providers/vspoStreamProvider/use.ts b/src/providers/vspoStreamProvider/use.ts
new file mode 100644
index 0000000..f106e60
--- /dev/null
+++ b/src/providers/vspoStreamProvider/use.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import {
+ vspoStreamContext,
+ vspoStreamerContext,
+ vspoStreamFilterContext,
+} from "./context";
+
+export const useVspoStreamFilter = () => useContext(vspoStreamFilterContext);
+export const useVspoStream = () => useContext(vspoStreamContext);
+export const useVspoStreamer = () => useContext(vspoStreamerContext);
diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js
index 5253d3a..9ecd33f 100644
--- a/src/reportWebVitals.js
+++ b/src/reportWebVitals.js
@@ -1,6 +1,6 @@
-const reportWebVitals = onPerfEntry => {
+const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
- import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
diff --git a/src/setupTests.js b/src/setupTests.js
index 8f2609b..1dd407a 100644
--- a/src/setupTests.js
+++ b/src/setupTests.js
@@ -2,4 +2,4 @@
// 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';
+import "@testing-library/jest-dom";
diff --git a/src/theme/dark.ts b/src/theme/dark.ts
deleted file mode 100644
index 7059857..0000000
--- a/src/theme/dark.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ColorTheme } from "../types";
-import { common, grey, pink, blue } from "../colors";
-
-export const dark: ColorTheme = {
- text: {
- primary: common.white,
- },
- bg: {
- primary: grey[900],
- secondary: grey[800],
- },
- hoverd: {
- primary: grey[700],
- secondary: grey[600],
- },
- border: {
- primary: grey[100],
- },
- vspo: {
- primary: pink[300],
- secondary: blue[400],
- },
-};
diff --git a/src/theme/index.ts b/src/theme/index.ts
deleted file mode 100644
index 3d2f010..0000000
--- a/src/theme/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { dark } from "./dark";
-import { light } from "./light";
-
-export default {
- dark,
- light,
-};
diff --git a/src/theme/light.ts b/src/theme/light.ts
deleted file mode 100644
index b438a3c..0000000
--- a/src/theme/light.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ColorTheme } from "../types";
-import { common, grey, pink, blue } from "../colors";
-
-export const light: ColorTheme = {
- text: {
- primary: common.black,
- },
- bg: {
- primary: grey[50],
- secondary: grey[100],
- },
- hoverd: {
- primary: grey[200],
- secondary: grey[300],
- },
- border: {
- primary: grey[900],
- },
- vspo: {
- primary: blue[400],
- secondary: pink[300],
- },
-};
diff --git a/src/types/configs.ts b/src/types/configs.ts
deleted file mode 100644
index 3dbadc2..0000000
--- a/src/types/configs.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {
- CSSObject,
- FlattenSimpleInterpolation,
- SimpleInterpolation,
-} from "styled-components";
-
-export type BreakpointValues = {
- xs: number;
- sm: number;
- md: number;
- lg: number;
- xl: number;
- xxl: number;
-};
-
-export type BreakpointMediaQuery = (
- base: CSSObject | TemplateStringsArray,
- ...interpolations: SimpleInterpolation[]
-) => FlattenSimpleInterpolation;
-
-export type BreakpointMediaQueries = {
- xs: BreakpointMediaQuery;
- sm: BreakpointMediaQuery;
- md: BreakpointMediaQuery;
- lg: BreakpointMediaQuery;
- xl: BreakpointMediaQuery;
- xxl: BreakpointMediaQuery;
-};
-
-export type Breakpoints = {
- values: BreakpointValues;
- mediaQueries: BreakpointMediaQueries;
-};
diff --git a/src/types/frontLogic.ts b/src/types/frontLogic.ts
deleted file mode 100644
index f4e8245..0000000
--- a/src/types/frontLogic.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-export type Service = "youtube" | "twitch" | "twitCasting";
-
-export type ChannelDTO = {
- id: string;
- name: string;
- thumbnail: string;
- uploads?: string;
-};
-
-export type StreamDTO = {
- channelId: string;
- id: string;
- title: string;
- thumbnail: string;
- url: string;
- startAt: string;
- gameName?: string;
-};
-
-export type ChannelCache = {
- channels: ChannelDTO[];
- timestamp: string;
-};
-
-export type StreamingCache = {
- streams: StreamDTO[];
- timestamp: string;
-};
-
-export type ChildrenNode = {
- children: React.ReactNode;
-};
-
-export type StreamInfo = {
- id: string;
- title: string;
- thumbnail: string;
- url: string;
- startAt: string;
- scheduledDate: string;
- channelId: string;
- name: string;
- icon: string;
- gameName?: string;
- service: Service;
-};
diff --git a/src/types/frontUI.ts b/src/types/frontUI.ts
deleted file mode 100644
index b25b04a..0000000
--- a/src/types/frontUI.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { StreamInfo, Service, ChildrenNode } from "./frontLogic";
-
-export type WindowSize = {
- width: number;
- height: number;
-};
-
-export type ClientType = {
- isMobile: boolean;
- isPhoneSize: boolean;
- isTabletSize: boolean;
- isDesktopSize: boolean;
-};
-
-export type ThumbnailBlockProps = {
- title: string;
- thumbnail: string;
- name: string;
- icon: string;
- isExpand: boolean;
- hovered: boolean;
-} & React.HTMLAttributes;
-
-export type ServiceIconProps = {
- startAt: string;
- isExpand: boolean;
- service: Service;
-} & React.HTMLAttributes;
-
-export type StreamingHeaderProps = Omit;
-
-export type StreamingCardProps = Omit<
- ServiceIconProps & Omit,
- "isExpand"
-> & {
- url: string;
-};
-
-export type StreamingTableProps = {
- streams: StreamInfo[];
-} & React.HTMLAttributes;
-
-export type DateBorderProps = {
- dateString: string;
-} & React.HTMLAttributes;
-
-export type BaseButtonProps = {
- onClickHandler: () => void;
- children: React.ReactNode;
-} & React.HTMLAttributes;
-
-export type LinkButtonProps = {
- url: string;
-} & Omit;
-
-export type StreamList = {
- date: string;
- streams: StreamInfo[];
-};
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index 051e7d1..0000000
--- a/src/types/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from "./frontLogic";
-export * from "./frontUI";
-export * from "./theme";
-export * from "./configs";
diff --git a/src/types/theme.ts b/src/types/theme.ts
deleted file mode 100644
index b676d77..0000000
--- a/src/types/theme.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export type ColorLevel = {
- primary: string;
- secondary?: string;
-};
-
-export type ColorTheme = {
- text: ColorLevel;
- bg: ColorLevel;
- hoverd: ColorLevel;
- border: ColorLevel;
- vspo: ColorLevel;
-};
-
-export type Theme = {
- dark: ColorTheme;
- light: ColorTheme;
-};
-
-export type ThemeTypes = keyof Theme;
-
-export type ThemeContextType = {
- themeType: ThemeTypes;
- theme: ColorTheme;
- setThemeDark: (isOn: boolean) => void;
-};
diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts
new file mode 100644
index 0000000..74dae28
--- /dev/null
+++ b/src/utils/arrayUtils.ts
@@ -0,0 +1,3 @@
+export const deduplication = (array: any[]) => {
+ return Array.from(new Set(array));
+};
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
new file mode 100644
index 0000000..7ecc85b
--- /dev/null
+++ b/src/utils/dateUtils.ts
@@ -0,0 +1,18 @@
+export const toJST = (utcDate: Date) => {
+ utcDate.setHours(utcDate.getHours() + 9);
+ return utcDate;
+};
+
+export const toYYYYMMDD = (date: Date) =>
+ date.toLocaleDateString("ja-JP", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+
+export const toJstHHMM = (date: Date) =>
+ date.toLocaleString("ja-JP", {
+ timeZone: "Asia/Tokyo",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
diff --git a/src/utils/deviceUtils.ts b/src/utils/deviceUtils.ts
new file mode 100644
index 0000000..414dd44
--- /dev/null
+++ b/src/utils/deviceUtils.ts
@@ -0,0 +1,12 @@
+///
+
+export const checkMobile = (): boolean => {
+ if (navigator.userAgentData) {
+ return navigator.userAgentData.mobile;
+ } else {
+ return (
+ /android|ipod|ipad|iphone/.test(navigator.userAgent.toLowerCase()) &&
+ "ontouchend" in document
+ );
+ }
+};
diff --git a/src/utils/index.ts b/src/utils/index.ts
index eed7dfe..6c0c937 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,10 +1,3 @@
-export const parseToJST = (utcmilisec: number) =>
- new Date(utcmilisec + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000);
-
-export const getFormattedDate = (date: Date) => {
- const y = date.getFullYear();
- const m = (date.getMonth() + 1).toString().padStart(2, "0");
- const d = date.getDate().toString().padStart(2, "0");
-
- return `${y}/${m}/${d}`.replace(/\n|\r/g, "");
-};
+export * from "./dateUtils";
+export * from "./deviceUtils";
+export * from "./arrayUtils";
diff --git a/tsconfig.json b/tsconfig.json
index e3642af..f31d7ae 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,12 +15,9 @@
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
- "isolatedModules": true,
"noEmit": true,
- "jsx": "react"
+ "jsx": "react",
+ "baseUrl": ".",
},
- "include": [
- "src",
- "@types"
- ]
+ "include": ["src", "types"]
}
\ No newline at end of file
diff --git a/types/config.ts b/types/config.ts
new file mode 100644
index 0000000..f0f6c18
--- /dev/null
+++ b/types/config.ts
@@ -0,0 +1,24 @@
+import {
+ CSSObject,
+ FlattenSimpleInterpolation,
+ SimpleInterpolation,
+} from "styled-components";
+
+export type BreakpointKey = "mobile" | "tablet" | "desktop";
+
+export type BreakpointMediaQuery = (
+ base: CSSObject | TemplateStringsArray,
+ ...interpolations: SimpleInterpolation[]
+) => FlattenSimpleInterpolation;
+
+export type BreakpointMediaQueries = {
+ [key in BreakpointKey]: BreakpointMediaQuery;
+};
+
+export type Breakpoints = {
+ [key in BreakpointKey]: number;
+};
+
+export type DisplaySizeInfo = {
+ [key in BreakpointKey]: boolean;
+};
diff --git a/types/custom.ts b/types/custom.ts
new file mode 100644
index 0000000..22958bf
--- /dev/null
+++ b/types/custom.ts
@@ -0,0 +1,9 @@
+declare module "*.png" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.svg" {
+ const content: string;
+ export default content;
+}
diff --git a/types/index.ts b/types/index.ts
new file mode 100644
index 0000000..ecf35a1
--- /dev/null
+++ b/types/index.ts
@@ -0,0 +1,12 @@
+export * from "./stream";
+export * from "./setting";
+export * from "./config";
+export * from "./theme";
+
+export type DeepPartial = {
+ [P in keyof T]?: T[P] extends (infer U)[]
+ ? DeepPartial[]
+ : T[P] extends Readonly[]
+ ? Readonly>[]
+ : DeepPartial;
+};
diff --git a/types/setting.ts b/types/setting.ts
new file mode 100644
index 0000000..eb8e598
--- /dev/null
+++ b/types/setting.ts
@@ -0,0 +1,18 @@
+import { Streamer } from "./stream";
+
+export type SettingState = {
+ state: boolean;
+ isReadOnly: boolean;
+};
+
+export type FilterInfo = {
+ streamerIds: Streamer["id"][];
+};
+
+export type Setting = {
+ isDarkTheme: SettingState;
+ isExpandAlways: SettingState;
+ isMarqueeTitle: SettingState;
+ isDisplayHistory: SettingState;
+ filter: FilterInfo;
+};
diff --git a/types/stream.ts b/types/stream.ts
new file mode 100644
index 0000000..79229da
--- /dev/null
+++ b/types/stream.ts
@@ -0,0 +1,47 @@
+export type Platform = "youtube" | "twitch" | "twitCasting";
+
+export type ChannelResponse = {
+ id: string;
+ name: string;
+ icon: string;
+ platform: Platform;
+};
+
+export type StreamerResponse = { [k in Platform]: ChannelResponse };
+
+export type StreamResponse = {
+ id: string;
+ streamerId: string;
+ channelId: string;
+ platform: Platform;
+ title: string;
+ thumbnail: string;
+ url: string;
+ scheduledStartTime: string; // date string
+ startTime?: string; // date string
+ endTime?: string; // date string
+ ttl: Date;
+};
+
+export type Channel = {
+ id: string;
+ name: string;
+ icon: string;
+};
+
+export type Streamer = { [key in Platform]: Channel } & { id: string };
+
+export type StreamerMap = { [id in string]: Streamer };
+
+export type Stream = {
+ id: string;
+ title: string;
+ thumbnail: string;
+ url: string;
+ streamerId: string;
+ streamerName: Channel["name"];
+ icon: Channel["icon"];
+ platform: Platform;
+ startAt: Date;
+ endAt?: Date;
+};
diff --git a/types/theme.ts b/types/theme.ts
new file mode 100644
index 0000000..720c983
--- /dev/null
+++ b/types/theme.ts
@@ -0,0 +1,63 @@
+import "styled-components";
+
+export type ThemeType = "light" | "dark";
+
+export type CardTheme = {
+ text: string;
+ bg: string;
+};
+
+export type CardHeaderTheme = {
+ icon: {
+ 0: string;
+ 1: string;
+ 2: string;
+ };
+ text: string;
+};
+
+export type HeaderTheme = {
+ text: string;
+};
+
+export type ButtonTheme = {
+ icon: string;
+ bg: {
+ normal: string;
+ hover?: string;
+ active?: string;
+ };
+};
+
+export type DropdownTheme = {
+ input: ButtonTheme;
+ filter: {
+ clear: ButtonTheme;
+ };
+ border: string;
+ text: string;
+ bg: string;
+ item: {
+ default: {
+ bg: {
+ normal: string;
+ hover: string;
+ };
+ };
+ toggle: (isOn: boolean) => ButtonTheme;
+ };
+};
+
+declare module "styled-components" {
+ export interface DefaultTheme {
+ bg: string;
+ card: CardTheme;
+ cardHeader: CardHeaderTheme;
+ header: HeaderTheme;
+ dropdown: DropdownTheme;
+ }
+
+ export type Themes = {
+ [key in ThemeType]: DefaultTheme;
+ };
+}