Skip to content

channeling-ai/FE

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

channeling-frontend

▶ 채널링 - Channeling

Coding conventions are documented in Rules.md.

💡 Project Overview

유튜브 채널 및 개별 영상 데이터를 AI로 분석해, 개선점과 트렌드 기반 콘텐츠 아이디어를 제공하는 솔루션입니다.
초보 유튜버부터 전문 크리에이터, 브랜드 마케팅 팀까지 모두가 활용할 수 있는 맞춤형 리포트를 자동 생성합니다.

멋있는 채널링 페이지

👥 Contributors

곰/김소원 하치/정윤빈 띵/장명준 정/김세정

gomx3

drddyn

komascode

sejeong223

🛠️ Tech Stacks

  • React + TypeScript + Vite: 빠른 개발 사이클(HMR)과 타입 안정성으로 품질·생산성 확보
  • TailwindCSS: 유틸리티 클래스 기반으로 일관된 디자인과 빠른 스타일링
  • Tanstack Query: 서버 상태 캐시/동기화, invalidateQueries로 신선도 제어
  • Zustand: 로그인 플로우/모달 등 전역 UI 상태를 심플하게 관리
  • Vercel: 간편한 프론트 배포 및 프리뷰 환경
  • ESLint/Prettier: 팀 컨벤션과 자동 포맷팅으로 일관성 유지

⚙️ Getting Started

  1. Install Plugin at your IDE
  1. Move to the frontend directory
cd frontend
  1. Install project dependencies
pnpm install
  1. Run development server
pnpm run dev

After running this command, you can see the website at localhost:5173.

📁 Project Structure

프론트엔드는 라우트(페이지) 중심의 기능 단위 구조 위에, 재사용 가능한 레이어(components · hooks · lib · api · stores) 를 분리해 구성했습니다.

📦FE
┣ 📁.github                              # GitHub 설정
┃ ┣ 📁ISSUE_TEMPLATE                     # 이슈 템플릿
┃ ┗ 📁workflows                          # CI/CD 워크플로우
┣ 📁frontend                             # 프론트엔드 앱 루트
┃ ┣ 📁node_modules
┃ ┣ 📁public                             # 정적 자산
┃ ┃ ┣ 📁fonts                            # 웹 폰트
┃ ┃ ┗ 📁icons                            # 퍼블릭 아이콘/이미지
┃ ┣ 📁src
┃ ┃ ┣ 📁api                              # API 클라이언트
┃ ┃ ┣ 📁assets                           # 내부 에셋
┃ ┃ ┃ ┣ 📁ellipses                       # 그래픽
┃ ┃ ┃ ┣ 📁icons                          # UI 아이콘
┃ ┃ ┃ ┃ ┣ 📁chart
┃ ┃ ┃ ┗ 📁loading
┃ ┃ ┣ 📁components                       # 재사용 컴포넌트
┃ ┃ ┃ ┣ 📁chart                          # 차트 컴포넌트/플러그인
┃ ┃ ┃ ┣ 📁common                         # 공통 UI
┃ ┃ ┃ ┃ ┗ 📁navbar                       # 모바일/태블릿/데스크톱 Navbar
┃ ┃ ┣ 📁constants                        # 상수
┃ ┃ ┃ ┗ 📜key.ts                         # 키/상수 모음
┃ ┃ ┣ 📁hooks                            # 커스텀 훅
┃ ┃ ┃ ┣ 📁channel
┃ ┃ ┃ ┣ 📁library
┃ ┃ ┃ ┣ 📁main
┃ ┃ ┃ ┣ 📁my
┃ ┃ ┃ ┗ 📁report
┃ ┃ ┣ 📁layouts                          # 루트/공통 레이아웃
┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┣ 📁lib                              # 유틸/매퍼/검증
┃ ┃ ┃ ┣ 📁mappers                        # API 매핑
┃ ┃ ┃ ┗ 📁validation
┃ ┃ ┣ 📁pages                            # 라우팅 페이지
┃ ┃ ┃ ┣ 📁auth                           # 인증(리다이렉트/모달)
┃ ┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┃ ┣ 📁library                        # 라이브러리
┃ ┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┃ ┣ 📁main                           # 메인
┃ ┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┃ ┣ 📁my                             # 마이페이지
┃ ┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┃ ┣ 📁report                         # 리포트 상세 페이지
┃ ┃ ┃ ┃ ┣ 📁_components
┃ ┃ ┃ ┃ ┃ ┣ 📁analysis
┃ ┃ ┃ ┃ ┃ ┣ 📁idea
┃ ┃ ┃ ┃ ┃ ┗ 📁overview
┃ ┃ ┃ ┣ 📁setting                        # 설정(프로필/동의/탈퇴)
┃ ┃ ┃ ┃ ┗ 📁_components
┃ ┃ ┣ 📁router                           # 라우터 설정
┃ ┃ ┣ 📁stores                           # Zustand 전역 상태
┃ ┃ ┣ 📁styles                           # 전역/유틸 CSS
┃ ┃ ┃ ┗ 📜global.css
┃ ┃ ┣ 📁types                            # 타입 선언
┃ ┃ ┣ 📁utils                            # 공통 유틸
┃ ┃ ┃ ┗ 📜format.ts
┃ ┃ ┣ 📜App.tsx
┃ ┃ ┣ 📜main.tsx
┃ ┃ ┗ 📜vite-env.d.ts
┃ ┣ 📜.env
┃ ┣ 📜.gitignore
┃ ┣ 📜.svg.d.ts
┃ ┣ 📜eslint.config.js
┃ ┣ 📜index.html
┃ ┣ 📜package.json
┃ ┣ 📜pnpm-lock.yaml
┃ ┣ 📜README.md
┃ ┣ 📜tsconfig.app.json
┃ ┣ 📜tsconfig.json
┃ ┣ 📜tsconfig.node.json
┃ ┣ 📜vercel.json
┃ ┗ 📜vite.config.ts
┣ 📁scripts                             # 스크립트(빌드/유틸)
┣ 📜.gitattributes
┣ 📜.gitignore
┣ 📜.prettierignore
┣ 📜.prettierrc
┣ 📜README.md                           # 루트 README
┗ 📜Rules.md                            # 컨벤션 문서

🐾 Frontend Architecture Flow

프론트 아키텍처

✍️ Typography System Guide

프로젝트 전역에서 일관된 텍스트 스타일을 적용하기 위해 타이포그래피 시스템을 정의했습니다.
모든 팀원은 typo.css에 정의된 클래스를 사용해야 하며, 상세 규칙과 클래스 레퍼런스는 Typography.md에서 확인할 수 있습니다.

🔫 Challenges & Solutions

페이지네이션의 숫자가 음수로 나타나는 문제
  • 원인 분석 페이지 버튼은 “5개 단위 창(window)”로 보여주는데, currentPage가 이 창의 범위를 벗어났을 때 startPage를 재조정해주는 로직이 없으면, 좌우 화살표 클릭 시 창 기준으로 계산된 값(예: startPage - 1)이 그대로 onChangePage로 전달됩니다. 특히 데이터 삭제 등으로 totalItems가 줄어들어 totalPageCount가 급격히 작아질 때, 이전에 보던 큰 페이지 번호가 남아 currentPage > totalPageCount 상태가 됩니다. 이 상태에서 좌측 이동을 반복하거나, 윈도우 앞쪽으로 순간 이동하면 startPagecurrentPage의 불일치가 커지고, 결국 0이나 음수 페이지가 계산되어 전달될 수 있습니다. 요약하면, (1) 윈도우 이동 동기화 부재 + (2) 페이지 경계값(1~totalPageCount) 클램핑 미흡이 결합해 발생한 버그입니다.

  • 해결 방법 currentPage가 보이는 창의 경계를 벗어날 때 자동으로 startPage를 재조정하여, 항상 현재 페이지가 5개짜리 창 안에 들어오게 했습니다. (아래 로직이 핵심)

    useEffect(() => {
        if (currentPage >= startPage + 5 && !noNext) {
            // 오른쪽 경계 초과 → 창 시작점을 현재 페이지로 이동
            setStartPage(currentPage)
        } else if (currentPage <= startPage - 1 && !noPrev) {
            // 왼쪽 경계 밖 → 현재 페이지가 창의 맨 앞에 오도록 이동
            setStartPage(currentPage - 4)
        }
    }, [noPrev, noNext, startPage, currentPage, setStartPage])

    이 로직을 넣으면, 좌우 화살표/번호 버튼으로 빠르게 이동하거나, 아이템이 줄어 총 페이지 수가 줄어드는 상황에서도 윈도우와 현재 페이지가 항상 동기화되어, startPage - 1 같은 계산이 0 이하로 떨어지는 경로가 차단됩니다. 재현 및 확인 과정: 1. 데이터가 여러 페이지(예: 18개, 6개/페이지 → 총 3페이지)일 때 3페이지로 이동. 2. 일부 아이템 삭제로 totalItems를 7부터 12개 수준으로 줄여 총 페이지 수를 2로 축소. 3. 좌측 화살표/페이지 빠른 클릭 → (수정 전) 창/현재페이지 불일치로 0 또는 음수 페이지가 찍히는 로그 확인. 4. 위 useEffect 추가 후 동일 시나리오 재실행 → 음수 페이지 발생하지 않음, 보이는 버튼도 항상 1부터 유효 범위를 유지.

텍스트 영역 글자 수 제한이 적용되지 않는 문제
  • 원인 분석 Textarea 컴포넌트가 maxLength를 선택값으로 받도록 되어 있는데, 일부 화면에서 이 값을 전달하지 않아 실제 <textarea>에 maxlength 속성이 잡히지 않았습니다. 자동 높이 조절 때문에 스크롤만 생겨 제한이 있는 것처럼 보였지만, 실제로는 무제한 입력이 가능했습니다.

  • 해결 방법

    import { useEffect, useRef, useState, type PropsWithChildren } from 'react'
    
    interface TextareaProps {
        id: string // textarea 요소의 고유 id
        value: string // textarea의 값
        onChange: (value: string) => void // 사용자가 입력한 텍스트가 변경될 때 호출되는 함수
        placeholder?: string
        initialRows?: number // row 개수로 textarea 박스의 초기 높이를 지정할 수 있습니다. 디폴트는 1
        disabled?: boolean
        className?: string
        maxLength?: number
    }
    
    const Textarea = ({
        id,
        value,
        onChange,
        placeholder,
        initialRows = 1,
        children,
        disabled = false,
        maxLength,
        className,
    }: PropsWithChildren<TextareaProps>) => {
        const [isFocused, setIsFocused] = useState(false)
        const textareaRef = useRef<HTMLTextAreaElement>(null)
    
        // Desktop, Tablet: 5줄까지 textarea가 늘어납니다. 6줄 부터는 스크롤해서 확인합니다.
        // Mobile: 3줄까지 textarea가 늘어납니다. 4줄 부터는 스크롤해서 확인합니다.
        useEffect(() => {
            const textarea = textareaRef.current
            if (!textarea) return
    
            const handleResize = () => {
                textarea.style.height = 'auto'
    
                const isMobile = window.innerWidth <= 768
                const maxLines = isMobile ? 3 : 5
                const maxHeight = 32 * maxLines
                textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px'
            }
            handleResize()
    
            window.addEventListener('resize', handleResize)
            return () => window.removeEventListener('resize', handleResize)
        }, [value])
    
        return (
            <divclassName={`
                    flex flex-col w/full min-w-[240px] tablet:min-w-[540px] desktop:min-w-[744px] p-4 space-y-6
                    border placeholder-gray-600 bg-neutral-white-opacity10 rounded-2xl
                    transition duration-300 ${isFocused ? 'border-gray-400' : 'border-transparent'} ${className ?? ''}
                `}
            >
                <textarearef={textareaRef}
                    id={id}
                    value={value}
                    disabled={disabled}
                    onChange={(e) => onChange(e.target.value)}
                    onFocus={() => setIsFocused(true)}
                    onBlur={() => setIsFocused(false)}
                    rows={initialRows}
                    placeholder={placeholder}
                    maxLength={maxLength}
                    className="
                        w-full h-fit max-h-[120px] px-2 outline-none resize-none focus:placeholder-transparent
                        text-[14px] leading-[150%] tracking-[-0.35px] tablet:text-[16px] tablet:tracking-[-0.4px]
                    "
                />
    
                {children && <>{children}</>}
            </div>
        )
    }
    
    export default Textarea

    모달별 상수값(콘셉트 500자, 타겟 100자)을 호출부에서 maxLength로 반드시 전달하여 제한을 확실히 적용했습니다.

페이지 전체가 스켈레톤 처리되어 사용자가 답답함을 느끼게 되는 문제
  • 원인 분석 내 채널 페이지에서 데이터가 pending일 경우를 한 번에 관리하여 사용자에게 답답한 느낌을 주는 문제가 있었습니다.

  • 해결 방법 스켈레톤 컴포넌트를 Skeleton과 VideoSkeleton으로 분리하고, 채널 대시보드 정보와 비디오리스트의 pending 상태가 각각 관리되게 했습니다.

    if (isMePending)
        return (
            <div>
                <Skeleton />
            </div>
        )
    if (isVideoPending || isShortsPending) return <VideoSkeleton />
프로필 이미지 변경 후 사이드바에 즉시 반영되지 않고, 새로고침 시 설정 이미지가 사라지는 문제
  • 원인 분석 프로필 변경 직후에도 사이드바가 갱신 전 사용자 정보(캐시) 를 계속 참조했고, staleTime: Infinity로 자동 재요청이 없어 최신 이미지가 반영되지 않았습니다. 또한 캐시 무효화가 없어서 새로고침 시 화면별로 서로 다른 소스가 뒤섞이며 이전 이미지가 사라진 것처럼 보였습니다.

  • 해결 방법

    import { useQuery } from '@tanstack/react-query'
    import type { User } from '../../types/channel'
    import { fetchMyProfile as fetchMyProfileAPI } from '../../api/user'
    export const useFetchMyProfile = (enabled = true) =>
        useQuery<User, Error, User, [string]>({
            queryKey: ['my-profile'],
            queryFn: async () => (await fetchMyProfileAPI()).result,
            staleTime: Infinity,
            retry: false,
            enabled,
        })
    // settings/ProfileImageUploader.tsx (프로필 이미지 변경 성공 시)
    import { useQueryClient } from '@tanstack/react-query'
    const queryClient = useQueryClient()
    
    const onSuccessUpdate = async () => {
    // 이미지 업로드/수정 성공 후 최신 정보로 동기화
    await queryClient.invalidateQueries({ queryKey: ['my-profile'] })
    }
    
    // components/Sidebar.tsx (사이드바와 설정 모두 같은 훅을 구독)
    import { useFetchMyProfile } from '../hooks/queries/useFetchMyProfile'
    const { data: me } = useFetchMyProfile()
    <img src={me?.profileImage ?? '/images/default.png'} alt="profile" />

    해당 문제를 해결하기 위해 hooks/queries 에 fetchMyProfile.tsx 파일을 만들어 프로필을 변경하면 invalidateQueries({queryKey: ['my-profile']})을 통해 캐시를 무효화한 후 fetchMyProfile.tsx가 API를 다시 불러와 최신 프로필 데이터를 가져오도록 설정했습니다

🤖 Gemini AI PR Review Automation Pipeline

저희 채널링에서는 보다 나은 코드를 위해 Gemini AI를 PR 리뷰에 자동화시켜 백엔드, 프론트엔드에서 사용하고 있습니다.

프론트 재미나이


default.mp4

About

UMC 8th 데모데이 프로젝트: 채널링 Frontend

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 6

Languages