Coding conventions are documented in Rules.md.
μ νλΈ μ±λ λ° κ°λ³ μμ λ°μ΄ν°λ₯Ό AIλ‘ λΆμν΄, κ°μ μ κ³Ό νΈλ λ κΈ°λ° μ½ν
μΈ μμ΄λμ΄λ₯Ό μ 곡νλ μ루μ
μ
λλ€.
μ΄λ³΄ μ νλ²λΆν° μ λ¬Έ ν¬λ¦¬μμ΄ν°, λΈλλ λ§μΌν
νκΉμ§ λͺ¨λκ° νμ©ν μ μλ λ§μΆ€ν 리ν¬νΈλ₯Ό μλ μμ±ν©λλ€.
| κ³°/κΉμμ | νμΉ/μ μ€λΉ | λ΅/μ₯λͺ μ€ | μ /κΉμΈμ |
|---|---|---|---|
gomx3 |
drddyn |
komascode |
sejeong223 |
- React + TypeScript + Vite: λΉ λ₯Έ κ°λ° μ¬μ΄ν΄(HMR)κ³Ό νμ μμ μ±μΌλ‘ νμ§Β·μμ°μ± ν보
- TailwindCSS: μ νΈλ¦¬ν° ν΄λμ€ κΈ°λ°μΌλ‘ μΌκ΄λ λμμΈκ³Ό λΉ λ₯Έ μ€νμΌλ§
- Tanstack Query: μλ² μν μΊμ/λκΈ°ν,
invalidateQueriesλ‘ μ μ λ μ μ΄ - Zustand: λ‘κ·ΈμΈ νλ‘μ°/λͺ¨λ¬ λ± μ μ UI μνλ₯Ό μ¬ννκ² κ΄λ¦¬
- Vercel: κ°νΈν νλ‘ νΈ λ°°ν¬ λ° ν리뷰 νκ²½
- ESLint/Prettier: ν 컨벀μ κ³Ό μλ ν¬λ§·ν μΌλ‘ μΌκ΄μ± μ μ§
- Install Plugin at your IDE
- Move to the frontend directory
cd frontend- Install project dependencies
pnpm install- Run development server
pnpm run devAfter running this command, you can see the website at localhost:5173.
νλ‘ νΈμλλ λΌμ°νΈ(νμ΄μ§) μ€μ¬μ κΈ°λ₯ λ¨μ ꡬ쑰 μμ, μ¬μ¬μ© κ°λ₯ν λ μ΄μ΄(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 # 컨벀μ
λ¬Έμ
νλ‘μ νΈ μ μμμ μΌκ΄λ ν
μ€νΈ μ€νμΌμ μ μ©νκΈ° μν΄ νμ΄ν¬κ·ΈλνΌ μμ€ν
μ μ μνμ΅λλ€.
λͺ¨λ νμμ typo.cssμ μ μλ ν΄λμ€λ₯Ό μ¬μ©ν΄μΌ νλ©°, μμΈ κ·μΉκ³Ό ν΄λμ€ λ νΌλ°μ€λ Typography.mdμμ νμΈν μ μμ΅λλ€.
νμ΄μ§λ€μ΄μ μ μ«μκ° μμλ‘ λνλλ λ¬Έμ
-
μμΈ λΆμ νμ΄μ§ λ²νΌμ β5κ° λ¨μ μ°½(window)βλ‘ λ³΄μ¬μ£Όλλ°,
currentPageκ° μ΄ μ°½μ λ²μλ₯Ό λ²μ΄λ¬μ λstartPageλ₯Ό μ¬μ‘°μ ν΄μ£Όλ λ‘μ§μ΄ μμΌλ©΄, μ’μ° νμ΄ν ν΄λ¦ μ μ°½ κΈ°μ€μΌλ‘ κ³μ°λ κ°(μ:startPage - 1)μ΄ κ·Έλλ‘onChangePageλ‘ μ λ¬λ©λλ€. νΉν λ°μ΄ν° μμ λ±μΌλ‘totalItemsκ° μ€μ΄λ€μ΄totalPageCountκ° κΈκ²©ν μμμ§ λ, μ΄μ μ 보λ ν° νμ΄μ§ λ²νΈκ° λ¨μcurrentPage > totalPageCountμνκ° λ©λλ€. μ΄ μνμμ μ’μΈ‘ μ΄λμ λ°λ³΅νκ±°λ, μλμ° μμͺ½μΌλ‘ μκ° μ΄λνλ©΄startPageμcurrentPageμ λΆμΌμΉκ° 컀μ§κ³ , κ²°κ΅ 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 리뷰μ μλνμμΌ λ°±μλ, νλ‘ νΈμλμμ μ¬μ©νκ³ μμ΅λλ€.

