From 15c446826e75dd981f22b25e346fae56d6071ac5 Mon Sep 17 00:00:00 2001
From: nate2402 <nate2402@ajou.ac.kr>
Date: Tue, 8 Apr 2025 01:56:56 +0900
Subject: [PATCH] refactor: ui structure refactoration

---
 src/api/request.ts                     |   5 +-
 src/app/page.tsx                       |  40 +++++++--
 src/components/ContentWrap/index.tsx   |  77 ------------------
 src/components/ContentWrap/style.ts    | 108 -------------------------
 src/components/IntroArea/index.tsx     |   8 +-
 src/components/LinkButton/index.tsx    |  19 +++++
 src/components/LinkButton/style.ts     |  22 +++++
 src/components/PageTitleArea/index.tsx |  23 ++++++
 src/components/PageTitleArea/style.ts  |  28 +++++++
 src/components/ScrollArea/index.tsx    |  30 +++++--
 src/components/ScrollArea/style.ts     |  13 +++
 src/components/StyleElements/index.tsx |  12 +++
 src/components/StyleElements/style.ts  |  36 +++++++++
 src/hooks/useBookInfo.ts               |  31 +++++++
 src/hooks/useScrollState.ts            |  12 +++
 src/style/App.style.ts                 |  18 +++++
 16 files changed, 276 insertions(+), 206 deletions(-)
 delete mode 100644 src/components/ContentWrap/index.tsx
 delete mode 100644 src/components/ContentWrap/style.ts
 create mode 100644 src/components/LinkButton/index.tsx
 create mode 100644 src/components/LinkButton/style.ts
 create mode 100644 src/components/PageTitleArea/index.tsx
 create mode 100644 src/components/PageTitleArea/style.ts
 create mode 100644 src/components/StyleElements/index.tsx
 create mode 100644 src/components/StyleElements/style.ts
 create mode 100644 src/hooks/useBookInfo.ts
 create mode 100644 src/hooks/useScrollState.ts

diff --git a/src/api/request.ts b/src/api/request.ts
index 7e30b90..b2a07bf 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 79a8478..fbb825f 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 cee361d..0000000
--- 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 65e07c5..0000000
--- 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 590214f..e42062c 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 0000000..f630433
--- /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 0000000..7a44d22
--- /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 0000000..583f18d
--- /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 0000000..d67496c
--- /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 2bfbc9a..27fccbc 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 edd49d4..3da2246 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 0000000..7fc98ca
--- /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 0000000..87e53a9
--- /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 0000000..707f133
--- /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 0000000..136a623
--- /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 b2f07e3..eeb6fc6 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
-- 
GitLab