Compare commits
16 Commits
5ace251b58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a67a9d812 | |||
| 677012a9c8 | |||
| 468ee84687 | |||
| 39025fc57b | |||
| 7cd63a3868 | |||
| 895b33d83d | |||
| 2aa424f3ce | |||
| 0742059db2 | |||
| 7100842179 | |||
| da33254076 | |||
| a1a281d059 | |||
| a5b47a0278 | |||
| d5be617eb2 | |||
| abec100a73 | |||
| fc55e6a928 | |||
| 4f41f09a8c |
13
CLAUDE.md
13
CLAUDE.md
@@ -21,10 +21,11 @@
|
||||
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
|
||||
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
||||
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
||||
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
|
||||
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 |
|
||||
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
|
||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||
|
||||
## 숨김 서비스 (admin_token 세션 전용)
|
||||
@@ -33,7 +34,6 @@ admin/services 패널에서 ON/OFF 전환 가능.
|
||||
|
||||
| 경로 | 서비스 |
|
||||
|------|--------|
|
||||
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
||||
| `/gyeol` | CONTOUR PMF 설문 |
|
||||
|
||||
## 기술 스택
|
||||
@@ -93,9 +93,12 @@ app/
|
||||
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
||||
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
||||
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
|
||||
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
|
||||
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만)
|
||||
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
|
||||
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
|
||||
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
||||
music/ — 숨김: 음악 팩 (packs는 /products로 308)
|
||||
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
|
||||
gyeol/ — 숨김: CONTOUR PMF 설문
|
||||
|
||||
lib/
|
||||
@@ -116,6 +119,8 @@ lib/
|
||||
shuffle.ts — 셔플·3카드 드로우 로직
|
||||
reference.ts — 카드 의미 레퍼런스
|
||||
prompt.ts — AI 해석 프롬프트
|
||||
music/
|
||||
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -225,7 +230,7 @@ lib/
|
||||
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
|
||||
|
||||
> **공개 서비스 — 로그인 시 AI 해석 무료(1회/일)**
|
||||
> 결과 화면 `--jsm` 라이트 재스킨 완료(2026-07-03) — 디자인 가드레일 준수
|
||||
> 전 화면(랜딩·입력·결과) `--jsm` 라이트 재스킨 완료(2026-07-03) — 디자인 가드레일 준수
|
||||
|
||||
### AI 연동 (`app/api/saju/analyze/route.ts`)
|
||||
- **AI**: Google Gemini (`@google/generative-ai`)
|
||||
|
||||
@@ -51,7 +51,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICES = [
|
||||
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
|
||||
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
|
||||
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
|
||||
];
|
||||
|
||||
9
app/api/studio/callback/route.ts
Normal file
9
app/api/studio/callback/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// Suno webhook 수신용 최소 엔드포인트.
|
||||
// 트랙 저장은 폴링 + 클라이언트 트리거(/api/studio/tracks)가 담당하므로 여기서는 200만 반환한다.
|
||||
export async function POST() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -13,6 +16,23 @@ type GenerateBody = {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 — 로그인 사용자만 (Suno API 무단 호출 방지)
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) 일일 제한
|
||||
const admin = createAdminClient();
|
||||
const used = await getTodayUsage(admin, user.id, 'music');
|
||||
if (used >= MUSIC_DAILY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org';
|
||||
const apiKey = process.env.SUNO_API_KEY;
|
||||
|
||||
@@ -69,6 +89,11 @@ export async function POST(request: Request) {
|
||||
{ status: res.ok ? 502 : res.status },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await recordUsage(admin, user.id, 'music');
|
||||
} catch {
|
||||
/* 집계 실패는 무시 — 생성은 이미 성공 */
|
||||
}
|
||||
return NextResponse.json({ ok: true, data });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
|
||||
115
app/api/studio/story/route.ts
Normal file
115
app/api/studio/story/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
STORY_SYSTEM_PROMPT,
|
||||
buildStoryUserMessage,
|
||||
parseStoryJson,
|
||||
validateStory,
|
||||
} from '@/lib/music/story-prompt';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
// 모델 우선순위 — 사주 analyze·타로 interpret와 동일 폴백 목록(이 API 키로 접근 가능한 모델만)
|
||||
const MODELS = [
|
||||
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
|
||||
] as const;
|
||||
|
||||
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
|
||||
const TIME_BUDGET_MS = 45_000;
|
||||
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
|
||||
// 일일 사용량 집계·제한은 generate 단계에서만 수행 — story는 가사 초안 생성일 뿐이라 미집계.
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) 입력 검증
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
|
||||
}
|
||||
const story = typeof body.story === 'string' ? body.story.trim() : '';
|
||||
if (!story) {
|
||||
return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3) API 키
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn('[스튜디오] GEMINI_API_KEY 미설정 — 503 반환 (예시 가사 반환 금지, 데이터 오염 방지)');
|
||||
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
}
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const userMessage = buildStoryUserMessage(story);
|
||||
|
||||
// 4) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
|
||||
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
|
||||
const startedAt = Date.now();
|
||||
let feedback = '';
|
||||
let attempts = 0;
|
||||
|
||||
modelLoop:
|
||||
for (const { id: modelId, maxTokens } of MODELS) {
|
||||
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
|
||||
for (let retry = 0; retry < 2; retry += 1) {
|
||||
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
|
||||
break modelLoop;
|
||||
}
|
||||
attempts += 1;
|
||||
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelId,
|
||||
systemInstruction: STORY_SYSTEM_PROMPT,
|
||||
generationConfig: {
|
||||
temperature: 0.9,
|
||||
topP: 0.95,
|
||||
maxOutputTokens: maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = feedback
|
||||
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
|
||||
: userMessage;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text();
|
||||
const parsed = parseStoryJson(text);
|
||||
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
|
||||
|
||||
if (parsed && !invalid) {
|
||||
return NextResponse.json({ story: parsed });
|
||||
}
|
||||
|
||||
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (modelError) {
|
||||
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
|
||||
feedback = modelError instanceof Error ? modelError.message : 'model error';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
49
app/api/studio/tracks/route.ts
Normal file
49
app/api/studio/tracks/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
|
||||
}
|
||||
|
||||
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin.from('music_tracks').insert({
|
||||
user_id: user.id,
|
||||
title: str('title'),
|
||||
story: str('story'),
|
||||
lyrics: str('lyrics'),
|
||||
style: str('style'),
|
||||
audio_url: str('audio_url'),
|
||||
task_id: str('task_id'),
|
||||
}).select('id, created_at').single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
// 세션 클라이언트로 본인 것만(RLS music_select_own)
|
||||
const { data, error } = await supabase
|
||||
.from('music_tracks')
|
||||
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ tracks: data ?? [] });
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const LINKS = [
|
||||
{ href: '/showcase', label: '제작 사례' },
|
||||
{ href: '/work/saju', label: '사주' },
|
||||
{ href: '/tarot', label: '타로' },
|
||||
{ href: '/music', label: '음악' },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { isServiceVisible } from '@/lib/service-visibility';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 음악 제품',
|
||||
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.',
|
||||
title: '나의 이야기를 음악으로',
|
||||
description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
|
||||
};
|
||||
|
||||
export default async function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!(await isServiceVisible('music'))) notFound();
|
||||
export default function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -2,67 +2,60 @@ import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Music — AI 음악 제품',
|
||||
title: '음악 — 나의 이야기를 음악으로',
|
||||
};
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
href: '/music/packs',
|
||||
label: '팩 상세',
|
||||
desc: '입문 ₩39,000부터 — Suno 프롬프트북 + 뮤비 워크플로우 + SEO 템플릿',
|
||||
key: 'packs',
|
||||
href: '/music/studio',
|
||||
label: 'AI 스튜디오',
|
||||
desc: '스토리를 입력하면 가사·음악을 자동 생성 — 로그인 무료',
|
||||
key: 'studio',
|
||||
},
|
||||
{
|
||||
href: '/music/samples',
|
||||
label: '샘플 갤러리',
|
||||
desc: '실제 결과물 — 장르별 데모 + 가사 + 영상 미리보기',
|
||||
desc: '실제 결과물 — 장르별 데모와 가사',
|
||||
key: 'samples',
|
||||
},
|
||||
{
|
||||
href: '/music/studio',
|
||||
label: 'AI 스튜디오',
|
||||
desc: 'Suno API 연동 — 직접 트랙 생성 (베타)',
|
||||
key: 'studio',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MusicHub() {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#060e20] to-black pointer-events-none" />
|
||||
<div className="min-h-screen bg-[var(--jsm-bg)]">
|
||||
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 bg-[var(--jsm-navy)]">
|
||||
<div className="relative z-10 max-w-3xl mx-auto text-center">
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-[var(--jsm-accent-soft)] mb-4">
|
||||
Music
|
||||
</p>
|
||||
<h1
|
||||
className="kx-display text-4xl md:text-6xl font-bold mb-5"
|
||||
className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white"
|
||||
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
|
||||
>
|
||||
AI 음악 제품
|
||||
나의 이야기를 음악으로
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
|
||||
Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿. 한 팩에 담긴 4단계 워크플로우로 1시간 안에 결과물 완성.
|
||||
당신의 이야기를 들려주면 AI가 가사와 음악으로 만들어 드립니다. 로그인하면 무료로 만들고 보관하세요.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{CARDS.map((c) => (
|
||||
<Link
|
||||
key={c.key}
|
||||
href={c.href}
|
||||
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-7 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col"
|
||||
className="rounded-2xl border border-[var(--jsm-line)] bg-[var(--jsm-surface)] p-7 transition-colors hover:border-[var(--jsm-accent)] hover:bg-[var(--jsm-surface-alt)] flex flex-col"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<h2 className="kx-display text-xl md:text-2xl font-bold text-white mb-3">
|
||||
<h2 className="kx-display text-xl md:text-2xl font-bold text-[var(--jsm-ink)] mb-3">
|
||||
{c.label}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-white/60 leading-relaxed flex-1">
|
||||
<p className="text-sm md:text-base text-[var(--jsm-ink-soft)] leading-relaxed flex-1">
|
||||
{c.desc}
|
||||
</p>
|
||||
<span aria-hidden="true" className="mt-4 text-white/40 text-xs">→</span>
|
||||
<span aria-hidden="true" className="mt-4 text-[var(--jsm-ink-faint)] text-xs">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,17 +27,17 @@ const SAMPLES: Sample[] = [
|
||||
|
||||
export default function MusicSamplesPage() {
|
||||
return (
|
||||
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--kx-surface)' }}>
|
||||
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="kx-label">SAMPLE GALLERY</span>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--jsm-ink)' }}>
|
||||
AI 음악·뮤비 샘플 모음
|
||||
</h1>
|
||||
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
팩 워크플로우로 제작된 결과물입니다. 장르별로 다양한 톤을 확인해보세요.
|
||||
<br className="hidden md:block" />
|
||||
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
일부 샘플은 런칭 직후 순차 공개됩니다.
|
||||
</span>
|
||||
</p>
|
||||
@@ -47,30 +47,36 @@ export default function MusicSamplesPage() {
|
||||
{SAMPLES.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`group relative aspect-[9/16] rounded-2xl overflow-hidden border ${
|
||||
s.featured ? 'border-violet-400/50 shadow-2xl shadow-violet-900/40' : 'border-white/10'
|
||||
className={`relative aspect-[9/16] rounded-2xl overflow-hidden border ${
|
||||
s.featured ? 'border-[var(--jsm-accent)] shadow-lg' : 'border-[var(--jsm-line)]'
|
||||
}`}
|
||||
style={{ background: 'linear-gradient(135deg, #1a0840 0%, #061228 100%)' }}
|
||||
style={{ background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/15 to-cyan-500/10 group-hover:from-violet-500/25 group-hover:to-cyan-500/20 transition-all" />
|
||||
|
||||
{s.featured && (
|
||||
<span
|
||||
className="absolute top-3 left-3 z-10 text-[10px] px-2 py-1 rounded-full font-semibold tracking-widest"
|
||||
style={{ background: 'rgba(204,151,255,0.2)', color: 'var(--kx-primary)', border: '1px solid rgba(204,151,255,0.5)' }}
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', border: '1px solid var(--jsm-accent)' }}
|
||||
>
|
||||
TOP
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-5">
|
||||
<div className="text-5xl mb-3 opacity-80 group-hover:scale-110 transition-transform">🎬</div>
|
||||
<p className="text-[10px] md:text-xs font-mono tracking-widest text-violet-300/80 mb-1">{s.genre.toUpperCase()}</p>
|
||||
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<svg
|
||||
className="w-10 h-10 mb-3 opacity-80"
|
||||
fill="none"
|
||||
stroke="var(--jsm-accent)"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 10.5l4.72-2.72a.75.75 0 011.28.53v9.38a.75.75 0 01-1.28.53l-4.72-2.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
<p className="text-[10px] md:text-xs font-mono tracking-widest mb-1" style={{ color: 'var(--jsm-accent)' }}>{s.genre.toUpperCase()}</p>
|
||||
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--kx-on-variant)' }}>{s.duration}</p>
|
||||
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--jsm-ink-soft)' }}>{s.duration}</p>
|
||||
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{s.embedId ? '영상 재생' : '영상 준비 중'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,19 +85,20 @@ export default function MusicSamplesPage() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-16 text-center p-10 kx-glass"
|
||||
style={{ border: '1px solid rgba(204,151,255,0.12)', borderRadius: '0.75rem 0.75rem 0.125rem 0.125rem' }}
|
||||
className="mt-16 text-center px-8 py-16 rounded-3xl"
|
||||
style={{ background: 'var(--jsm-navy)' }}
|
||||
>
|
||||
<span className="kx-label">NEXT</span>
|
||||
<h2 className="kx-display text-2xl md:text-3xl font-bold mt-2 mb-3" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<span className="text-[var(--jsm-accent-soft)] text-xs font-bold uppercase tracking-widest">NEXT</span>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mt-3 mb-3 text-white">
|
||||
내 채널에도 이런 쇼츠 올리고 싶다면
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-sm mb-6 text-white/70">
|
||||
동일 워크플로우 팩 ₩39,000부터.
|
||||
</p>
|
||||
<Link
|
||||
href="/music/packs#pricing"
|
||||
className="kx-btn-primary px-8 py-3.5 rounded-full text-sm inline-flex"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
style={{ color: 'var(--jsm-navy)' }}
|
||||
>
|
||||
팩 가격 보기
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Mode = 'simple' | 'custom';
|
||||
type FlowTab = 'story' | 'manual';
|
||||
type StoryStage = 'input' | 'preview';
|
||||
|
||||
type SunoClip = {
|
||||
id: string;
|
||||
@@ -23,6 +26,20 @@ type TaskState = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type TrackMeta = {
|
||||
title?: string;
|
||||
story?: string;
|
||||
lyrics?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type MusicStory = {
|
||||
title: string;
|
||||
lyrics: string;
|
||||
style: string;
|
||||
mood: string;
|
||||
};
|
||||
|
||||
const MODELS = [
|
||||
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
|
||||
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
|
||||
@@ -35,11 +52,16 @@ const TAG_PRESETS = [
|
||||
];
|
||||
|
||||
const LS_KEY = 'jsm_studio_task_ids_v2';
|
||||
const LOGIN_HREF = '/login?next=/music/studio';
|
||||
|
||||
const isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS';
|
||||
const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR';
|
||||
|
||||
const FIELD_INPUT =
|
||||
'w-full rounded-xl border border-[var(--jsm-line)] bg-white px-4 py-3 text-base text-[var(--jsm-ink)] outline-none transition focus:border-[var(--jsm-accent)]';
|
||||
|
||||
export default function StudioPage() {
|
||||
const [flowTab, setFlowTab] = useState<FlowTab>('story');
|
||||
const [mode, setMode] = useState<Mode>('simple');
|
||||
const [model, setModel] = useState('V4');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
@@ -48,11 +70,28 @@ export default function StudioPage() {
|
||||
const [tags, setTags] = useState('');
|
||||
const [instrumental, setInstrumental] = useState(false);
|
||||
|
||||
// 스토리 흐름 상태
|
||||
const [storyText, setStoryText] = useState('');
|
||||
const [storyStage, setStoryStage] = useState<StoryStage>('input');
|
||||
const [storyLoading, setStoryLoading] = useState(false);
|
||||
const [storyError, setStoryError] = useState<string | null>(null);
|
||||
const [storyAuthRequired, setStoryAuthRequired] = useState(false);
|
||||
const [mood, setMood] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [tasks, setTasks] = useState<TaskState[]>([]);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
// 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용
|
||||
const metaRef = useRef<Map<string, TrackMeta>>(new Map());
|
||||
// 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지
|
||||
const savedRef = useRef<Set<string>>(new Set());
|
||||
// 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다
|
||||
// (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지)
|
||||
const sessionTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const saveToLS = useCallback((ids: string[]) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ }
|
||||
@@ -105,10 +144,38 @@ export default function StudioPage() {
|
||||
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
||||
}, [tasks, refreshAll]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
// 완료된 트랙 자동 저장 (best-effort) — 실패해도 재생에는 영향 없음
|
||||
useEffect(() => {
|
||||
tasks.forEach((task) => {
|
||||
if (!isDone(task.status)) return;
|
||||
if (!sessionTaskIdsRef.current.has(task.taskId)) return;
|
||||
if (savedRef.current.has(task.taskId)) return;
|
||||
const clip = task.clips.find((c) => c.audioUrl || c.streamAudioUrl);
|
||||
if (!clip) return;
|
||||
|
||||
savedRef.current.add(task.taskId);
|
||||
const meta = metaRef.current.get(task.taskId);
|
||||
const audioUrl = clip.audioUrl || clip.streamAudioUrl || '';
|
||||
fetch('/api/studio/tracks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: meta?.title || clip.title || null,
|
||||
story: meta?.story || null,
|
||||
lyrics: meta?.lyrics || null,
|
||||
style: meta?.style || clip.tags || null,
|
||||
audio_url: audioUrl,
|
||||
task_id: task.taskId,
|
||||
}),
|
||||
}).catch(() => { /* 비로그인·오류 등 — 무시(best-effort) */ });
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const runGenerate = useCallback(async (forcedMode: Mode, meta: TrackMeta) => {
|
||||
setError(null);
|
||||
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (mode === 'custom') {
|
||||
setAuthRequired(false);
|
||||
if (forcedMode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (forcedMode === 'custom') {
|
||||
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
|
||||
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
|
||||
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
|
||||
@@ -119,7 +186,7 @@ export default function StudioPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode, model,
|
||||
mode: forcedMode, model,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
@@ -127,7 +194,16 @@ export default function StudioPage() {
|
||||
make_instrumental: instrumental,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setAuthRequired(true);
|
||||
setError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
setError(typeof json.error === 'string' ? json.error : '오늘 생성 가능 횟수를 모두 사용했습니다.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.ok) {
|
||||
setError(typeof json.error === 'string' ? json.error : '생성 실패');
|
||||
return;
|
||||
@@ -137,6 +213,8 @@ export default function StudioPage() {
|
||||
setError('응답에서 taskId를 찾지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
metaRef.current.set(taskId, meta);
|
||||
sessionTaskIdsRef.current.add(taskId);
|
||||
setTasks((prev) => {
|
||||
const next: TaskState[] = [
|
||||
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
|
||||
@@ -150,6 +228,65 @@ export default function StudioPage() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [prompt, title, lyrics, tags, instrumental, model, saveToLS]);
|
||||
|
||||
const onManualSubmit = () => {
|
||||
runGenerate(mode, {
|
||||
title: mode === 'custom' ? title.trim() : undefined,
|
||||
lyrics: mode === 'custom' ? lyrics.trim() : undefined,
|
||||
style: mode === 'custom' ? tags.trim() : undefined,
|
||||
story: mode === 'simple' ? prompt.trim() : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onStoryGenerate = () => {
|
||||
runGenerate('custom', {
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
style: tags.trim(),
|
||||
story: storyText.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const onMakeLyrics = async () => {
|
||||
setStoryError(null);
|
||||
setStoryAuthRequired(false);
|
||||
if (!storyText.trim()) {
|
||||
setStoryError('이야기를 먼저 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setStoryLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/studio/story', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ story: storyText.trim() }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setStoryAuthRequired(true);
|
||||
setStoryError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 503 || res.status === 502) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : 'AI 서비스가 잠시 준비 중입니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.story) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : '가사 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
const s = json.story as MusicStory;
|
||||
setTitle(s.title);
|
||||
setLyrics(s.lyrics);
|
||||
setTags(s.style);
|
||||
setMood(s.mood);
|
||||
setStoryStage('preview');
|
||||
} catch (e) {
|
||||
setStoryError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (t: string) => {
|
||||
@@ -161,203 +298,344 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen px-4 md:px-8 lg:px-12 py-10"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(1200px 600px at 20% -10%, rgba(156,72,234,0.18), transparent 60%), radial-gradient(1000px 500px at 110% 10%, rgba(83,221,252,0.12), transparent 55%), var(--kx-surface)',
|
||||
color: 'var(--kx-on-surface)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-bg)', color: 'var(--jsm-ink)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
|
||||
<div>
|
||||
<span className="kx-label">JAENGSEUNG STUDIO</span>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}>
|
||||
프롬프트 한 줄로 트랙 만들기
|
||||
나의 이야기를 음악으로
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
Suno 엔진 기반 · Custom 모드로 가사·태그·보컬까지 세밀 제어
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
이야기를 들려주면 AI가 가사·스타일을 제안합니다. 직접 입력 모드로 세밀하게 조정할 수도 있어요.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs px-3 py-1.5 rounded-full border"
|
||||
className="text-xs px-3 py-1.5 rounded-full font-semibold tracking-wide"
|
||||
style={{
|
||||
borderColor: 'rgba(204,151,255,0.35)',
|
||||
background: 'rgba(204,151,255,0.1)',
|
||||
color: 'var(--kx-primary)',
|
||||
border: '1px solid var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
color: 'var(--jsm-accent)',
|
||||
}}
|
||||
>
|
||||
⚡ v1 Studio · Live
|
||||
STUDIO · LIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6">
|
||||
{/* 좌측: 제어판 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-8"
|
||||
style={{
|
||||
background: 'rgba(12,22,45,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||
<div className="rounded-2xl p-6 md:p-8 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['story', 'manual'] as FlowTab[]).map((t) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
key={t}
|
||||
onClick={() => setFlowTab(t)}
|
||||
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
|
||||
style={
|
||||
mode === m
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
|
||||
color: '#fff',
|
||||
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
|
||||
}
|
||||
: { color: 'var(--kx-on-variant)' }
|
||||
flowTab === t
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
{flowTab === 'story' ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
|
||||
className="w-full bg-transparent outline-none resize-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
|
||||
className="w-full bg-transparent outline-none resize-none font-mono text-sm leading-relaxed"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
{storyStage === 'input' ? (
|
||||
<>
|
||||
<Field label="나의 이야기" hint="추억·순간·감정을 편하게 적어주세요">
|
||||
<textarea
|
||||
value={storyText}
|
||||
onChange={(e) => setStoryText(e.target.value)}
|
||||
rows={7}
|
||||
placeholder="예: 대학 시절 자취방에서 혼자 라면을 끓여 먹으며 창밖 비 오는 거리를 보던 밤, 외로웠지만 이상하게 평온했던 기억"
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="w-full py-4 rounded-xl font-bold text-base transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{storyLoading ? '가사 만드는 중…' : '가사 만들기'}
|
||||
</button>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
{storyAuthRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="text-xs px-3 py-1 rounded-full font-semibold"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
무드 · {mood || '미정'}
|
||||
</span>
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
onClick={() => setStoryStage('input')}
|
||||
className="text-xs underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
이야기 다시 쓰기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="AI가 제안한 가사입니다 — 자유롭게 수정 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="flex-1 py-3.5 rounded-xl font-semibold text-sm transition disabled:opacity-60"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<button
|
||||
onClick={onStoryGenerate}
|
||||
disabled={submitting}
|
||||
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '음악 만들기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className="flex-1 py-2 text-xs font-semibold rounded-full transition-all"
|
||||
style={
|
||||
mode === m
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: instrumental ? 'rgba(204,151,255,0.6)' : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: instrumental ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={instrumental}
|
||||
onChange={(e) => setInstrumental(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
{instrumental ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
|
||||
style={{
|
||||
background: submitting
|
||||
? 'rgba(204,151,255,0.2)'
|
||||
: 'linear-gradient(135deg, #cc97ff 0%, #7c3aed 50%, #53ddfc 100%)',
|
||||
color: '#0b1428',
|
||||
boxShadow: submitting ? 'none' : '0 12px 40px -12px rgba(204,151,255,0.6)',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '▶ Generate Track'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'simple' ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onManualSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '트랙 생성하기'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 결과 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-7"
|
||||
style={{
|
||||
background: 'rgba(9,17,36,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl p-6 md:p-7 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="kx-label">RECENT TRACKS</span>
|
||||
@@ -367,7 +645,7 @@ export default function StudioPage() {
|
||||
<button
|
||||
onClick={() => { setTasks([]); saveToLS([]); }}
|
||||
className="text-[11px] underline underline-offset-4"
|
||||
style={{ color: 'var(--kx-on-variant)' }}
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
기록 지우기
|
||||
</button>
|
||||
@@ -376,35 +654,31 @@ export default function StudioPage() {
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-sm"
|
||||
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
|
||||
className="rounded-xl p-8 text-center text-sm border border-dashed"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
아직 생성된 트랙이 없습니다.
|
||||
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
||||
<br />왼쪽에서 이야기를 들려주거나 프롬프트를 입력해보세요.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(20,31,56,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
className="rounded-xl p-4 border"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}…</span>
|
||||
<span className="text-[11px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
task: {task.taskId.slice(0, 10)}…
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
|
||||
{task.clips.length === 0 ? (
|
||||
<div
|
||||
className="h-9 rounded-md flex items-center justify-center text-xs"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
{isFailed(task.status)
|
||||
? (task.errorMessage ?? '생성 실패')
|
||||
@@ -417,8 +691,8 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="rounded-lg p-3"
|
||||
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }}
|
||||
className="rounded-lg p-3 bg-white border"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{c.imageUrl && (
|
||||
@@ -430,17 +704,17 @@ export default function StudioPage() {
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{c.title || '제목 없음'}
|
||||
</p>
|
||||
{c.tags && (
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{c.tags}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{c.duration && (
|
||||
<span className="text-[10px] font-mono opacity-60">
|
||||
<span className="text-[10px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{Math.round(c.duration)}s
|
||||
</span>
|
||||
)}
|
||||
@@ -449,8 +723,13 @@ export default function StudioPage() {
|
||||
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
|
||||
) : null}
|
||||
{c.audioUrl && (
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white">
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<a
|
||||
href={c.audioUrl}
|
||||
download
|
||||
className="underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
MP3 다운로드
|
||||
</a>
|
||||
</div>
|
||||
@@ -467,9 +746,9 @@ export default function StudioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
|
||||
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<Tip title="① 스토리 모드" body="이야기를 적으면 AI가 제목·가사·스타일을 자동으로 제안합니다." />
|
||||
<Tip title="② 직접 입력 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,41 +766,60 @@ function Field({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}>
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--jsm-accent)' }}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>}
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--jsm-ink-soft)' }}>{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: checked ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: checked ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{checked ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' },
|
||||
PENDING: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
|
||||
const map: Record<string, { bg: string; fg: string; border: string; label: string }> = {
|
||||
SUCCESS: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '가사 완료' },
|
||||
PENDING: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: '대기' },
|
||||
};
|
||||
let entry = map[status];
|
||||
if (!entry) {
|
||||
entry = isFailed(status)
|
||||
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' }
|
||||
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status };
|
||||
? { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', label: '실패' }
|
||||
: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: status };
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||
style={{ background: entry.bg, color: entry.fg }}
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap border"
|
||||
style={{ background: entry.bg, color: entry.fg, borderColor: entry.border }}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
@@ -530,11 +828,8 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
function Tip({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<div className="rounded-xl p-4 bg-white border" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="leading-relaxed">{body}</p>
|
||||
|
||||
@@ -68,10 +68,20 @@ type SajuRecordRow = {
|
||||
is_paid: boolean;
|
||||
};
|
||||
|
||||
// AI 기록 탭 — 사주·타로 병합 렌더용 판별 유니언
|
||||
// AI 기록 탭 — 음악 트랙 (app/api/studio/tracks 응답)
|
||||
type MusicTrackRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
story: string | null;
|
||||
audio_url: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
|
||||
type AiRecordItem =
|
||||
| { kind: 'saju'; data: SajuRecordRow }
|
||||
| { kind: 'tarot'; data: TarotReadingRow };
|
||||
| { kind: 'tarot'; data: TarotReadingRow }
|
||||
| { kind: 'music'; data: MusicTrackRow };
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
@@ -152,9 +162,10 @@ function MyPageContent() {
|
||||
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
||||
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
||||
|
||||
// AI 기록 탭 — 타로 리딩 / 사주 기록
|
||||
// AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
|
||||
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
|
||||
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
||||
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
|
||||
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
@@ -166,7 +177,7 @@ function MyPageContent() {
|
||||
} catch { /* 미로그인/네트워크 — 무시 */ }
|
||||
}, []);
|
||||
|
||||
// 사주·타로 결과 통합 로드 — 둘 다 실패해도 서로 영향 없이 무시(best-effort)
|
||||
// 사주·타로·음악 결과 통합 로드 — 셋 다 실패해도 서로 영향 없이 무시(best-effort)
|
||||
const loadAiRecords = useCallback(async () => {
|
||||
try {
|
||||
const tr = await fetch('/api/tarot/readings');
|
||||
@@ -184,6 +195,10 @@ function MyPageContent() {
|
||||
setSajuRecords(data ?? []);
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
try {
|
||||
const mt = await fetch('/api/studio/tracks');
|
||||
if (mt.ok) setMusicTracks((await mt.json()).tracks ?? []);
|
||||
} catch { /* 무시 */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -369,10 +384,11 @@ function MyPageContent() {
|
||||
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
||||
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
|
||||
|
||||
// AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합
|
||||
// AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
|
||||
const aiRecords: AiRecordItem[] = [
|
||||
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
|
||||
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
|
||||
...musicTracks.map((r): AiRecordItem => ({ kind: 'music', data: r })),
|
||||
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
|
||||
|
||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||
@@ -380,7 +396,7 @@ function MyPageContent() {
|
||||
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
||||
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
||||
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
|
||||
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined },
|
||||
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length + musicTracks.length) || undefined },
|
||||
];
|
||||
|
||||
function selectTab(key: Tab) {
|
||||
@@ -910,7 +926,7 @@ function MyPageContent() {
|
||||
AI 기록이 없습니다
|
||||
</div>
|
||||
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
사주 분석·타로 리딩 결과가 여기에 모아서 표시됩니다.
|
||||
사주 분석·타로 리딩·음악 생성 결과가 여기에 모아서 표시됩니다.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
@@ -927,6 +943,13 @@ function MyPageContent() {
|
||||
>
|
||||
타로 리딩 하기 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
|
||||
>
|
||||
음악 만들기 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -934,13 +957,15 @@ function MyPageContent() {
|
||||
{aiRecords.map((item) =>
|
||||
item.kind === 'saju' ? (
|
||||
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
|
||||
) : (
|
||||
) : item.kind === 'tarot' ? (
|
||||
<TarotAiCard
|
||||
key={`tarot-${item.data.id}`}
|
||||
reading={item.data}
|
||||
expanded={expandedAiCards.has(item.data.id)}
|
||||
onToggle={() => toggleAiCard(item.data.id)}
|
||||
/>
|
||||
) : (
|
||||
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -1569,6 +1594,48 @@ function TarotAiCard({
|
||||
);
|
||||
}
|
||||
|
||||
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시).
|
||||
function MusicAiCard({ track }: { track: MusicTrackRow }) {
|
||||
return (
|
||||
<Card compact>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
음악
|
||||
</span>
|
||||
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{new Date(track.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{track.title || '제목 없는 트랙'}
|
||||
</div>
|
||||
|
||||
{track.story && (
|
||||
<p
|
||||
className="text-sm line-clamp-2 break-keep mb-3"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{track.story}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{track.audio_url ? (
|
||||
<audio controls className="w-full" style={{ height: 36 }}>
|
||||
<source src={track.audio_url} />
|
||||
</audio>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
생성 준비 중입니다. 잠시 후 다시 확인해주세요.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
|
||||
@@ -66,14 +66,14 @@ export default function SajuForm() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 생년월일 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
생년월일
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="년 (예: 1990)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={year}
|
||||
@@ -83,7 +83,7 @@ export default function SajuForm() {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="월 (1-12)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1"
|
||||
max="12"
|
||||
value={month}
|
||||
@@ -93,7 +93,7 @@ export default function SajuForm() {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="일 (1-31)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1"
|
||||
max="31"
|
||||
value={day}
|
||||
@@ -105,11 +105,11 @@ export default function SajuForm() {
|
||||
|
||||
{/* 태어난 시간 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
태어난 시간 (선택)
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="w-full px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
value={hour}
|
||||
onChange={(e) => setHour(e.target.value)}
|
||||
>
|
||||
@@ -131,7 +131,7 @@ export default function SajuForm() {
|
||||
|
||||
{/* 양력/음력 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
생일 구분
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -140,8 +140,8 @@ export default function SajuForm() {
|
||||
onClick={() => setCalendarType('solar')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
calendarType === 'solar'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
양력
|
||||
@@ -151,8 +151,8 @@ export default function SajuForm() {
|
||||
onClick={() => setCalendarType('lunar')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
calendarType === 'lunar'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
음력
|
||||
@@ -165,7 +165,7 @@ export default function SajuForm() {
|
||||
type="checkbox"
|
||||
checked={isLeapMonth}
|
||||
onChange={(e) => setIsLeapMonth(e.target.checked)}
|
||||
className="w-4 h-4 text-[#1a56db] border-gray-300 rounded focus:ring-[#1a56db]"
|
||||
className="w-4 h-4 text-[var(--jsm-accent)] border-gray-300 rounded focus:ring-[var(--jsm-accent)]"
|
||||
/>
|
||||
<span>윤달</span>
|
||||
</label>
|
||||
@@ -175,7 +175,7 @@ export default function SajuForm() {
|
||||
|
||||
{/* 성별 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
성별
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -184,8 +184,8 @@ export default function SajuForm() {
|
||||
onClick={() => setGender('male')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
gender === 'male'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
남성
|
||||
@@ -195,8 +195,8 @@ export default function SajuForm() {
|
||||
onClick={() => setGender('female')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
gender === 'female'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
여성
|
||||
@@ -207,7 +207,7 @@ export default function SajuForm() {
|
||||
{/* 제출 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
|
||||
className="w-full bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
|
||||
>
|
||||
내 사주 보기 →
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,12 @@ import SajuForm from '@/app/work/saju/components/SajuForm';
|
||||
|
||||
export default function SajuInputPage() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)]">
|
||||
{/* Hero */}
|
||||
<div className="relative overflow-hidden px-6 py-12"
|
||||
style={{ background: '#04102b', backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
|
||||
<div className="relative overflow-hidden px-6 py-12 bg-[var(--jsm-navy)]">
|
||||
|
||||
<div className="relative max-w-xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 border border-white/20 text-[var(--jsm-accent-soft)] text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
AI 사주 분석 · 생년월일 입력
|
||||
</div>
|
||||
@@ -24,10 +23,10 @@ export default function SajuInputPage() {
|
||||
|
||||
{/* Form 영역 */}
|
||||
<div className="px-6 py-10 max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 shadow-lg">
|
||||
<div className="bg-white rounded-2xl border border-[var(--jsm-line)] p-8 shadow-lg">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-1 h-5 bg-gradient-to-b from-[#1a56db] to-[#7c3aed] rounded-full" />
|
||||
<h2 className="font-bold text-[#04102b] text-base">기본 정보 입력</h2>
|
||||
<div className="w-1 h-5 bg-[var(--jsm-accent)] rounded-full" />
|
||||
<h2 className="font-bold text-[var(--jsm-ink)] text-base">기본 정보 입력</h2>
|
||||
</div>
|
||||
<SajuForm />
|
||||
</div>
|
||||
|
||||
@@ -74,14 +74,14 @@ export default function SajuPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)]">
|
||||
{/* ─── Hero ─── */}
|
||||
<div className="relative overflow-hidden bg-[#04102b] px-6 py-14 lg:px-12" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 40px)' }}>
|
||||
<div className="relative overflow-hidden bg-[var(--jsm-navy)] px-6 py-14 lg:px-12">
|
||||
|
||||
<div className="relative max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span className="text-violet-300/70 text-xs font-mono tracking-widest uppercase">전통 명리학 × AI 해석 · 무료</span>
|
||||
<span className="text-[var(--jsm-accent-soft)] text-xs font-mono tracking-widest uppercase">전통 명리학 × AI 해석 · 무료</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-white leading-tight mb-5 tracking-tight">
|
||||
AI가 분석하는<br />
|
||||
@@ -97,7 +97,7 @@ export default function SajuPage() {
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all"
|
||||
className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
@@ -117,7 +117,7 @@ export default function SajuPage() {
|
||||
) : (
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
|
||||
className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
@@ -135,19 +135,19 @@ export default function SajuPage() {
|
||||
{hasPaid && paidRecords.length > 0 && (
|
||||
<div id="past-records">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-violet-600 text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b]">이전 AI 사주 기록</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
|
||||
<h2 className="text-2xl font-extrabold text-[var(--jsm-ink)]">이전 AI 사주 기록</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">결제한 사주 기록을 다시 확인하세요</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{paidRecords.map((rec) => (
|
||||
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5 hover:border-violet-300 transition-colors">
|
||||
<div key={rec.id} className="bg-white rounded-2xl border border-[var(--jsm-line)] p-5 hover:border-[var(--jsm-accent-soft)] transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 mb-1">
|
||||
{new Date(rec.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="font-bold text-[#04102b] text-base">
|
||||
<div className="font-bold text-[var(--jsm-ink)] text-base">
|
||||
{rec.saju_data.birth_year ?? '?'}년{' '}
|
||||
{rec.saju_data.birth_month ?? '?'}월{' '}
|
||||
{rec.saju_data.birth_day ?? '?'}일생
|
||||
@@ -168,7 +168,7 @@ export default function SajuPage() {
|
||||
)}
|
||||
<Link
|
||||
href={buildResultUrl(rec)}
|
||||
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-[#04102b] hover:bg-[#0a1f5c] text-white border border-[#1a3a7a] transition"
|
||||
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-[var(--jsm-navy)] hover:opacity-90 text-white transition"
|
||||
>
|
||||
다시 보기 →
|
||||
</Link>
|
||||
@@ -179,18 +179,12 @@ export default function SajuPage() {
|
||||
)}
|
||||
|
||||
{/* ─── 바로 시작하기 CTA ─── */}
|
||||
<div
|
||||
className="rounded-2xl border border-[#1a3a7a] p-8 text-center"
|
||||
style={{
|
||||
background: '#04102b',
|
||||
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 30px)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl p-8 text-center bg-[var(--jsm-navy)]">
|
||||
<h3 className="text-2xl font-extrabold text-white mb-2">지금 무료로 시작하세요</h3>
|
||||
<p className="text-blue-200/60 text-sm mb-6">회원가입 없이, 생년월일만 입력하면 바로 확인 가능합니다</p>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] px-8 py-3.5 rounded-xl font-bold text-base transition-all"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[var(--jsm-ink)] px-8 py-3.5 rounded-xl font-bold text-base transition-all"
|
||||
>
|
||||
사주 입력하러 가기 →
|
||||
</Link>
|
||||
@@ -199,23 +193,23 @@ export default function SajuPage() {
|
||||
{/* ─── 무료 vs 유료 비교표 ─── */}
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b] tracking-tight">무엇을 분석해드리나요</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[var(--jsm-ink)] tracking-tight">무엇을 분석해드리나요</h2>
|
||||
<p className="text-slate-500 text-sm mt-2">기본 원국은 무료, AI 상세 해석은 1,000원</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 무료 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6 shadow-sm">
|
||||
<div className="bg-white rounded-2xl border border-[var(--jsm-line)] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-[#1a56db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-[var(--jsm-accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wide">FREE</div>
|
||||
<div className="text-lg font-extrabold text-[#04102b]">무료 기본 분석</div>
|
||||
<div className="text-lg font-extrabold text-[var(--jsm-ink)]">무료 기본 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
@@ -229,18 +223,18 @@ export default function SajuPage() {
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2.5 text-sm text-slate-700">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#1a56db]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--jsm-accent)]" />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 pt-5 border-t border-slate-100">
|
||||
<div className="text-2xl font-extrabold text-[#04102b]">무료</div>
|
||||
<div className="text-2xl font-extrabold text-[var(--jsm-ink)]">무료</div>
|
||||
<div className="text-xs text-slate-500 mt-1">회원가입 불필요</div>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] hover:bg-blue-50 transition"
|
||||
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] text-[var(--jsm-accent)] hover:bg-blue-50 transition"
|
||||
>
|
||||
무료로 시작하기
|
||||
</Link>
|
||||
@@ -248,24 +242,18 @@ export default function SajuPage() {
|
||||
</div>
|
||||
|
||||
{/* AI 해석 (현재 무료) */}
|
||||
<div
|
||||
className="rounded-2xl border border-[#1a3a7a] p-6 shadow-lg relative overflow-hidden"
|
||||
style={{
|
||||
background: '#04102b',
|
||||
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 30px)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-4 right-4 bg-amber-400 text-[#04102b] text-xs font-bold px-2 py-0.5 rounded-lg">
|
||||
<div className="rounded-2xl p-6 shadow-lg relative overflow-hidden bg-[var(--jsm-navy)]">
|
||||
<div className="absolute top-4 right-4 bg-amber-400 text-[var(--jsm-ink)] text-xs font-bold px-2 py-0.5 rounded-lg">
|
||||
1,000원
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-5 relative">
|
||||
<div className="w-10 h-10 rounded-xl bg-violet-500/20 border border-violet-400/30 flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-violet-300 uppercase tracking-wide">AI PREMIUM</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-accent-soft)] uppercase tracking-wide">AI PREMIUM</div>
|
||||
<div className="text-lg font-extrabold text-white">AI 상세 해석</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,7 +282,7 @@ export default function SajuPage() {
|
||||
<div className="text-xs text-blue-300/70 mt-1 mb-4">로그인 후 결제 · 12가지 항목 AI 해석</div>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
|
||||
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[var(--jsm-ink)] hover:bg-amber-300"
|
||||
>
|
||||
사주 분석 시작하기 →
|
||||
</Link>
|
||||
@@ -306,18 +294,18 @@ export default function SajuPage() {
|
||||
{/* ─── FAQ ─── */}
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b]">자주 묻는 질문</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
|
||||
<h2 className="text-2xl font-extrabold text-[var(--jsm-ink)]">자주 묻는 질문</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{faqItems.map((item, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<div key={i} className="bg-white rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[#1a56db] text-xs font-bold">Q</span>
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[var(--jsm-accent)] text-xs font-bold">Q</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#04102b] text-sm mb-2">{item.q}</p>
|
||||
<p className="font-bold text-[var(--jsm-ink)] text-sm mb-2">{item.q}</p>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{item.a}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
143
docs/superpowers/plans/2026-07-03-phase2_6-saju-entry-reskin.md
Normal file
143
docs/superpowers/plans/2026-07-03-phase2_6-saju-entry-reskin.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Phase 2.6 사주 랜딩·입력 라이트 재스킨 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 사주 진입 화면 3파일(랜딩 page.tsx·input/page.tsx·SajuForm.tsx)을 `--jsm` 라이트로 재스킨해 사주 서비스 전 화면 톤을 통일한다.
|
||||
|
||||
**Architecture:** Phase 2.5와 동일한 순수 시각 재스킨 — 로직·데이터·props 무변경, 다크 hex·gradient·보라 className을 `--jsm-*` 토큰으로 치환. 대상 3파일엔 이모지가 없어 아이콘 작업 불필요. 각 파일 done-condition은 금지 패턴 grep 0건 + 빌드 성공.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*` 토큰), vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-phase2_6-saju-entry-reskin-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **순수 시각 변경**: 로직·조건분기·fetch·상태·props·데이터·라우팅 라인은 건드리지 않는다(className/style만)
|
||||
- 신규 색 토큰 추가 금지 — 아래 11개 `--jsm-*`만:
|
||||
`--jsm-bg --jsm-surface --jsm-surface-alt --jsm-ink --jsm-ink-soft --jsm-ink-faint --jsm-line --jsm-navy --jsm-accent --jsm-accent-soft --jsm-accent-hover`
|
||||
- 대상 3파일에서 `gradient` / `violet` / `purple` / `blur` / 이모지 **0건**(각 Task 종료 게이트)
|
||||
- navy 밴드 = 무테두리 flat + 흰 CTA(2.5·`app/page.tsx`에서 확립된 사이트 관용구). navy 배경 위 강조 텍스트는 `--jsm-accent-soft`(대비 확보)
|
||||
- 의미색(상태 신호색)은 있으면 2.5 선례대로 보존
|
||||
- next.config.ts·로직 파일 수정 금지
|
||||
- 커밋은 스코프 파일만 스테이징 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30) 유지 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 색상 매핑 (모든 Task 공통 — 이 표대로 치환)
|
||||
|
||||
| 현재(제거 대상) | 교체(→) |
|
||||
|------|------|
|
||||
| `#04102b` 다크 배경 + `repeating-linear-gradient` 텍스처 | `bg-[var(--jsm-navy)]` 플랫(히어로 밴드) / `bg-[var(--jsm-bg)]`(페이지). 텍스처 style 삭제 |
|
||||
| `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]`(+hover) CTA | `bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)]` 흰 텍스트 |
|
||||
| `violet-300/500/600`·`#7c3aed`·`shadow-violet-*` | `var(--jsm-accent)`; navy 밴드 위 텍스트는 `var(--jsm-accent-soft)` |
|
||||
| amber 강조 | `var(--jsm-accent)` 또는 `var(--jsm-ink-soft)` |
|
||||
| 흰색·`blue-xx` 본문 텍스트 | `text-[var(--jsm-ink)]` / `ink-soft` / `ink-faint` |
|
||||
| 다크 카드/테두리 `#dbe8ff`·`#1a3a7a` 등 | `bg-[var(--jsm-surface)]` + `border-[var(--jsm-line)]` |
|
||||
|
||||
참고 관용구: `app/work/saju/result/page.tsx`(2.5 완료본), `app/showcase/page.tsx`, `app/page.tsx`(navy CTA 밴드).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 랜딩 page.tsx 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/page.tsx` (333줄, 위반 9건)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음 (기존 --jsm 토큰만)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 전체 Read 후 색상 매핑 치환**
|
||||
|
||||
`app/work/saju/page.tsx`를 먼저 전체 Read. 다음 위반 지점을 색상 매핑 표대로 치환(className/style만):
|
||||
- 히어로(79행): `bg-[#04102b]` + `repeating-linear-gradient` style → `bg-[var(--jsm-navy)]`(밴드) + 텍스처 style 삭제
|
||||
- 부제(84행): `text-violet-300/70` → `text-[var(--jsm-accent-soft)]`(navy 위)
|
||||
- CTA 버튼(120행): `bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:… shadow-violet-900/40` → `bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)]`(shadow는 기본 유지 또는 제거)
|
||||
- MY RECORDS 라벨(138행): `text-violet-600` → `text-[var(--jsm-accent)]`
|
||||
- 레코드 카드(144행): `border-[#dbe8ff] hover:border-violet-300` → `border-[var(--jsm-line)] hover:border-[var(--jsm-accent-soft)]`
|
||||
- 하단 섹션 텍스처(186·255행): `repeating-linear-gradient` style 삭제, 다크 배경 → navy/surface
|
||||
- AI PREMIUM 카드(262·268행): `bg-violet-500/20 border-violet-400/30`·`text-violet-300` → navy 밴드 위 `bg-white/10 border-white/20`·`text-[var(--jsm-accent-soft)]`
|
||||
- 그 외 `#04102b` 텍스트·다크 카드 → `--jsm-ink`/`surface`/`line`
|
||||
- **서버/클라 로직·데이터 조회(MY RECORDS)·JSX 구조 미변경**
|
||||
|
||||
- [ ] **Step 2: 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/work/saju/page.tsx # 기대: 0
|
||||
npm run build && npm test # build 성공 + 30 PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/work/saju/page.tsx && git commit -m "feat(phase2.6): 사주 랜딩 라이트 재스킨 — gradient/보라→--jsm, 텍스처 제거"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: input/page.tsx + SajuForm.tsx 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/input/page.tsx` (41줄, 위반 3건)
|
||||
- Modify: `app/work/saju/components/SajuForm.tsx` (220줄, 위반 1건)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 두 파일 Read 후 치환**
|
||||
|
||||
각 파일 전체 Read. 다크/gradient/violet/purple 지점을 색상 매핑 표대로 치환:
|
||||
- `input/page.tsx`: 입력 화면 컨테이너·헤더·다크 배경 → `bg-[var(--jsm-bg)]`/navy 밴드, violet → accent, 텍스트 → ink
|
||||
- `SajuForm.tsx`: 폼 필드·버튼·강조 색의 gradient/violet → 플랫 accent, 다크 → surface/line. **`useSajuForm` 등 폼 상태·핸들러·submit 로직 미변경**
|
||||
|
||||
- [ ] **Step 2: 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/work/saju/input/page.tsx app/work/saju/components/SajuForm.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/work/saju/input/page.tsx app/work/saju/components/SajuForm.tsx && git commit -m "feat(phase2.6): 사주 입력 화면·폼 라이트 재스킨"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 사주 전체 게이트 + CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1~2 완료
|
||||
- Produces: 문서 정합
|
||||
|
||||
- [ ] **Step 1: 사주 전체 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -rnE "gradient|violet|purple|blur" app/work/saju --include="*.tsx" # result 포함 사주 전체 0건
|
||||
```
|
||||
2.5 재스킨된 result 파일(SajuIcons·SajuAISection·SajuFortuneSection·result/page)과 이번 3파일 모두 0건이어야 함. 이모지도 Grep 유니코드로 0건 확인.
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md 갱신**
|
||||
|
||||
`## 사주 시스템` 섹션의 2.5 재스킨 안내 문구("결과 화면 --jsm 라이트 재스킨 완료(2026-07-03)")를 "전 화면(랜딩·입력·결과) --jsm 라이트 재스킨 완료(2026-07-03)"로 갱신. 무관 섹션 미변경.
|
||||
|
||||
- [ ] **Step 3: 최종 빌드·테스트**
|
||||
|
||||
```bash
|
||||
npm run build # 성공, /work/saju·/work/saju/input·/work/saju/result 라우트 존재
|
||||
npm test # 30 PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add CLAUDE.md && git commit -m "docs(phase2.6): CLAUDE.md — 사주 전 화면 라이트 재스킨 반영"`
|
||||
|
||||
- [ ] **Step 5: 보고**
|
||||
- 수동 확인: `/work/saju` 랜딩→입력→결과 전 플로우가 라이트로 일관 렌더
|
||||
- 사주 서비스 전 화면 라이트 완결 — 재스킨 후속 없음
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 사주 전체 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/work/saju --include="*.tsx"` | 0건 |
|
||||
| 이모지 | grep 유니코드 범위 | 0건 |
|
||||
| 빌드 | `npm run build` | 성공 |
|
||||
| 테스트 | `npm test` | 30 PASS(무변) |
|
||||
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Phase 3a 음악 서비스 공개화 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 숨김 상태의 Suno 음악 스튜디오를 공개·무료화하고 "스토리→음악"(Gemini) 흐름·회원 저장·라이트 디자인을 붙인다.
|
||||
|
||||
**Architecture:** 사주·타로의 "공개+무료+로그인 저장+일일제한" 패턴을 음악에 적용. Gemini가 스토리→가사/스타일 변환, Suno가 음악 생성(폴링), 완료 시 회원 저장. 음악 페이지는 --jsm 라이트로 재스킨.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*`), Supabase, @google/generative-ai, Suno API, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-phase3a-music-public-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **순수 시각 변경 태스크**에서는 로직 라인 미변경(className/style만); **API 태스크**에서는 인증→제한→호출→(성공)recordUsage 순서 준수
|
||||
- 신규 색 토큰 금지 — 11개 `--jsm-*`만. 음악 신규/재스킨 파일에서 `gradient`/`violet`/`purple`/`blur`/이모지 **0건**
|
||||
- 일일 제한: `MUSIC_DAILY_LIMIT = 1`. 생성(Suno) 성공 시에만 `recordUsage('music')`. story(Gemini) 단계는 인증만, 미집계
|
||||
- GEMINI_API_KEY/SUNO_API_KEY 미설정 시 각각 503(예시 폴백 금지)
|
||||
- `ai_usage_log` CHECK ALTER는 **phase2-saju-tarot 마이그 DB 적용 후** 실행 전제(플랜/CEO 안내에 명시)
|
||||
- next.config.ts 수정 금지, 기존 supabase/migrations/ 파일 수정 금지(신규만)
|
||||
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30→) 유지 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 확인된 기존 계약
|
||||
|
||||
- `POST /api/studio/generate`(무인증): body `{mode:'simple'|'custom', prompt?, title?, lyrics?, tags?, make_instrumental?, model?}` → Suno `/api/v1/generate` → `{ ok, data }`. `callBackUrl=${origin}/api/studio/callback`(부재)
|
||||
- `GET /api/studio/status?taskId=`(무인증) → Suno record-info `{ ok, data }`
|
||||
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `kstDayStartISO`, `getTodayUsage(admin,userId,service)`, `recordUsage(admin,userId,service)`
|
||||
- supabase 헬퍼: `createClient()`(세션·RLS), `createAdminClient()`(service role). 타로 prompt 방어 패턴: `app/api/tarot/interpret/route.ts`, `lib/tarot/prompt.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ai-usage 확장 + DB 마이그레이션
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/ai-usage.ts`
|
||||
- Create: `supabase/migrations/2026-07-03-phase3a-music.sql`
|
||||
- Modify: `lib/__tests__/ai-usage.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AiService = 'saju'|'tarot'|'music'`, `MUSIC_DAILY_LIMIT = 1`, `music_tracks` 테이블. Task 3·4·6이 소비
|
||||
|
||||
- [ ] **Step 1: 테스트에 music 상수 추가**
|
||||
|
||||
`lib/__tests__/ai-usage.test.ts`의 상수 검증 `it`에 추가(기존 테스트 유지):
|
||||
```typescript
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
|
||||
// ... 기존 KST 테스트 유지 ...
|
||||
it('음악 일일 제한 상수', () => {
|
||||
expect(MUSIC_DAILY_LIMIT).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → FAIL(MUSIC_DAILY_LIMIT 없음)
|
||||
|
||||
- [ ] **Step 3: ai-usage.ts 확장**
|
||||
|
||||
`lib/ai-usage.ts`:
|
||||
```typescript
|
||||
export const SAJU_DAILY_LIMIT = 1;
|
||||
export const TAROT_DAILY_LIMIT = 3;
|
||||
export const MUSIC_DAILY_LIMIT = 1;
|
||||
export type AiService = 'saju' | 'tarot' | 'music';
|
||||
```
|
||||
(getTodayUsage/recordUsage/kstDayStartISO 본문 무변경 — AiService 타입만 확장되어 'music' 허용)
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → PASS
|
||||
|
||||
- [ ] **Step 5: 마이그레이션 파일**
|
||||
|
||||
`supabase/migrations/2026-07-03-phase3a-music.sql`:
|
||||
```sql
|
||||
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
|
||||
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
|
||||
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
story text,
|
||||
lyrics text,
|
||||
style text,
|
||||
audio_url text,
|
||||
task_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
|
||||
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'music';
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 검증·커밋** — `npm test && npm run build` PASS. `git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-03-phase3a-music.sql && git commit -m "feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 음악 공개화 (가드 제거)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/layout.tsx`
|
||||
- Modify: `lib/service-visibility.ts`
|
||||
- Modify: `app/api/admin/services/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: `/music*` 공개
|
||||
|
||||
- [ ] **Step 1: layout 가드 제거**
|
||||
|
||||
`app/music/layout.tsx`: `isServiceVisible`/`notFound` import·호출 제거, metadata 있으면 유지, 단순 `return <>{children}</>`. (파일 먼저 Read — metadata 유무 확인)
|
||||
|
||||
- [ ] **Step 2: HideableService에서 music 제거**
|
||||
|
||||
`lib/service-visibility.ts`: `export type HideableService = 'gyeol' | 'lotto';`
|
||||
|
||||
- [ ] **Step 3: DEFAULT_SERVICES music 행 제거**
|
||||
|
||||
`app/api/admin/services/route.ts` DEFAULT_SERVICES에서 `{ id: 'music', ... }` 한 줄 삭제(gyeol/lotto 유지). (service_settings music DELETE는 Task 1 마이그레이션이 담당)
|
||||
|
||||
- [ ] **Step 4: 검증·커밋** — `npm test && npm run build`(빌드 라우트에 /music이 static/공개로 등장). `git add app/music/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts && git commit -m "feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 스토리→음악 (story-prompt + story API + generate 인증/제한 + callback)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/music/story-prompt.ts`
|
||||
- Create: `app/api/studio/story/route.ts`
|
||||
- Create: `app/api/studio/callback/route.ts`
|
||||
- Modify: `app/api/studio/generate/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getTodayUsage`/`recordUsage`/`MUSIC_DAILY_LIMIT`(T1), `createClient`/`createAdminClient`
|
||||
- Produces:
|
||||
- `type MusicStory = { title: string; lyrics: string; style: string; mood: string }`
|
||||
- `POST /api/studio/story` (로그인) → 200 `{ story: MusicStory }` / 401 / 503
|
||||
- `POST /api/studio/generate` (로그인+제한) → 기존 `{ ok, data }` / 401 / 429 / 503
|
||||
- `POST /api/studio/callback` → `{ ok: true }`
|
||||
- Task 6이 소비
|
||||
|
||||
- [ ] **Step 1: story-prompt 모듈** (타로 prompt.ts 방어 패턴 포팅)
|
||||
|
||||
`lib/music/story-prompt.ts`:
|
||||
```typescript
|
||||
export type MusicStory = { title: string; lyrics: string; style: string; mood: string };
|
||||
|
||||
export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다.
|
||||
사용자가 들려준 이야기를 바탕으로:
|
||||
1. title: 노래 제목(짧고 인상적으로)
|
||||
2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄)
|
||||
3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo")
|
||||
4. mood: 전체 정서를 한 단어로(예 "그리움", "희망")
|
||||
반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"}
|
||||
사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`;
|
||||
|
||||
export function buildStoryUserMessage(story: string): string {
|
||||
return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`;
|
||||
}
|
||||
|
||||
export function parseStoryJson(raw: string): MusicStory | null {
|
||||
let text = raw.trim().replace(/^\`\`\`(json)?/i, '').replace(/\`\`\`$/,'').trim();
|
||||
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) text = text.slice(first, last + 1);
|
||||
try { return JSON.parse(text) as MusicStory; } catch { return null; }
|
||||
}
|
||||
|
||||
export function validateStory(obj: unknown): string | null {
|
||||
if (!obj || typeof obj !== 'object') return 'not an object';
|
||||
const o = obj as Record<string, unknown>;
|
||||
for (const k of ['title', 'lyrics', 'style', 'mood']) {
|
||||
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: story API** (타로 interpret의 Gemini 폴백·45s 가드·reroll 패턴)
|
||||
|
||||
`app/api/studio/story/route.ts` — `app/api/tarot/interpret/route.ts`를 참고해 동일 SDK 사용법으로:
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { STORY_SYSTEM_PROMPT, buildStoryUserMessage, parseStoryJson, validateStory } from '@/lib/music/story-prompt';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60;
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
const MODELS = [{ id: 'gemini-2.5-pro', maxTokens: 8192 }, { id: 'gemini-2.5-flash', maxTokens: 8192 }, { id: 'gemini-2.0-flash', maxTokens: 8192 }];
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const TIME_BUDGET_MS = 45_000;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
|
||||
const story = typeof body.story === 'string' ? body.story.trim() : '';
|
||||
if (!story) return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const userMsg = buildStoryUserMessage(story);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0; let feedback = '';
|
||||
for (const m of MODELS) {
|
||||
for (let retry = 0; retry < 2; retry += 1) {
|
||||
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) break;
|
||||
attempts += 1;
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: m.id, systemInstruction: STORY_SYSTEM_PROMPT, generationConfig: { temperature: 0.9, topP: 0.95, maxOutputTokens: m.maxTokens } });
|
||||
const prompt = feedback ? `${userMsg}\n\n[이전 오류: ${feedback}] 스키마를 지켜 다시 출력하세요.` : userMsg;
|
||||
const res = await model.generateContent(prompt);
|
||||
const parsed = parseStoryJson(res.response.text());
|
||||
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
|
||||
if (parsed && !invalid) return NextResponse.json({ story: parsed });
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (e) { feedback = e instanceof Error ? e.message : 'model error'; break; }
|
||||
}
|
||||
if (attempts >= MAX_ATTEMPTS) break;
|
||||
}
|
||||
return NextResponse.json({ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
|
||||
}
|
||||
```
|
||||
(작성 전 `app/api/tarot/interpret/route.ts`를 Read해 실제 SDK 시그니처와 일치시킬 것)
|
||||
|
||||
- [ ] **Step 3: callback 최소 라우트**
|
||||
|
||||
`app/api/studio/callback/route.ts`:
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
export const runtime = 'nodejs';
|
||||
// Suno webhook 수신용 최소 엔드포인트. 회원 저장은 폴링+클라 트리거(/api/studio/tracks)가 담당하므로 여기선 200만.
|
||||
export async function POST() { return NextResponse.json({ ok: true }); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: generate에 인증 + 일일제한**
|
||||
|
||||
`app/api/studio/generate/route.ts` POST 최상단(Suno 키 체크 전 또는 직후)에 인증·제한 추가:
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
// POST 시작부:
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
const admin = createAdminClient();
|
||||
if ((await getTodayUsage(admin, user.id, 'music')) >= MUSIC_DAILY_LIMIT) {
|
||||
return NextResponse.json({ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` }, { status: 429 });
|
||||
}
|
||||
```
|
||||
그리고 Suno task 생성이 성공 반환(`return NextResponse.json({ ok: true, data })`)되기 **직전**에 `await recordUsage(admin, user.id, 'music');` 추가. (503/502/400 실패 경로엔 넣지 않음)
|
||||
|
||||
- [ ] **Step 5: 검증·커밋** — `npm test && npm run build`(라우트 /api/studio/story·/callback 등장). `git add lib/music/story-prompt.ts app/api/studio/story/route.ts app/api/studio/callback/route.ts app/api/studio/generate/route.ts && git commit -m "feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 음악 저장·조회 API
|
||||
|
||||
**Files:**
|
||||
- Create: `app/api/studio/tracks/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createClient`/`createAdminClient`, `music_tracks`(T1)
|
||||
- Produces:
|
||||
- `POST /api/studio/tracks` (로그인) body `{ title?, story?, lyrics?, style?, audio_url?, task_id? }` → `{ id, created_at }` / 401
|
||||
- `GET /api/studio/tracks` (로그인) → `{ tracks: [{ id, title, story, lyrics, style, audio_url, task_id, created_at }] }` / 401
|
||||
- Task 6이 소비
|
||||
|
||||
- [ ] **Step 1: 구현** (타로 readings 패턴)
|
||||
|
||||
`app/api/studio/tracks/route.ts`:
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
|
||||
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin.from('music_tracks').insert({
|
||||
user_id: user.id,
|
||||
title: str('title'), story: str('story'), lyrics: str('lyrics'),
|
||||
style: str('style'), audio_url: str('audio_url'), task_id: str('task_id'),
|
||||
}).select('id, created_at').single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
const { data, error } = await supabase
|
||||
.from('music_tracks')
|
||||
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ tracks: data ?? [] });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 검증·커밋** — `npm test && npm run build`. `git add app/api/studio/tracks/route.ts && git commit -m "feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: music/page·samples 라이트 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/page.tsx` (72줄)
|
||||
- Modify: `app/music/samples/page.tsx` (102줄)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 두 파일 Read 후 --jsm 치환**
|
||||
|
||||
색상 매핑(사주 재스킨과 동일): 다크 hex/`gradient`/`violet`/`purple`/`blur`/amber → `--jsm-navy`(밴드 플랫)/`accent`/`accent-soft`/`surface`/`line`/`ink`. 이모지 있으면 제거 또는 인라인 SVG. **로직·데이터 조회·JSX 구조 미변경.** navy 밴드=무테두리 flat + 흰 CTA 관용구.
|
||||
|
||||
- [ ] **Step 2: 게이트·검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/music/page.tsx app/music/samples/page.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/music/page.tsx app/music/samples/page.tsx && git commit -m "feat(phase3a): 음악 랜딩·샘플 라이트 재스킨"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: studio 라이트 재스킨 + 스토리 UI 흐름
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/studio/page.tsx` (543줄)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/studio/story`(T3), `/api/studio/generate`(T3), `/api/studio/status`(기존), `/api/studio/tracks`(T4)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: Read 후 라이트 재스킨 + 스토리 흐름 재구성**
|
||||
|
||||
`app/music/studio/page.tsx` 전체 Read. 두 가지 동시:
|
||||
1. **라이트 재스킨**: 다크/gradient/violet/purple/amber/이모지 → --jsm 토큰(사주 스튜디오 아님 — 신규 라이트). 폼 필드는 라이트 관용구(`bg-white`+`border-[var(--jsm-line)]`+`focus:border-[var(--jsm-accent)]`)
|
||||
2. **스토리 UI 흐름**(기존 prompt/lyrics 직접입력 → 스토리 우선 흐름으로 확장, 기존 custom/simple 모드는 "직접 입력" 탭으로 보존 가능):
|
||||
- ①스토리 textarea + "가사 만들기" → `POST /api/studio/story` → 401이면 로그인 CTA(`/login?next=/music/studio`), 503/502면 안내
|
||||
- ②반환된 `{title, lyrics, style, mood}` 미리보기(편집 가능한 필드)
|
||||
- ③"음악 만들기" → `POST /api/studio/generate`(custom 모드, title/lyrics/tags=style) → 429면 제한 안내
|
||||
- ④기존 `status` 폴링 로직 유지 → 완료 시 오디오 URL 표시(플레이어)
|
||||
- ⑤완료+로그인 시 `POST /api/studio/tracks`로 자동 저장(best-effort, 실패해도 재생 유지)
|
||||
- 디자인 가드레일: gradient/blur/보라/이모지 0건
|
||||
|
||||
- [ ] **Step 2: 게이트·검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/music/studio/page.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/music/studio/page.tsx && git commit -m "feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: TopNav + 마이페이지 AI기록 음악 + CLAUDE.md + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx`
|
||||
- Modify: `app/mypage/page.tsx`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/studio/tracks`(T4)
|
||||
- Produces: 문서·네비 정합
|
||||
|
||||
- [ ] **Step 1: TopNav 링크**
|
||||
|
||||
`app/components/TopNav.tsx` LINKS에 `{ href: '/music', label: '음악' }` 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크). 다른 항목 무변.
|
||||
|
||||
- [ ] **Step 2: 마이페이지 AI 기록 탭에 음악 통합**
|
||||
|
||||
`app/mypage/page.tsx`의 'AI 기록' 탭(사주·타로 병합 리스트)에 음악 트랙 추가:
|
||||
- 타입 `type MusicTrackRow = { id: string; title: string | null; story: string | null; audio_url: string | null; created_at: string }` 추가
|
||||
- state·로드: `loadAiRecords`에 `fetch('/api/studio/tracks')` 추가(try/catch, `{ tracks }`)
|
||||
- 렌더: 병합 내림차순에 음악 카드 추가(제목·스토리 요약·오디오 링크/`<audio>`), 빈 상태 CTA에 `/music` 추가
|
||||
- **기존 사주·타로 렌더·로직 미변경, 음악만 추가**
|
||||
|
||||
- [ ] **Step 3: CLAUDE.md 갱신**
|
||||
- 핵심 IA 표에 `/music`(공개 음악 — 스토리→음악) 추가
|
||||
- 숨김 서비스 표에서 `/music/*` 행 제거
|
||||
- 파일 구조에 `lib/music/`, `api/studio/{story,tracks,callback}` 반영
|
||||
- `/mypage` 탭 서술에 음악 포함(AI 기록: 사주·타로·음악)
|
||||
|
||||
- [ ] **Step 4: 최종 검증**
|
||||
```bash
|
||||
grep -rnE "gradient|violet|purple|blur" app/music --include="*.tsx" # 0
|
||||
npm run build # /music·/music/studio·/music/samples·/api/studio/{story,tracks,callback} 라우트 존재
|
||||
npm test # 30+ PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add app/components/TopNav.tsx app/mypage/page.tsx CLAUDE.md && git commit -m "feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md"`
|
||||
|
||||
- [ ] **Step 6: CEO 안내(보고)**
|
||||
- 마이그레이션 `2026-07-03-phase3a-music.sql`을 클라우드+NAS 양쪽 적용(**phase2 마이그 적용 후**)
|
||||
- `SUNO_API_KEY`·`GEMINI_API_KEY` 운영 설정 확인(미설정 시 각 503)
|
||||
- 수동 E2E: 비로그인 /music/studio → 스토리 입력→가사→로그인 CTA→로그인 후 생성(일1회)→저장→마이페이지 AI기록 음악
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 음악 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/music --include="*.tsx"` | 0건 |
|
||||
| 단위 테스트 | `npm test` | ai-usage music 포함 전체 PASS |
|
||||
| 빌드 | `npm run build` | /music·studio·samples·api/studio/{story,tracks,callback} |
|
||||
@@ -0,0 +1,68 @@
|
||||
# Phase 2.6 사주 랜딩·입력 라이트 재스킨 설계
|
||||
|
||||
- 날짜: 2026-07-03
|
||||
- 선행: Phase 2.5(사주 결과 화면 라이트 재스킨) main 머지 완료(5ace251)
|
||||
- 배경: 2.5가 결과 화면만 라이트로 전환 → 공개 진입점(`/work/saju` 랜딩·입력)이 아직 다크로 남아 톤 불일치. 남은 3파일을 동일 방식으로 전환해 사주 서비스 전 화면 라이트 완결.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-03)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 방식 | **Phase 2.5와 완전 동일** — --jsm 라이트 순수 시각 재스킨, 색상 매핑 표 재사용 |
|
||||
| 범위 | 랜딩 `page.tsx` + 입력 `input/page.tsx` + `components/SajuForm.tsx` (3파일) |
|
||||
| 이모지 | **대상 3파일에 이모지 없음(grep 0)** — 아이콘 교체 불필요, 순수 색 재스킨 |
|
||||
|
||||
## 대상 (위반 분포, gradient/violet/purple/blur)
|
||||
|
||||
- `app/work/saju/page.tsx` — 랜딩(333줄, **9건**): 히어로 `bg-[#04102b]`+`repeating-linear-gradient` 텍스처, `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]` CTA 버튼, `violet-300/600/500` 텍스트·뱃지·테두리, AI PREMIUM 카드, MY RECORDS 섹션
|
||||
- `app/work/saju/input/page.tsx` — 입력(41줄, **3건**)
|
||||
- `app/work/saju/components/SajuForm.tsx` — 폼(220줄, **1건**)
|
||||
|
||||
## 원칙 (2.5 스펙 그대로)
|
||||
|
||||
- **순수 시각 변경**: 로직·데이터·props·라우팅 무변경. className/style만
|
||||
- 신규 색 토큰 금지 — 기존 11개 `--jsm-*`(bg/surface/surface-alt/ink/ink-soft/ink-faint/line/navy/accent/accent-soft/accent-hover)만
|
||||
- gradient / blur / 보라(violet/purple) / 이모지 **0건**(대상 3파일)
|
||||
- navy 밴드 = 무테두리 flat + 흰 CTA(2.5·`app/page.tsx`에서 확립된 사이트 관용구)
|
||||
- 의미색(있다면 상태 신호색)은 2.5 선례대로 보존
|
||||
|
||||
## 색상 매핑 (2.5와 동일 — 이 표대로 치환)
|
||||
|
||||
| 현재(제거) | 교체(→) |
|
||||
|------|------|
|
||||
| `#04102b` 다크 배경 + `repeating-linear-gradient` 텍스처 | `bg-[var(--jsm-navy)]` 플랫(히어로 밴드) / `bg-[var(--jsm-bg)]`(페이지) — 텍스처 제거 |
|
||||
| `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]` CTA | `bg-[var(--jsm-accent)]` 단색 + `hover:bg-[var(--jsm-accent-hover)]`, 흰 텍스트 |
|
||||
| `violet-300/500/600`·`#7c3aed`·`shadow-violet-*` | `var(--jsm-accent)` / navy 밴드 위 텍스트는 `var(--jsm-accent-soft)`(대비) |
|
||||
| amber 강조 | `var(--jsm-accent)` 또는 `var(--jsm-ink-soft)` |
|
||||
| 흰색/`blue-xx` 본문 텍스트 | `text-[var(--jsm-ink)]` / `ink-soft` / `ink-faint` |
|
||||
| 다크 카드/테두리 `#dbe8ff` 등 | `bg-[var(--jsm-surface)]` + `border-[var(--jsm-line)]` |
|
||||
|
||||
참고 관용구: `app/work/saju/result/page.tsx`(2.5 재스킨 완료본), `app/showcase/page.tsx`, `app/page.tsx`(navy CTA 밴드).
|
||||
|
||||
## 워크스트림 3개 (순차)
|
||||
|
||||
### WS1. 랜딩 page.tsx 재스킨 (9건)
|
||||
- 히어로 다크 밴드+repeating-linear-gradient 텍스처 → `bg-[var(--jsm-navy)]` 플랫, 페이지 배경 라이트
|
||||
- CTA 버튼 gradient → 플랫 accent
|
||||
- `violet-*` 텍스트/뱃지/테두리(부제·MY RECORDS 라벨·레코드 카드 hover·AI PREMIUM 카드) → --jsm 토큰
|
||||
- 서버/클라 로직·데이터 조회·JSX 구조 미변경
|
||||
|
||||
### WS2. input/page.tsx + SajuForm.tsx 재스킨 (4건)
|
||||
- 입력 화면·폼의 다크/gradient/violet → --jsm 토큰. 폼 필드·버튼 스타일 라이트
|
||||
- `useSajuForm` 등 로직·상태·핸들러 미변경
|
||||
|
||||
### WS3. 최종 검증 + CLAUDE.md
|
||||
- **사주 전체 게이트**: `grep -rnE "gradient|violet|purple|blur" app/work/saju/**/*.tsx`(result 포함) → 0건, 이모지 0건
|
||||
- `npm run build` 성공, `npm test`(30) 유지
|
||||
- CLAUDE.md 사주 시스템 섹션: "결과 화면"→"전 화면(랜딩·입력·결과) --jsm 라이트 재스킨 완료(2026-07-03)"로 갱신
|
||||
|
||||
## 범위 밖
|
||||
|
||||
- 로직·계산·프롬프트·데이터 무변경(순수 시각)
|
||||
- 이로써 사주 서비스 전 화면 라이트 완결 — 추가 재스킨 후속 없음
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- 랜딩 `page.tsx`가 클라이언트에서 saju 기록(MY RECORDS)을 조회하는 로직이 있으면 그 fetch/상태는 미변경 — className만
|
||||
- navy 밴드 위 accent 텍스트는 대비상 `accent-soft` 사용(2.5 선례)
|
||||
- 시각 회귀 자동 테스트 불가 → 빌드+게이트 grep + 수동 확인
|
||||
@@ -0,0 +1,90 @@
|
||||
# Phase 3a 음악 서비스 공개화 — "나의 이야기를 음악으로" 설계
|
||||
|
||||
- 날짜: 2026-07-03
|
||||
- 선행: Phase 2(사주/타로)·2.5/2.6(사주 재스킨) main 머지 완료
|
||||
- 배경: 운영 비전 2축 3번째 서비스(음악). 숨김 상태의 Suno 스튜디오를 공개·무료화하고, "스토리→음악" 흐름과 회원 저장을 추가. 영상화 유료는 Phase 3b로 분리.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-03)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 영상화 | **Phase 3b로 분리** — 계좌이체 발주(관리자 수동 제작·납품). 이번 3a 범위 밖 |
|
||||
| 음악 노출·과금 | **공개+무료** — 페이지 공개(숨김 해제), 생성은 로그인+일일제한(무료). 사주·타로 패턴 |
|
||||
| 스토리→음악 | **Gemini가 스토리→{가사·스타일·무드} 변환** 후 Suno 생성 |
|
||||
| 일일 제한 | 음악 생성 **1회/일**(Suno 비용) |
|
||||
| callback | `/api/studio/callback` **폴링 전용 확정** — 최소 200 응답 route로 댕글링 해소, 회원 저장은 폴링 완료 후 클라 트리거 |
|
||||
|
||||
## 확인된 기존 구조
|
||||
|
||||
- `POST /api/studio/generate`: **무인증**, `SUNO_API_KEY` 미설정 시 503, custom/simple 모드, `callBackUrl=${origin}/api/studio/callback`(부재), Suno `/api/v1/generate` 호출 → task 반환
|
||||
- `GET /api/studio/status?taskId=`: **무인증**, Suno `record-info` 폴링
|
||||
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `getTodayUsage`/`recordUsage`, KST 일일 집계. `ai_usage_log` CHECK는 `('saju','tarot')`(phase2 마이그, auto-name `ai_usage_log_service_check`)
|
||||
- `app/music/`: layout(가드 `isServiceVisible('music')`), page(72), samples(102), studio(543, 다크 테마 Suno UI)
|
||||
- 저장·마이페이지 통합 패턴: 타로 `interpret→readings save`, 마이페이지 'AI 기록' 탭(사주·타로)
|
||||
|
||||
## 워크스트림 5개
|
||||
|
||||
### WS1. 음악 공개화 + 무료화
|
||||
- `app/music/layout.tsx`의 `isServiceVisible('music')`+`notFound()`+import 제거 → 공개
|
||||
- `lib/service-visibility.ts` `HideableService`에서 `'music'` 제거 → `'gyeol'|'lotto'`
|
||||
- `app/api/admin/services/route.ts` DEFAULT_SERVICES music 행 제거
|
||||
- 마이그레이션에서 `service_settings` music 행 DELETE
|
||||
- Suno 키 미설정 시 503 유지(예시 폴백 없음)
|
||||
|
||||
### WS2. 스토리 → 음악 (Gemini + Suno)
|
||||
- 신규 `lib/music/story-prompt.ts`: `STORY_SYSTEM_PROMPT` + `buildStoryUserMessage(story)` + `parseStoryJson`/`validateStory`. Gemini가 스토리 텍스트 → `{ title, lyrics, style, mood }` strict JSON. 타로 prompt.ts 방어(코드블록 스트립·검증·reroll 1회, 45s 가드) 패턴 재사용
|
||||
- 신규 `POST /api/studio/story`: 인증(401) → Gemini 변환(GEMINI_API_KEY, 미설정 503) → `{ title, lyrics, style, mood }` 반환(사용자 편집 가능). **story 단계는 일일제한 미집계**(값싼 단계)
|
||||
- 기존 `POST /api/studio/generate`(Suno) 수정: 인증(401) 추가 + **일일제한(429, `MUSIC_DAILY_LIMIT`)** + Suno task 생성 성공 시에만 `recordUsage('music')`. body에 `title/lyrics/tags(style)`를 story 결과에서 채워 custom 모드로 호출
|
||||
- `GET /api/studio/status` 유지(무인증 폴링 가능 — taskId만 필요, 민감정보 없음)
|
||||
- **callback 정리**: 신규 `POST /api/studio/callback`이 최소 `{ ok: true }` 200 반환(Suno webhook 404 방지). 회원 저장은 콜백이 아니라 폴링 완료 후 클라가 `POST /api/studio/tracks` 트리거
|
||||
|
||||
### WS3. 회원 저장 + 일일제한
|
||||
- 마이그레이션 `supabase/migrations/2026-07-03-phase3a-music.sql`:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
story text,
|
||||
lyrics text,
|
||||
style text,
|
||||
audio_url text,
|
||||
task_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ai_usage_log CHECK에 'music' 추가 (phase2 마이그 적용 후 실행 전제)
|
||||
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'music';
|
||||
```
|
||||
**의존성**: phase2-saju-tarot 마이그(ai_usage_log 생성)가 먼저 적용돼야 함 — 스펙·플랜·CEO 안내에 명시
|
||||
- `lib/ai-usage.ts`: `AiService`에 `'music'` 추가, `export const MUSIC_DAILY_LIMIT = 1;`
|
||||
- 신규 `POST/GET /api/studio/tracks`: 저장(admin insert, user_id 세션)·본인 조회(세션 client RLS). 타로 readings 패턴
|
||||
|
||||
### WS4. 라이트 재스킨 + 스토리 UI
|
||||
- `app/music/{page,samples,studio}.tsx` 다크 → `--jsm` 라이트 재스킨(사주 2.5/2.6 패턴, gradient/blur/보라/이모지 0건). navy 밴드 무테두리 flat 관용구
|
||||
- **스토리 UI 흐름**(studio 재구성): ①스토리 textarea → `POST /studio/story` → ②가사·스타일 미리보기(편집 가능) → ③"음악 만들기" → `POST /studio/generate` → ④`status` 폴링 → ⑤플레이어 + (로그인 시) 자동 저장(`POST /studio/tracks`). 비로그인 생성 시도 → 로그인 CTA(`/login?next=/music/studio`)
|
||||
- 셔플류 클라 이슈 없음(폼 기반)
|
||||
|
||||
### WS5. 진입점 + AI기록 통합 + 문서
|
||||
- TopNav `음악` 링크 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크)
|
||||
- 마이페이지 'AI 기록' 탭에 음악 트랙 통합(`GET /api/studio/tracks`) — 사주·타로·음악 3종 병합 리스트, 음악은 제목·스토리 요약·오디오 링크
|
||||
- CLAUDE.md: 음악 공개 서비스·스토리→음악·music_tracks·studio API 반영, 숨김 서비스 표에서 music 제거
|
||||
- 최종 검증: `grep -rnE "gradient|violet|purple|blur" app/music/**/*.tsx` 0건, build+test 30, sajumusic 라우트 존재
|
||||
|
||||
## 범위 밖 (Phase 3b)
|
||||
|
||||
- 영상화 유료 발주(계좌이체 order로 video 신청·관리자 제작·납품·다운로드)
|
||||
- 음악 자동 영상 생성 API 연동
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- **Suno 실동작**은 `SUNO_API_KEY` 운영 설정 의존 — 로컬 미설정 시 503(사주 Gemini와 동일 정책). 수동 E2E는 운영 키 있는 환경에서
|
||||
- `ai_usage_log` CHECK ALTER는 phase2 마이그 DB 적용 후 실행돼야 함(미적용 시 ALTER 실패) — CEO 안내
|
||||
- studio 페이지가 큼(543줄) — 재스킨 + 스토리 UI 재구성은 태스크 분할(라이트 재스킨과 스토리 흐름 분리 가능)
|
||||
- Suno 응답 스키마(task/record-info)는 기존 status route가 이미 다룸 — 저장 시 audio_url 추출 지점은 구현 시 record-info 응답 구조 확인
|
||||
- 생성은 비동기(task) — recordUsage는 task 생성 성공(Suno 202/200) 시점 집계(완료 아님). 완료 실패해도 1회 소진되나 개인 서비스 규모에서 허용(사주·타로와 동일 기조)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT } from '../ai-usage';
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
|
||||
|
||||
describe('kstDayStartISO', () => {
|
||||
it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => {
|
||||
@@ -14,4 +14,7 @@ describe('kstDayStartISO', () => {
|
||||
expect(SAJU_DAILY_LIMIT).toBe(1);
|
||||
expect(TAROT_DAILY_LIMIT).toBe(3);
|
||||
});
|
||||
it('음악 일일 제한 상수', () => {
|
||||
expect(MUSIC_DAILY_LIMIT).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export const SAJU_DAILY_LIMIT = 1;
|
||||
export const TAROT_DAILY_LIMIT = 3;
|
||||
export type AiService = 'saju' | 'tarot';
|
||||
export const MUSIC_DAILY_LIMIT = 1;
|
||||
export type AiService = 'saju' | 'tarot' | 'music';
|
||||
|
||||
/** KST(UTC+9) 자정을 UTC ISO로. 오늘 사용량 집계 하한. */
|
||||
export function kstDayStartISO(now: Date): string {
|
||||
|
||||
45
lib/music/story-prompt.ts
Normal file
45
lib/music/story-prompt.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 스토리→가사 생성 프롬프트·스키마.
|
||||
// lib/tarot/prompt.ts의 방어 패턴(코드블록 스트립 + {...} 추출 + 스키마 검증)을 음악 스토리용으로 포팅.
|
||||
|
||||
export type MusicStory = {
|
||||
title: string;
|
||||
lyrics: string;
|
||||
style: string;
|
||||
mood: string;
|
||||
};
|
||||
|
||||
export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다.
|
||||
사용자가 들려준 이야기를 바탕으로:
|
||||
1. title: 노래 제목(짧고 인상적으로)
|
||||
2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄)
|
||||
3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo")
|
||||
4. mood: 전체 정서를 한 단어로(예 "그리움", "희망")
|
||||
반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"}
|
||||
사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`;
|
||||
|
||||
export function buildStoryUserMessage(story: string): string {
|
||||
return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`;
|
||||
}
|
||||
|
||||
/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */
|
||||
export function parseStoryJson(raw: string): MusicStory | null {
|
||||
let text = raw.trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim();
|
||||
const first = text.indexOf('{');
|
||||
const last = text.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) text = text.slice(first, last + 1);
|
||||
try {
|
||||
return JSON.parse(text) as MusicStory;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */
|
||||
export function validateStory(obj: unknown): string | null {
|
||||
if (!obj || typeof obj !== 'object') return 'not an object';
|
||||
const o = obj as Record<string, unknown>;
|
||||
for (const k of ['title', 'lyrics', 'style', 'mood']) {
|
||||
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
|
||||
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
|
||||
export type HideableService = 'music' | 'gyeol' | 'lotto';
|
||||
export type HideableService = 'gyeol' | 'lotto';
|
||||
|
||||
/**
|
||||
* 서비스 노출 여부. admin_token 세션이면 항상 true.
|
||||
|
||||
23
supabase/migrations/2026-07-03-phase3a-music.sql
Normal file
23
supabase/migrations/2026-07-03-phase3a-music.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
|
||||
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
|
||||
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
story text,
|
||||
lyrics text,
|
||||
style text,
|
||||
audio_url text,
|
||||
task_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
|
||||
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'music';
|
||||
Reference in New Issue
Block a user