Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ yarn-error.log*
.gemini/

.cursorrules
.gitmessage.txt
.gitmessage.txt

temp/
45 changes: 45 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SingCode Project Context

## Project Overview

SingCode is a Karaoke number search service built as a Monorepo. It aggregates song data from various sources and provides a web interface for users to search and manage songs.

## Tech Stack (Global)

- **Monorepo Manager:** TurboRepo
- **Package Manager:** pnpm (@9.0.0)
- **Language:** TypeScript (v5.8.2)
- **Core Framework:** React 19
- **Engines:** Node.js >= 18

## Project Structure

The project follows a standard pnpm workspace structure:

- **`apps/web/`**: The main user-facing web application (Next.js).
- **`packages/`**: Shared libraries and configurations.
- **`crawling/`**: Scripts and logic for crawling song data (DB input).
- **`open-api/`**: Internal API module for providing karaoke numbers (Domestic songs).
- **`query/`**: Shared TanStack Query hooks and configurations.
- **`ui/`**: Shared UI components (Design System).
- **`eslint-config/`**: Shared ESLint configurations.
- **`typescript-config/`**: Shared `tsconfig` bases.

## Development Workflow (TurboRepo)

Use the following commands from the root directory:

- **`pnpm dev`**: Starts the development server for all apps (runs `turbo run dev`).
- **`pnpm dev-web`**: Starts only the web application (`turbo run dev --filter=web`).
- **`pnpm build`**: Builds all apps and packages.
- **`pnpm lint`**: Runs linting across the workspace.
- **`pnpm format`**: Formats code using Prettier.
- **`pnpm check-types`**: Runs TypeScript type checking.

## Key Conventions

1. **Workspace Dependencies**: Packages utilize `workspace:*` to reference internal packages (e.g., `@repo/ui`).
2. **React 19**: All applications and UI packages are compatible with React 19.
3. **Strict Typing**: All code must be strictly typed via TypeScript.

Context is in English, but please answer in Korean.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ sing-code/
- 2025.6.17 : github action schedule 활용하여 매일마다 TJ 최신곡을 DB에 업데이트하는 프로세스 구축
- 2025.6.18 : 버전 1.6.0 배포. 회원탈퇴 기능 추가. mac 환경 이슈 해결.
- 2025.10.26 : 버전 1.8.0 배포. 최근곡 기능 추가. 비동기 요청 포기, isPending으로 제어
- 2026.1.4 : 버전 1.9.0 배포. OPENAI 활용 챗봇 기능 추가.
- 2026.1.27 : 버전 2.0.0 배포. DB 재설계 및 로직 리펙토링. 출석 체크, 유저 별 포인트, 곡 추천 기능 추가.

## 📝 회고

Expand Down
40 changes: 40 additions & 0 deletions apps/web/GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Web Application Context (`apps/web`)

## Overview

This is the main Next.js web application for SingCode. It serves as the frontend client for searching songs, viewing lyrics, and user interaction.

## Tech Stack

- **Framework:** Next.js 15.2.7 (App Router)
- **Language:** TypeScript
- **Styling:** Tailwind CSS v4, `tailwind-merge`, `clsx`, `class-variance-authority` (CVA).
- **UI Components:** Radix UI Primitives, Lucide React (Icons).
- **State Management:**
- **Server State:** TanStack Query (`@repo/query`, v5).
- **Client Global State:** Zustand.
- **Local State:** React Hooks (`useState`, `useReducer`).
- **Backend & Auth:** Supabase (Auth, DB, SSR).
- **Animations:** GSAP, Motion (Framer Motion), Lottie, `tw-animate-css`.
- **Utilities:** `date-fns`, `immer`, `axios`.

## Key Features & Libraries

- **Drag & Drop:** `@dnd-kit` is used for interaction.
- **Physics Engine:** `matter-js` is used for specific visual effects.
- **AI Integration:** `openai` SDK is integrated for AI-related features.
- **Analytics:** PostHog, Vercel Analytics/Speed Insights.

## Coding Conventions & Guidelines

### 1. Component Structure

- Use **Functional Components** with TypeScript interfaces for props.
- Use `shadcn/ui` patterns: Combine Radix UI primitives with Tailwind CSS.
- Use `cn()` utility (clsx + tailwind-merge) for conditional class names.
```tsx
// Example
<div className={cn('bg-white p-4', className)}>...</div>
```

Context is in English, but please answer in Korean.
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.9.1",
"version": "2.0.1",
"type": "module",
"private": true,
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,12 @@
"포인트를 사용해 곡을 추천할 수 있습니다.",
"인기곡 페이지에서는 추천곡 순위를 확인할 수 있습니다."
]
},
"2.0.1": {
"title": "버전 2.0.1",
"message": [
"로컬 스토리지 저장 기능을 개선했습니다.",
"검색 카드 디자인 및 기능을 개선했습니다."
]
}
}
31 changes: 16 additions & 15 deletions apps/web/src/app/search/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSearchHistory } from '@/hooks/useSearchHistory';
import useSearchSong from '@/hooks/useSearchSong';
import { type ChatMessage } from '@/lib/api/openAIchat';
import { useSearchHistoryStore } from '@/stores/useSearchHistoryStore';
import { SearchSong } from '@/types/song';
import { ChatResponseType } from '@/utils/safeParseJson';

Expand Down Expand Up @@ -57,7 +57,7 @@ export default function SearchPage() {
searchSongs = searchResults.pages.flatMap(page => page.data);
}

const { searchHistory, removeFromHistory } = useSearchHistory();
const { searchHistory, removeFromHistory } = useSearchHistoryStore();

// 엔터 키 처리
const handleKeyUp = (e: React.KeyboardEvent) => {
Expand All @@ -66,16 +66,6 @@ export default function SearchPage() {
}
};

useEffect(() => {
const timeout = setTimeout(() => {
if (inView && hasNextPage && !isFetchingNextPage && !isError) {
fetchNextPage();
}
}, 1000); // 1000ms 정도 지연

return () => clearTimeout(timeout);
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isError]);

const handleSearchClick = () => {
if (!search.trim()) {
toast.error('검색어를 입력해주세요.');
Expand All @@ -100,6 +90,16 @@ export default function SearchPage() {
}
};

useEffect(() => {
const timeout = setTimeout(() => {
if (inView && hasNextPage && !isFetchingNextPage && !isError) {
fetchNextPage();
}
}, 1000); // 1000ms 정도 지연

return () => clearTimeout(timeout);
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isError]);

return (
<div className="bg-background">
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -157,9 +157,9 @@ export default function SearchPage() {
</div>
)}
</div>
<ScrollArea className="h-[calc(100vh-24rem)]">
<div className="h-[calc(100vh-24rem)] overflow-x-hidden overflow-y-auto">
{searchSongs.length > 0 && (
<div className="flex w-full max-w-md flex-col gap-4 py-4">
<div className="flex w-full max-w-md flex-col gap-4 p-4">
{searchSongs.map((song, index) => (
<SearchResultCard
key={song.artist + song.title + index}
Expand All @@ -169,6 +169,7 @@ export default function SearchPage() {
}
onToggleLike={() => handleToggleLike(song.id, song.isLike ? 'DELETE' : 'POST')}
onClickSave={() => handleToggleSave(song, song.isSave ? 'PATCH' : 'POST')}
onClickArtist={() => setSearch(song.artist)}
/>
))}
{hasNextPage && !isFetchingNextPage && (
Expand Down Expand Up @@ -197,7 +198,7 @@ export default function SearchPage() {
<p className="m-2">노래 제목이나 가수를 검색해보세요</p>
</div>
)}
</ScrollArea>
</div>

{selectedSaveSong && (
<AddFolderModal
Expand Down
19 changes: 13 additions & 6 deletions apps/web/src/app/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ interface IProps {
onToggleToSing: () => void;
onToggleLike: () => void;
onClickSave: () => void;
onClickArtist: () => void;
}

export default function SearchResultCard({
song,
onToggleToSing,
onToggleLike,
onClickSave,
onClickArtist,
}: IProps) {
const { id, title, artist, num_tj, num_ky, isToSing, isLike, isSave } = song;
const { isAuthenticated } = useAuthStore();
Expand All @@ -36,16 +38,21 @@ export default function SearchResultCard({
};

return (
<Card className="relative overflow-hidden">
<Card className="w-full overflow-hidden p-4">
{/* 메인 콘텐츠 영역 */}
<div className="h-[150px] w-full gap-4 p-3">
<div className="gap-4">
{/* 노래 정보 */}
<div className="mb-8 flex flex-col">
{/* 제목 및 가수 */}
<div className="mb-1 flex justify-between pr-6">
<div>
<div className="mb-1 flex justify-between pr-2">
<div className="w-[calc(100%-40px)]">
<h3 className="truncate text-base font-medium">{title}</h3>
<p className="text-muted-foreground truncate text-sm">{artist}</p>
<span
className="text-muted-foreground cursor-pointer truncate text-sm hover:underline"
onClick={onClickArtist}
>
{artist}
</span>
</div>

<Dialog open={open} onOpenChange={setOpen}>
Expand Down Expand Up @@ -79,7 +86,7 @@ export default function SearchResultCard({
</div>

{/* 버튼 영역 - 우측 하단에 고정 */}
<div className="absolute bottom-3 flex w-full space-x-2 pr-6">
<div className="flex w-full space-x-2">
<Button
variant="ghost"
size="icon"
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/reactBits/CountUp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function CountUp({
startWhen = true,
separator = '',
onStart,
onEnd
onEnd,
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === 'down' ? to : from);
Expand All @@ -34,7 +34,7 @@ export default function CountUp({

const springValue = useSpring(motionValue, {
damping,
stiffness
stiffness,
});

const isInView = useInView(ref, { once: true, margin: '0px' });
Expand All @@ -59,14 +59,14 @@ export default function CountUp({
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
};

const formattedNumber = Intl.NumberFormat('en-US', options).format(latest);

return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
},
[maxDecimals, separator]
[maxDecimals, separator],
);

useEffect(() => {
Expand All @@ -91,7 +91,7 @@ export default function CountUp({
onEnd();
}
},
delay * 1000 + duration * 1000
delay * 1000 + duration * 1000,
);

return () => {
Expand Down
31 changes: 20 additions & 11 deletions apps/web/src/components/reactBits/GradientText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef, ReactNode } from 'react';
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';

interface GradientTextProps {
children: ReactNode;
Expand All @@ -20,7 +20,7 @@ export default function GradientText({
showBorder = false,
direction = 'horizontal',
pauseOnHover = false,
yoyo = true
yoyo = true,
}: GradientTextProps) {
const [isPaused, setIsPaused] = useState(false);
const progress = useMotionValue(0);
Expand Down Expand Up @@ -84,41 +84,50 @@ export default function GradientText({
}, [pauseOnHover]);

const gradientAngle =
direction === 'horizontal' ? 'to right' : direction === 'vertical' ? 'to bottom' : 'to bottom right';
direction === 'horizontal'
? 'to right'
: direction === 'vertical'
? 'to bottom'
: 'to bottom right';
// Duplicate first color at the end for seamless looping
const gradientColors = [...colors, colors[0]].join(', ');

const gradientStyle = {
backgroundImage: `linear-gradient(${gradientAngle}, ${gradientColors})`,
backgroundSize: direction === 'horizontal' ? '300% 100%' : direction === 'vertical' ? '100% 300%' : '300% 300%',
backgroundRepeat: 'repeat'
backgroundSize:
direction === 'horizontal'
? '300% 100%'
: direction === 'vertical'
? '100% 300%'
: '300% 300%',
backgroundRepeat: 'repeat',
};

return (
<motion.div
className={`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${showBorder ? 'py-1 px-2' : ''} ${className}`}
className={`relative mx-auto flex max-w-fit cursor-pointer flex-row items-center justify-center overflow-hidden rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 ${showBorder ? 'px-2 py-1' : ''} ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showBorder && (
<motion.div
className="absolute inset-0 z-0 pointer-events-none rounded-[1.25rem]"
className="pointer-events-none absolute inset-0 z-0 rounded-[1.25rem]"
style={{ ...gradientStyle, backgroundPosition }}
>
<div
className="absolute bg-black rounded-[1.25rem] z-[-1]"
className="absolute z-[-1] rounded-[1.25rem] bg-black"
style={{
width: 'calc(100% - 2px)',
height: 'calc(100% - 2px)',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
transform: 'translate(-50%, -50%)',
}}
/>
</motion.div>
)}
<motion.div
className="inline-block relative z-2 text-transparent bg-clip-text"
className="relative z-2 inline-block bg-clip-text text-transparent"
style={{ ...gradientStyle, backgroundPosition, WebkitBackgroundClip: 'text' }}
>
{children}
Expand Down
Loading