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.
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">
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScrollArea๋ฅผ ์ผ๋ฐ˜ div๋กœ ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค. ํ”„๋กœ์ ํŠธ ์ „๋ฐ˜์—์„œ ScrollArea ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋Š” ํŒจํ„ด(apps/web/src/app/info/like/page.tsx:46, apps/web/src/app/info/save/page.tsx:194, apps/web/src/app/popular/page.tsx:22, apps/web/src/app/tosing/page.tsx:29)๊ณผ ์ผ๊ด€์„ฑ์ด ๋–จ์–ด์ง‘๋‹ˆ๋‹ค. ํŠน๋ณ„ํ•œ ์ด์œ ๊ฐ€ ์—†๋‹ค๋ฉด ScrollArea ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ณ„์† ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

Copilot uses AI. Check for mistakes.
{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>
Comment on lines +50 to +55
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๊ฐ€์ˆ˜๋ช…์„ ํด๋ฆญํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. button ๋˜๋Š” role="button"์„ ์‚ฌ์šฉํ•˜๊ณ  ํ‚ค๋ณด๋“œ ์ ‘๊ทผ์„ฑ(onKeyDown ๋˜๋Š” onKeyPress)์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ๋Š” ๋งˆ์šฐ์Šค ํด๋ฆญ๋งŒ ๊ฐ€๋Šฅํ•˜์—ฌ ํ‚ค๋ณด๋“œ ์‚ฌ์šฉ์ž๊ฐ€ ์ด ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

Copilot uses AI. Check for mistakes.
</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