diff options
Diffstat (limited to 'src/components/MVPlayer')
| -rw-r--r-- | src/components/MVPlayer/index.styled.ts | 73 | ||||
| -rw-r--r-- | src/components/MVPlayer/index.tsx | 85 |
2 files changed, 158 insertions, 0 deletions
diff --git a/src/components/MVPlayer/index.styled.ts b/src/components/MVPlayer/index.styled.ts new file mode 100644 index 0000000..ca8a06e --- /dev/null +++ b/src/components/MVPlayer/index.styled.ts @@ -0,0 +1,73 @@ +import styled from "styled-components"; + +export const Frame = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin: 16px 0; +`; + +export const Images = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + width: 100%; +`; + +export const ImageSlot = styled.div<{ $revealed: boolean }>` + position: relative; + width: 200px; + height: 113px; + background-color: var(--cl-gray-2); + border: 1px solid ${({ theme }) => theme.border}; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +`; + +export const Placeholder = styled.p` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.7rem; + color: var(--cl-gray-5); + text-align: center; + padding: 0 8px; +`; + +export const Hint = styled.p` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.75rem; + color: var(--cl-gray-6); +`; + +export const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: zoom-out; +`; + +export const LightboxImage = styled.img` + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + cursor: default; +`; diff --git a/src/components/MVPlayer/index.tsx b/src/components/MVPlayer/index.tsx new file mode 100644 index 0000000..a1141ab --- /dev/null +++ b/src/components/MVPlayer/index.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import * as Styled from "./index.styled"; + +interface Props { + currentTry: number; + date: string; +} + +const MV_CDN_URL = import.meta.env.VITE_CDN_URL || ""; + +function getRevealedCount(currentTry: number): number { + if (currentTry >= 6) return 3; + if (currentTry >= 3) return 2; + return 1; +} + +export function MVPlayer({ currentTry, date }: Props) { + const revealed = getRevealedCount(currentTry); + const [errored, setErrored] = React.useState<Set<number>>(new Set()); + + const [activeImage, setActiveImage] = React.useState<string | null>(null); + + const markError = React.useCallback((index: number) => { + setErrored((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + }, []); + + React.useEffect(() => { + setErrored(new Set()); + setActiveImage(null); + }, [date]); + + return ( + <Styled.Frame> + <Styled.Images> + {Array.from({ length: 3 }, (_, i) => { + const index = i + 1; + const isRevealed = index <= revealed; + const src = `${MV_CDN_URL}/k-heardle-mvs/${date}/${index}.jpg`; + const hasError = errored.has(index); + + return ( + <Styled.ImageSlot key={index} $revealed={isRevealed}> + {isRevealed && !hasError ? ( + <img + src={src} + alt={`MV frame ${index}`} + onError={() => markError(index)} + onClick={() => setActiveImage(src)} + style={{ cursor: "zoom-in" }} + /> + ) : isRevealed && hasError ? ( + <Styled.Placeholder> + Frame {index} not available yet. + </Styled.Placeholder> + ) : ( + <Styled.Placeholder>?</Styled.Placeholder> + )} + </Styled.ImageSlot> + ); + })} + </Styled.Images> + + <Styled.Hint> + {revealed < 3 + ? `More frames unlock as you guess. (${revealed}/3 revealed)` + : "All frames revealed."} + </Styled.Hint> + + {/* Lightbox */} + {activeImage && ( + <Styled.Overlay onClick={() => setActiveImage(null)}> + <Styled.LightboxImage + src={activeImage} + alt="Full size MV frame" + onClick={(e) => e.stopPropagation()} + /> + </Styled.Overlay> + )} + </Styled.Frame> + ); +} |
