diff --git a/src/api/request.ts b/src/api/request.ts index 7e30b904f9c763f168fe9f721c3e83fbd25e2522..b2a07bf45f6be5dade014aa2340e461f8eaddfc7 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -53,9 +53,10 @@ export const getSentence = cache(async (): Promise<{ throw new Error('데이터가 없습니다.'); } - const randomIndex = Math.floor(Math.random() * rows.length) % rows.length; - const [sentence, title, author, location, code] = rows[randomIndex]; + let randomIndex = Math.floor(Math.random() * rows.length) % rows.length; + if (randomIndex === 0) randomIndex = 1; + const [sentence, title, author, location, code] = rows[randomIndex]; return { result: true, data: {sentence,title,author,location,code} diff --git a/src/app/page.tsx b/src/app/page.tsx index 79a84789be9af7e880e694bb29f1324d875a9e0f..fbb825f2e247cb82554ad45132f841e319acedeb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,36 @@ 'use client'; +import { useMemo, useCallback, useState } from "react"; -import { ContentWrap } from '@/components/ContentWrap'; -import { AppSection, GlobalSection } from '@/style/App.style'; +// styles +import * as S from '@/style/App.style'; + +// hooks +import { useBookInfo } from "@/hooks/useBookInfo"; + +// components +import ScrollArea from "@/components/ScrollArea"; +import PageTitleArea from "@/components/PageTitleArea"; +import StyleElements from "@/components/StyleElements"; +import LinkButton from "@/components/LinkButton"; export default function Home() { - return <GlobalSection> - <AppSection> - <ContentWrap/> - </AppSection> -</GlobalSection>; -} + + const [loading, error, book_info] = useBookInfo(); + const [scroll, setScroll] = useState(0); + + const button_link = useMemo(() => { + return `https://library.ajou.ac.kr/#/total-search?keyword=${book_info?.code}`; + }, [book_info]); + + return <S.GlobalSection> + <S.AppSection> + <S.StyledSection> + <S.TopLogo src="/ajoulib_logo_4x.png" alt="AjouLib Logo" /> + <PageTitleArea scroll={scroll}/> + <ScrollArea book_info={book_info} onScroll={setScroll}/> + <LinkButton scroll={scroll} href={button_link}/> + <StyleElements scroll={scroll}/> + </S.StyledSection> + </S.AppSection> + </S.GlobalSection>; +} \ No newline at end of file diff --git a/src/components/ContentWrap/index.tsx b/src/components/ContentWrap/index.tsx deleted file mode 100644 index cee361de95027d8d1c48d31dd0c5532cf4c79637..0000000000000000000000000000000000000000 --- a/src/components/ContentWrap/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as S from "./style"; - -import TextArea from "../TextArea"; -import ScrollArea from "../ScrollArea"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import axios from "axios"; -import { BookInfo } from "@/types"; -import IntroArea from "../IntroArea"; -export const ContentWrap = () => { - - const [book_info, setBookInfo] = useState<BookInfo | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - const fetchSentence = async () => { - try { - const response = await axios.get('/api/sentence'); - const {result, data, error} = response?.data; - if (!result) throw new Error(error); - - setBookInfo(data); // 첫 번째 문장만 표시 - setLoading(false); - } catch (err) { - setError('문장을 불러오는데 실패했습니다.'); - setLoading(false); - console.error('Error fetching sentence:', err); - } - }; - - fetchSentence(); - }, []); - - const [scroll, setScroll] = useState(0); - - const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { - const percent = (e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight)) * 100; - setScroll(percent); - } - - const date_text = useMemo(() => { - const now = new Date(); - return `${now.getFullYear()}.${now.getMonth()}.${now.getDate()}.`; - }, []); - - const onLinkButtonClick = useCallback(() => { - if (scroll < 50) return; - window.open(`https://library.ajou.ac.kr/#/total-search?keyword=${book_info?.code}`); - }, [book_info, scroll]); - - return ( - <S.StyledSection> - <S.TopLogo src="/ajoulib_logo_4x.png" alt="AjouLib Logo" /> - <S.PageTitle style={{ - opacity: (100-scroll)/100 - }}>오늘의 한 문장</S.PageTitle> - <S.DateText style={{ - opacity: (100-scroll)/100 - }}>{date_text}</S.DateText> - <S.ScrollSection onScroll={handleScroll} style={{ - marginTop: `-${Math.min(scroll/2, 30)}%` - }}> - <IntroArea topRate={scroll} info={book_info}/> - <TextArea topRate={scroll} sentence={book_info?.sentence || ""}/> - <ScrollArea/> - </S.ScrollSection> - <S.LinkButton style={{ - opacity: scroll/100 - }} onClick={onLinkButtonClick}>도서관에서 이어보기</S.LinkButton> - <S.DesignBackground style={{ - transform: `translate(-50%, calc(-1*${scroll*1/10}%))` - }}/> - <S.BottomLogo src="/ajoulib_bottom_logo.png" alt="AjouLib Logo" /> - <S.CharacterImg src="/ajoulib_reading_chito.png" alt="AjouLib Chito" /> - </S.StyledSection> - ) -} \ No newline at end of file diff --git a/src/components/ContentWrap/style.ts b/src/components/ContentWrap/style.ts deleted file mode 100644 index 65e07c51061092f92a5458cb11a84f69b54501eb..0000000000000000000000000000000000000000 --- a/src/components/ContentWrap/style.ts +++ /dev/null @@ -1,108 +0,0 @@ -import styled from "styled-components"; - -export const StyledSection = styled.section` - height: 100%; - - display: flex; - flex-direction: column; - align-items: center; - - padding: 2rem; - box-sizing: border-box; - - position: relative; - overflow: hidden; -`; - -export const DateText = styled.span` - font-family: var(--font-ajou), sans-serif; - - font-size: 1rem; - font-weight: 100; - color: #1361A7; - - margin-top: .5rem; -`; - -export const LinkButton = styled.div` - width: 100%; - padding: 1.5em; - - position: sticky; - margin-bottom: 2rem; - left: 0; - - font-family: var(--font-ajou), sans-serif; - font-size: 1rem; - font-weight: 100; - color: white; - - text-align: center; - - background-color: #1361A7; - border-radius: 1em; - - cursor: pointer; -`; - -export const TopLogo = styled.img` - height: 2.5rem; -`; -export const BottomLogo = styled.img` - max-width: 100%; - height: auto; -`; - -export const ScrollSection = styled.section` - width: 100%; - - flex: 1; - height: auto; - - overflow-y: auto; - - @media (min-height: 760px) { - margin-top: 0 !important; - } -`; - -export const PageTitle = styled.h1` - font-family: var(--font-ajou), sans-serif; - - font-size: 2rem; - font-weight: 100; - color: #1361A7; - - margin: 0; - margin-top: 1.5rem; -`; - -export const DesignBackground = styled.div` - width: 1200px; - height: 1200px; - - position: absolute; - bottom: -70%; - left: 50%; - transform: translate(-50%); - - border-radius: 1050px; - background-color: #E0E7ED; - - z-index: -10; - - @media (max-height: 760px) { - display: none; - } -`; - -export const CharacterImg = styled.img` - width: 12rem; - - position: fixed; - bottom: 3rem; - right: 1.5rem; - - opacity: 0.3; - z-index: -2; -`; \ No newline at end of file diff --git a/src/components/IntroArea/index.tsx b/src/components/IntroArea/index.tsx index 590214f196c09b8e68002e43d4a725cd63c14d53..e42062c0415b2f180747230c452f209459b34ae4 100644 --- a/src/components/IntroArea/index.tsx +++ b/src/components/IntroArea/index.tsx @@ -1,19 +1,17 @@ import { useState, useEffect, useRef } from "react"; -import { BookInfo } from "@/types"; - // style import * as S from "./style"; +// types +import { BookInfo } from "@/types"; + // interfaces type IntroAreaProps = { topRate: number, info: BookInfo | null } -// components - - const IntroArea: React.FC<IntroAreaProps> = ({ topRate, info }) => { if (!info) return <></>; diff --git a/src/components/LinkButton/index.tsx b/src/components/LinkButton/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f630433f413aeeff05f4d3ee321cbd39d137403b --- /dev/null +++ b/src/components/LinkButton/index.tsx @@ -0,0 +1,19 @@ +import { useCallback } from "react"; + +// styles +import * as S from "./style"; + +const LinkButton = ({scroll, href}: {scroll: number, href: string}) => { + + const onLinkButtonClick = useCallback(() => { + if (scroll < 50) return; + window.open(href); + }, [href, scroll]); + + return <S.LinkButton + style={{opacity: scroll/100}} + onClick={onLinkButtonClick} + >도서관에서 이어보기</S.LinkButton> +}; + +export default LinkButton \ No newline at end of file diff --git a/src/components/LinkButton/style.ts b/src/components/LinkButton/style.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a44d2297f58fe978d77ad03cfef3438deda4cba --- /dev/null +++ b/src/components/LinkButton/style.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const LinkButton = styled.div` + width: 100%; + padding: 1.5em; + + position: sticky; + margin-bottom: 2rem; + left: 0; + + font-family: var(--font-ajou), sans-serif; + font-size: 1rem; + font-weight: 100; + color: white; + + text-align: center; + + background-color: #1361A7; + border-radius: 1em; + + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/PageTitleArea/index.tsx b/src/components/PageTitleArea/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..583f18deab7ef405be122830a73ca791cca3b10f --- /dev/null +++ b/src/components/PageTitleArea/index.tsx @@ -0,0 +1,23 @@ +import { useMemo } from "react"; + +// styles +import * as S from "./style"; + +const PageTitleArea = ({ scroll }: { scroll: number }) => { + + const date_text = useMemo(() => { + const now = new Date(); + return `${now.getFullYear()}.${now.getMonth()}.${now.getDate()}.`; + }, []); + + const text_opacity = useMemo(() => { + return (100-scroll)/100; + }, [scroll]); + + return <S.PageTitleArea> + <S.PageTitle style={{opacity: text_opacity}}>오늘의 한 문장</S.PageTitle> + <S.DateText style={{opacity: text_opacity}}>{date_text}</S.DateText> + </S.PageTitleArea> +} + +export default PageTitleArea; \ No newline at end of file diff --git a/src/components/PageTitleArea/style.ts b/src/components/PageTitleArea/style.ts new file mode 100644 index 0000000000000000000000000000000000000000..d67496c9c419862a314d4ebd3705d36a2f95e5a2 --- /dev/null +++ b/src/components/PageTitleArea/style.ts @@ -0,0 +1,28 @@ +import styled from "styled-components"; + +export const PageTitleArea = styled.section` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const PageTitle = styled.h1` + font-family: var(--font-ajou), sans-serif; + + font-size: 2rem; + font-weight: 100; + color: #1361A7; + + margin: 0; + margin-top: 1.5rem; +`; + +export const DateText = styled.span` + font-family: var(--font-ajou), sans-serif; + + font-size: 1rem; + font-weight: 100; + color: #1361A7; + + margin-top: .5rem; +`; \ No newline at end of file diff --git a/src/components/ScrollArea/index.tsx b/src/components/ScrollArea/index.tsx index 2bfbc9aeb6566bb0c62eea4fd8882578e94820de..27fccbc9a5c322af811e62b1519cca13dbde5d26 100644 --- a/src/components/ScrollArea/index.tsx +++ b/src/components/ScrollArea/index.tsx @@ -3,17 +3,35 @@ import { useState, useEffect, useRef } from "react"; // style import * as S from "./style"; +// types +import { BookInfo } from "@/types"; +import { useScrollState } from "@/hooks/useScrollState"; + +// components +import IntroArea from "@/components/IntroArea"; +import TextArea from "@/components/TextArea"; + // interfaces type ScrollAreaProps = { - + book_info: BookInfo | null; + onScroll: (v: number) => void; } -// components - +const ScrollArea: React.FC<ScrollAreaProps> = ({ book_info, onScroll }) => { + + const [scroll, handleScroll] = useScrollState(); + + useEffect(() => { + onScroll(scroll); + }, [scroll]); -const ScrollArea: React.FC<ScrollAreaProps> = ({ }) => { - return <S.ScrollAreaWrap> - + return <S.ScrollAreaWrap + onScroll={handleScroll} + style={{marginTop: `-${Math.min(scroll/2, 30)}%`}} + > + <IntroArea topRate={scroll} info={book_info}/> + <TextArea topRate={scroll} sentence={book_info?.sentence || ""}/> + <S.FakeScrollArea/> </S.ScrollAreaWrap> }; diff --git a/src/components/ScrollArea/style.ts b/src/components/ScrollArea/style.ts index edd49d43548bf4f356f402849af961d9f95c2a0e..3da2246029f4028a4b393f8418b8f12a0cb87850 100644 --- a/src/components/ScrollArea/style.ts +++ b/src/components/ScrollArea/style.ts @@ -2,5 +2,18 @@ import styled from "styled-components"; export const ScrollAreaWrap = styled.section` width: 100%; + + flex: 1; + height: auto; + + overflow-y: auto; + + @media (min-height: 760px) { + margin-top: 0 !important; + } +`; + +export const FakeScrollArea = styled.div` + width: 100%; height: calc(150%); `; \ No newline at end of file diff --git a/src/components/StyleElements/index.tsx b/src/components/StyleElements/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fc98ca0ebf02f55aa776123926d7d42549b6075 --- /dev/null +++ b/src/components/StyleElements/index.tsx @@ -0,0 +1,12 @@ +// styles +import * as S from "./style"; + +const StyleElements = ({scroll}: {scroll: number}) => { + return <> + <S.DesignBackground style={{transform: `translate(-50%, calc(-1*${scroll*1/10}%))`}}/> + <S.BottomLogo src="/ajoulib_bottom_logo.png" alt="AjouLib Logo" /> + <S.CharacterImg src="/ajoulib_reading_chito.png" alt="AjouLib Chito" /> + </>; +}; + +export default StyleElements; \ No newline at end of file diff --git a/src/components/StyleElements/style.ts b/src/components/StyleElements/style.ts new file mode 100644 index 0000000000000000000000000000000000000000..87e53a9cf3b5ac5fa43b0957059e099bcabcd334 --- /dev/null +++ b/src/components/StyleElements/style.ts @@ -0,0 +1,36 @@ +import styled from "styled-components"; + +export const BottomLogo = styled.img` + max-width: 100%; + height: auto; +`; + +export const DesignBackground = styled.div` + width: 1200px; + height: 1200px; + + position: absolute; + bottom: -70%; + left: 50%; + transform: translate(-50%); + + border-radius: 1050px; + background-color: #E0E7ED; + + z-index: -10; + + @media (max-height: 760px) { + display: none; + } +`; + +export const CharacterImg = styled.img` + width: 12rem; + + position: fixed; + bottom: 3rem; + right: 1.5rem; + + opacity: 0.3; + z-index: -2; +`; \ No newline at end of file diff --git a/src/hooks/useBookInfo.ts b/src/hooks/useBookInfo.ts new file mode 100644 index 0000000000000000000000000000000000000000..707f1335f180e0db63c7931c0f9bbd60abdae9d0 --- /dev/null +++ b/src/hooks/useBookInfo.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useCallback } from "react"; +import axios from "axios"; + +import { BookInfo } from "@/types"; + +export const useBookInfo = (): [boolean, string | null, BookInfo | null] => { + const [book_info, setBookInfo] = useState<BookInfo | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const fetchSentence = useCallback(async () => { + try { + const response = await axios.get('/api/sentence'); + const {result, data, error} = response?.data; + if (!result) throw new Error(error); + + setBookInfo(data); // 첫 번째 문장만 표시 + setLoading(false); + } catch (err) { + setError('문장을 불러오는데 실패했습니다.'); + setLoading(false); + console.error('Error fetching sentence:', err); + } + }, []); + + useEffect(() => { + fetchSentence(); + }, []); + + return [loading, error, book_info]; +} \ No newline at end of file diff --git a/src/hooks/useScrollState.ts b/src/hooks/useScrollState.ts new file mode 100644 index 0000000000000000000000000000000000000000..136a623c3d9f3859bc2b54d33a99439ff11c811f --- /dev/null +++ b/src/hooks/useScrollState.ts @@ -0,0 +1,12 @@ +import { useState } from "react"; + +export const useScrollState = (): [number, (e: React.UIEvent<HTMLElement>) => void] => { + const [scroll, setScroll] = useState(0); + + const handleScroll = (e: React.UIEvent<HTMLElement>) => { + const percent = (e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight)) * 100; + setScroll(percent); + } + + return [scroll, handleScroll]; +} \ No newline at end of file diff --git a/src/style/App.style.ts b/src/style/App.style.ts index b2f07e3627a55c75190c1ad421b8395063273341..eeb6fc68f8dc201699155fb277a55b27e523cd26 100644 --- a/src/style/App.style.ts +++ b/src/style/App.style.ts @@ -24,4 +24,22 @@ export const AppSection = styled.section` top: 50%; left: 50%; transform: translate(-50%, -50%); +`; + +export const StyledSection = styled.section` + height: 100%; + + display: flex; + flex-direction: column; + align-items: center; + + padding: 2rem; + box-sizing: border-box; + + position: relative; + overflow: hidden; +`; + +export const TopLogo = styled.img` + height: 2.5rem; `; \ No newline at end of file