Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
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
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

**Singcode** is a Korean karaoke song management service (singcode.kr). This is a pnpm workspace monorepo managed with Turborepo.

## Commands

Run from the **repo root** to target all workspaces:

```bash
pnpm dev # Start all dev servers via Turbo
pnpm dev-web # Start only the web app dev server
pnpm build # Build all packages
pnpm lint # Lint all packages
pnpm format # Prettier format all packages
pnpm check-types # TypeScript type-check all packages
```

Run from **`apps/web/`** for web-only work:

```bash
pnpm dev # Next.js dev server with Turbopack (http://localhost:3000)
pnpm build # Production build + next-sitemap postbuild
pnpm lint # ESLint
pnpm format # Prettier format .ts, .tsx, .md
```

No test suite is configured.

## Monorepo Structure

```
apps/
web/ โ€” Next.js 15 web app (primary app, see apps/web/CLAUDE.md)
mobile/ โ€” Expo React Native app (early stage)
packages/
open-api/ โ€” Wrapper around the external karaoke open API (@repo/open-api)
query/ โ€” Shared TanStack Query hooks for open-api (@repo/query)
api/ โ€” Internal API utilities (@repo/api), built with tsup
ui/ โ€” Shared UI components (@repo/ui)
eslint-config/ โ€” Shared ESLint config (@repo/eslint-config)
format-config/ โ€” Shared Prettier config (@repo/format-config)
typescript-config/ โ€” Shared tsconfig bases
crawling/ โ€” One-off data crawling scripts (not a published package)
```

## Web App Architecture

See [apps/web/CLAUDE.md](apps/web/CLAUDE.md) for full detail. Key points:

- **Next.js 15 App Router** + React 19, deployed on Vercel
- **BFF pattern**: client โ†’ internal API routes (`/api/*`) โ†’ Supabase / external karaoke API. Never call Supabase or external APIs directly from the browser.
- **Supabase** (`@supabase/ssr`) for auth and database; three client variants (browser, server/route handler, middleware)
- **TanStack Query** for server state; **Zustand** for client state
- **Tailwind CSS v4** + **shadcn/ui** in `src/components/ui/` (do not modify directly)
- Path alias `@/` โ†’ `src/`

## Git Conventions

Branch format: `<type>/<camelCaseName>` โ€” flow: `feat/*` โ†’ `develop` โ†’ `main`

Commit format: `<type> : <Korean description>` (space before and after colon)

Types: `feat`, `fix`, `hotfix`, `chore`, `refactor`, `doc`

Examples:
```
feat : MarqueeText ์ž๋™ ์Šคํฌ๋กค ํ…์ŠคํŠธ ์ ์šฉ
fix : SongCard css ์ˆ˜์ •
chore : ๋ฒ„์ „ 2.3.0
```
145 changes: 145 additions & 0 deletions apps/web/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**Singcode** is a Korean karaoke song management web app (singcode.kr). Users can search for songs by title/singer/number, save songs to folders, track liked songs, and manage their "to-sing" list. The app uses Supabase for auth and database, and an external karaoke open API via the `@repo/open-api` workspace package.

## Commands

All commands run from `apps/web/`:

```bash
pnpm dev # Start dev server with Turbopack (http://localhost:3000)
pnpm build # Production build (also runs next-sitemap postbuild)
pnpm lint # ESLint
pnpm format # Prettier format all .ts, .tsx, .md files
pnpm start # Start production server
```

No test suite is configured.

## Architecture

### Monorepo Structure

This is a pnpm workspace monorepo. The web app lives at `apps/web/`. Key workspace packages:
- `@repo/open-api` โ€” wrapper around the external karaoke open API (used in API routes)
- `@repo/query` โ€” shared TanStack Query setup
- `@repo/eslint-config`, `@repo/format-config` โ€” shared tooling configs

### Tech Stack

- **Next.js 15** (App Router) with React 19
- **Supabase** (`@supabase/ssr`) for auth and database
- **TanStack Query** for server state
- **Zustand** for client state
- **Tailwind CSS v4** + **shadcn/ui** components in `src/components/ui/`
- **Axios** with a base `/api` instance in `src/lib/api/client.ts`
- **Framer Motion / GSAP** for animations

### Data Flow Pattern

1. **API routes** (`src/app/api/`) act as a BFF layer โ€” client code calls these via Axios, never directly to Supabase or external APIs from the browser.
2. **API route handlers** use `src/lib/supabase/server.ts` (for Next.js route handlers) to get an authenticated Supabase client, then call `src/utils/getAuthenticatedUser.ts` to extract `userId`.
3. **Client-side API functions** live in `src/lib/api/` (e.g., `likeSong.ts`, `saveSong.ts`). They call internal Next.js API routes via the Axios instance.
4. **TanStack Query hooks** live in `src/queries/` โ€” they wrap the `src/lib/api/` functions.

### Supabase Client Variants

Three different Supabase clients for different contexts:
- `src/lib/supabase/client.ts` โ€” browser client (`createBrowserClient`), uses `NEXT_PUBLIC_` env vars
- `src/lib/supabase/server.ts` โ€” server/route handler client (`createServerClient`)
- `src/lib/supabase/api.ts` โ€” legacy API routes client (Next.js Pages-style `req/res`)
- `src/lib/supabase/middleware.ts` โ€” middleware client (`updateSession`)

### Auth

`src/auth.tsx` is a client-side `AuthProvider` wrapping the app. It checks auth on every route change via `useAuthStore`. Public routes (no auth required): `/`, `/popular`, `/login`, `/signup`, `/recent`, `/tosing`, `/update-password`. All other routes redirect to `/login?alert=login`.

### State Management (Zustand)

- `src/stores/useModalStore.ts` โ€” global message dialog state (use `openMessage`/`closeMessage`)
- `src/stores/useSearchHistoryStore.ts` โ€” search history persisted to `localStorage`
- `src/stores/middleware.ts` โ€” shared Zustand middleware
- Auth state is in `useAuthStore` (referenced but not shown above)

### Pages / Routes

- `/` โ†’ Song search (main feature, `src/app/search/HomePage.tsx`)
- `/popular` โ†’ Popular songs
- `/recent` โ†’ Recently added songs
- `/tosing` โ†’ User's "to-sing" list
- `/info` โ†’ User profile/info
- `/info/like` โ†’ Liked songs
- `/info/save` โ†’ Saved song folders (supports drag-and-drop via `@dnd-kit`)
- `/login`, `/signup`, `/update-password`, `/withdrawal` โ†’ Auth flows

### External Song Search API

Song searches go through `GET /api/open_songs/[type]/[param]` which proxies to `@repo/open-api`. Search types: `title`, `singer`, `composer`, `lyricist`, `no`, `release`, `popular`. The `brand` query param selects the karaoke brand.

### AI Chat

`POST /api/chat` proxies to OpenAI (using the `openai` package). Client-side in `src/lib/api/openAIchat.ts`, UI in `src/app/search/ChatBot.tsx`.

## Environment Variables

Required in `.env` / `.env.development.local`:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_URL` (server-only, used in legacy API client)
- `SUPABASE_ANON_KEY` (server-only)
- OpenAI key for chat feature

## Git Conventions

### Branch Naming

Format: `<type>/<camelCaseName>`

| type | usage |
|------|-------|
| `feat` | new feature |
| `fix` | bug fix |
| `hotfix` | urgent fix |
| `chore` | maintenance, docs, config |
| `release` | release (e.g. `release/2.1.0`) |

The part after the slash uses camelCase (e.g. `feat/scrollText`, `feat/FooterNavbar`, `fix/loginAuth`).

Branch flow: `feat/*` โ†’ `develop` โ†’ `main`

### Commit Messages

Format: `<type> : <Korean description>` โ€” one space before and after the colon.

| type | usage |
|------|-------|
| `feat` | new feature |
| `fix` | bug fix |
| `hotfix` | urgent bug fix |
| `chore` | version bump, config, format, cleanup |
| `refactor` | refactoring |
| `doc` | documentation |

Examples:
```
feat : MarqueeText ์ž๋™ ์Šคํฌ๋กค ํ…์ŠคํŠธ ์ ์šฉ
fix : SongCard css ์ˆ˜์ •
hotfix : ๋นŒ๋“œ ์—๋Ÿฌ ์ˆ˜์ •. thumb ํ•„๋“œ ์˜ต์…”๋„ ํƒ€์ž… ์กฐ์ •.
chore : ๋ฒ„์ „ 2.3.0
refactor : useSearchSong ํ›… ๋ถ„๋ฆฌ - ๊ณก ๋ชจ๋‹ฌ ์ €์žฅ ๋ถ„๋ฆฌ
```

## Key Conventions

- Path alias `@/` maps to `src/`
- `src/utils/cn.ts` โ€” `clsx` + `tailwind-merge` utility for className merging
- `src/utils/isSuccessResponse.ts` โ€” type guard for API responses
- `src/utils/getErrorMessage.ts` โ€” standardized error message extraction
- shadcn/ui components are in `src/components/ui/` and should not be modified directly
- `src/components/reactBits/` โ€” custom animation components (AnimatedContent, SplitText, GradientText, Shuffle)
- Global toast notifications use `sonner` (`<Toaster>` in layout)
- Global modal dialog uses `useModalStore` + `<MessageDialog>` in layout
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev --turbopack -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/info/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function LibraryPage() {
<div className="flex items-center gap-1 text-2xl font-bold text-[#FFC300]">
<CircleDollarSign />

<GradientText className="text-2xl" colors={['#FFC300', '#FFF59D', '#FB8C00']}>
<GradientText className="text-2xl" colors={['#FFD700', '#FFA000', '#E65100']}>
<CountUp to={point} duration={0.2} />
</GradientText>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/search/ChatBot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default function ChatBot({ setInputSearch }: ChatBotProps) {
};

return (
<div className="fixed right-4 bottom-10 z-50 flex flex-col items-end gap-3 sm:right-6 sm:bottom-6">
<div className="fixed right-10 bottom-20 z-50 flex flex-col items-end gap-3 sm:right-6 sm:bottom-6">
{isOpen && (
<div className="bg-background animate-in slide-in-from-bottom-5 fade-in-0 flex h-[500px] w-[calc(100vw-4rem)] max-w-[400px] flex-col rounded-lg border shadow-2xl duration-300 sm:h-[600px]">
{/* ํ—ค๋” */}
Expand Down
45 changes: 36 additions & 9 deletions apps/web/src/app/search/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import { useInView } from 'react-intersection-observer';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
// import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
// import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import useSaveSongModal from '@/hooks/useSaveSongModal';
import useSearchSong from '@/hooks/useSearchSong';
import useGuestToSingStore from '@/stores/useGuestToSingStore';
import { SearchSong } from '@/types/song';

import AddFolderModal from './AddFolderModal';
import ChatBot from './ChatBot';
// import ChatBot from './ChatBot';
import JpnArtistList from './JpnArtistList';
import SearchAutocomplete from './SearchAutocomplete';
import SearchHistory from './SearchHistory';
Expand Down Expand Up @@ -55,6 +57,11 @@ export default function SearchPage() {

const [isJpnArtistModalOpen, setIsJpnArtistModalOpen] = useState(false);
const [isFocusAuto, setIsFocusAuto] = useState(false);
// const [isChatBotEnabled, setIsChatBotEnabled] = useState(() => {
// if (typeof window === 'undefined') return true;
// const stored = localStorage.getItem('chatbot-enabled');
// return stored === null ? true : stored === 'true';
// });

const [scrollRef, setScrollRef] = useState<HTMLDivElement | null>(null);
const { ref, inView } = useInView({
Expand All @@ -64,6 +71,11 @@ export default function SearchPage() {

const { guestToSingSongs } = useGuestToSingStore();

// const handleToggleChatBot = (checked: boolean) => {
// setIsChatBotEnabled(checked);
// localStorage.setItem('chatbot-enabled', String(checked));
// };

const isToSing = (song: SearchSong, songId: string) => {
if (!isAuthenticated) {
return guestToSingSongs?.some(item => item.songs.id === songId);
Expand Down Expand Up @@ -138,12 +150,27 @@ export default function SearchPage() {
</span>
)}
</div>
<JpnArtistList
open={isJpnArtistModalOpen}
onOpenChange={setIsJpnArtistModalOpen}
onSelectArtist={setSearch}
callback={() => handleSearchTypeChange('artist')}
/>
<div className="flex flex-col items-end gap-2">
<JpnArtistList
open={isJpnArtistModalOpen}
onOpenChange={setIsJpnArtistModalOpen}
onSelectArtist={setSearch}
callback={() => handleSearchTypeChange('artist')}
/>
{/* <div className="flex items-center gap-2">
<Checkbox
id="chatbot-toggle"
checked={isChatBotEnabled}
onCheckedChange={handleToggleChatBot}
/>
<Label
htmlFor="chatbot-toggle"
className="text-muted-foreground cursor-pointer text-xs"
>
AI ์ฑ—๋ด‡
</Label>
</div> */}
</div>
</div>

<Tabs defaultValue="all" value={searchType} onValueChange={handleSearchTypeChange}>
Expand Down Expand Up @@ -182,7 +209,7 @@ export default function SearchPage() {
{/* ๊ฒ€์ƒ‰ ๊ธฐ๋ก */}
<SearchHistory onHistoryClick={handleHistoryClick} />
</div>
<div ref={setScrollRef} className="h-[calc(100vh-24rem)] overflow-x-hidden overflow-y-auto">
<div ref={setScrollRef} className="h-[calc(100vh-26rem)] overflow-x-hidden overflow-y-auto">
{searchSongs.length > 0 && (
<div className="flex w-full max-w-md flex-col gap-4 p-4">
{searchSongs.map((song, index) => (
Expand Down Expand Up @@ -239,7 +266,7 @@ export default function SearchPage() {
)}

{/* ์ฑ—๋ด‡ ์œ„์ ฏ */}
<ChatBot setInputSearch={setSearch} />
{/* {isChatBotEnabled && <ChatBot setInputSearch={setSearch} />} */}
</div>
);
}
12 changes: 9 additions & 3 deletions apps/web/src/app/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default function SearchResultCard({
{/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  ์˜์—ญ */}
<div className="flex flex-col gap-4">
{/* ๋…ธ๋ž˜ ์ •๋ณด */}
<div className="flex flex-col">
<div className="flex flex-col gap-2">
{/* ์ œ๋ชฉ ๋ฐ ๊ฐ€์ˆ˜ */}
<div className="flex justify-between">
<div className="flex w-[calc(100%-40px)] flex-col truncate">
Expand Down Expand Up @@ -102,7 +102,10 @@ export default function SearchResultCard({
</div>

{/* ๋…ธ๋ž˜๋ฐฉ ๋ฒˆํ˜ธ */}
<div className="flex items-center justify-between">
<div
className="hover:bg-muted/40 active:bg-muted/60 flex cursor-pointer items-center justify-between rounded-md border-b p-1 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex space-x-4">
<div className="flex w-[70px] items-center">
<span className="text-brand-tj mr-1 text-xs font-bold">TJ</span>
Expand All @@ -117,7 +120,10 @@ export default function SearchResultCard({
<Button
variant="ghost"
className="h-10 w-10"
onClick={() => setIsExpanded(!isExpanded)}
onClick={e => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
<ChevronDown
className={`h-5 w-5 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
Expand Down
Loading