feat: 스튜디오 페이지 + Suno API 프록시 + 팩 상세 가격 최상단 재구성
- TopNav: 홈/샘플/팩 상세/스튜디오 4개 링크 구조 - /services/music: 컴팩트 헤더 + PRICING 최상단 배치 (상세 포맷) - /studio: Suno Generate UI (simple/custom 모드, 태그 프리셋, 폴링) - /api/studio/generate, /api/studio/status: Suno API 프록시
This commit is contained in:
76
app/api/studio/generate/route.ts
Normal file
76
app/api/studio/generate/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
type GenerateBody = {
|
||||||
|
mode: 'simple' | 'custom';
|
||||||
|
prompt?: string;
|
||||||
|
title?: string;
|
||||||
|
lyrics?: string;
|
||||||
|
tags?: string;
|
||||||
|
make_instrumental?: boolean;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const apiUrl = process.env.SUNO_API_URL;
|
||||||
|
const apiKey = process.env.SUNO_API_KEY;
|
||||||
|
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: GenerateBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as GenerateBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = body.mode === 'custom' ? '/api/custom_generate' : '/api/generate';
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
body.mode === 'custom'
|
||||||
|
? {
|
||||||
|
prompt: body.lyrics ?? '',
|
||||||
|
tags: body.tags ?? '',
|
||||||
|
title: body.title ?? '',
|
||||||
|
make_instrumental: !!body.make_instrumental,
|
||||||
|
model: body.model ?? 'chirp-v3-5',
|
||||||
|
wait_audio: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
prompt: body.prompt ?? '',
|
||||||
|
make_instrumental: !!body.make_instrumental,
|
||||||
|
model: body.model ?? 'chirp-v3-5',
|
||||||
|
wait_audio: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl.replace(/\/$/, '')}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '생성 실패', detail: data ?? (await res.text().catch(() => '')) },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, data });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Suno API 호출 오류', detail: e instanceof Error ? e.message : String(e) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/studio/status/route.ts
Normal file
39
app/api/studio/status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const apiUrl = process.env.SUNO_API_URL;
|
||||||
|
const apiKey = process.env.SUNO_API_KEY;
|
||||||
|
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const ids = searchParams.get('ids');
|
||||||
|
if (!ids) return NextResponse.json({ error: 'ids required' }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl.replace(/\/$/, '')}/api/get?ids=${encodeURIComponent(ids)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '조회 실패', detail: data },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, data });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '조회 오류', detail: e instanceof Error ? e.message : String(e) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import { useState, useEffect } from 'react';
|
|||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: '/', label: '홈' },
|
{ href: '/', label: '홈' },
|
||||||
{ href: '/services/music/samples', label: '샘플' },
|
{ href: '/services/music/samples', label: '샘플' },
|
||||||
{ href: '/services/music#pricing', label: '가격' },
|
|
||||||
{ href: '/services/music', label: '팩 상세' },
|
{ href: '/services/music', label: '팩 상세' },
|
||||||
|
{ href: '/studio', label: '스튜디오' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TopNav() {
|
export default function TopNav() {
|
||||||
|
|||||||
@@ -118,89 +118,89 @@ export default function MusicServicePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-slate-950 text-white">
|
<div className="min-h-full bg-slate-950 text-white">
|
||||||
{/* HERO */}
|
{/* 상세 페이지 헤더 (컴팩트) */}
|
||||||
<section
|
<section className="px-6 pt-14 pb-8 lg:px-14 bg-slate-950 border-b border-white/5">
|
||||||
className="relative overflow-hidden px-6 py-24 lg:px-14 lg:py-32"
|
<div className="max-w-6xl mx-auto">
|
||||||
style={{
|
<div className="flex items-center gap-3 mb-4">
|
||||||
background:
|
|
||||||
'radial-gradient(circle at 25% 20%, #2e1065 0%, #020617 55%), radial-gradient(circle at 80% 80%, #164e63 0%, transparent 50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 opacity-[0.05] pointer-events-none mix-blend-overlay"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.5'/></svg>\")",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative max-w-5xl mx-auto">
|
|
||||||
<div className="flex items-center gap-3 mb-8">
|
|
||||||
<span className="inline-flex h-2 w-2 rounded-full bg-violet-400 animate-pulse" />
|
<span className="inline-flex h-2 w-2 rounded-full bg-violet-400 animate-pulse" />
|
||||||
<span className="kx-label">AI MUSIC PACK · v1</span>
|
<span className="kx-label">AI MUSIC PACK · v1 · 상품 상세</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="kx-display text-3xl md:text-5xl font-extrabold leading-tight mb-3" style={{ wordBreak: 'keep-all' }}>
|
||||||
<h1
|
AI 음악 마스터 구조 팩
|
||||||
className="kx-display text-[2.8rem] md:text-[4rem] lg:text-[5.5rem] font-extrabold leading-[1.02] mb-6"
|
|
||||||
style={{ wordBreak: 'keep-all' }}
|
|
||||||
>
|
|
||||||
<span className="text-white">네 사연을 노래로.</span>
|
|
||||||
<br />
|
|
||||||
<span className="kx-gradient-text">
|
|
||||||
쇼츠까지 한 번에.
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-slate-300 md:text-lg max-w-3xl">
|
||||||
<p
|
Suno 프롬프트 + MV 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트.
|
||||||
className="text-slate-300 text-lg md:text-xl leading-relaxed mb-3 max-w-2xl"
|
4단계 AI 음악 제작 공정을 한 팩으로.
|
||||||
style={{ wordBreak: 'keep-all' }}
|
|
||||||
>
|
|
||||||
AI로 음악을 뽑는 게 아니라, <span className="text-white font-semibold">고품질 결과물을 빠르게</span> 뽑는 법을 팝니다.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-400 text-base mb-10 max-w-2xl">
|
</div>
|
||||||
엔지니어가 설계한 <span className="text-white">4단계 AI 음악 공정</span> · Suno Pro 검증.
|
</section>
|
||||||
|
|
||||||
|
{/* PRICING — 상세 최상단 */}
|
||||||
|
<section id="pricing" className="px-6 py-14 lg:px-14 bg-slate-950">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs text-violet-300/70 tracking-widest uppercase mb-1">Pricing · 1회 결제</p>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-extrabold">3개 티어, 목표에 맞게 선택</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/services/music/samples" className="text-sm text-violet-300 hover:text-violet-200 underline underline-offset-4">
|
||||||
|
샘플 먼저 보기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-5 items-stretch">
|
||||||
|
{(Object.keys(TIERS) as Tier[]).map((key) => {
|
||||||
|
const t = TIERS[key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`relative rounded-3xl p-8 flex flex-col border transition-all ${
|
||||||
|
t.highlight
|
||||||
|
? 'border-violet-400 bg-gradient-to-br from-violet-900/40 to-slate-900 shadow-[0_0_60px_rgba(139,92,246,0.35)] md:scale-[1.03] md:-translate-y-2'
|
||||||
|
: 'border-white/10 bg-white/[0.02] hover:border-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.highlight && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="inline-flex items-center gap-1 bg-gradient-to-r from-violet-500 to-pink-500 text-white text-[10px] font-extrabold px-3 py-1.5 rounded-full uppercase tracking-wider">
|
||||||
|
🔥 가장 많이 팔림
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="font-extrabold text-2xl mb-1">{t.name}</h3>
|
||||||
|
<p className="text-sm text-slate-400 mb-6">{t.desc}</p>
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-4xl font-extrabold font-mono">{t.price}</span>
|
||||||
|
<span className="text-xs text-slate-500 ml-2">1회 결제</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm text-slate-200 mb-8 flex-1">
|
||||||
|
{t.features.map((f) => (
|
||||||
|
<li key={f} className="flex gap-2.5">
|
||||||
|
<svg className="w-4 h-4 text-emerald-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="leading-relaxed">{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTier(key)}
|
||||||
|
className={`w-full py-4 rounded-xl font-extrabold text-sm transition-colors ${
|
||||||
|
t.highlight
|
||||||
|
? 'bg-violet-500 hover:bg-violet-400 text-white'
|
||||||
|
: 'bg-white/10 hover:bg-white/20 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.name} 구매하기 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 text-center mt-8">
|
||||||
|
구매 전 <Link href="/legal/refund" className="underline hover:text-white">환불 정책</Link>을 반드시 확인해주세요.
|
||||||
|
디지털 콘텐츠 특성상 제공 시작 후 청약철회가 제한됩니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 mb-10">
|
|
||||||
<a
|
|
||||||
href="#pricing"
|
|
||||||
className="inline-flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white px-8 py-4 rounded-xl font-bold text-sm transition-colors shadow-[0_0_40px_rgba(139,92,246,0.45)]"
|
|
||||||
>
|
|
||||||
팩 둘러보기
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#samples"
|
|
||||||
className="inline-flex items-center gap-2 border border-white/20 hover:border-white/50 text-white/90 hover:text-white px-8 py-4 rounded-xl font-semibold text-sm transition-all"
|
|
||||||
>
|
|
||||||
▶ 샘플 쇼츠 보기
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-5 text-xs text-slate-400">
|
|
||||||
<span className="flex items-center gap-1.5">✅ 평생 업데이트</span>
|
|
||||||
<span className="flex items-center gap-1.5">✅ 즉시 다운로드</span>
|
|
||||||
<span className="flex items-center gap-1.5">✅ Suno Pro 검증 샘플</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom waveform */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-32 opacity-40 pointer-events-none">
|
|
||||||
<svg viewBox="0 0 1200 120" preserveAspectRatio="none" className="w-full h-full">
|
|
||||||
<path
|
|
||||||
d="M0,60 Q150,10 300,60 T600,60 T900,60 T1200,60 L1200,120 L0,120 Z"
|
|
||||||
fill="url(#wg)"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="wg" x1="0%" x2="100%">
|
|
||||||
<stop offset="0%" stopColor="#a78bfa" stopOpacity="0.5" />
|
|
||||||
<stop offset="50%" stopColor="#22d3ee" stopOpacity="0.3" />
|
|
||||||
<stop offset="100%" stopColor="#a78bfa" stopOpacity="0.5" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -327,73 +327,6 @@ export default function MusicServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* PRICING */}
|
|
||||||
<section id="pricing" className="px-6 py-24 lg:px-14 bg-slate-950">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<p className="font-mono text-xs text-violet-300/70 tracking-widest uppercase mb-2 text-center">
|
|
||||||
Pricing
|
|
||||||
</p>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-extrabold text-center mb-4">
|
|
||||||
3개 티어, 내 목표에 맞게.
|
|
||||||
</h2>
|
|
||||||
<p className="text-center text-slate-400 mb-14">한 번 결제로 평생 업데이트.</p>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-5 items-stretch">
|
|
||||||
{(Object.keys(TIERS) as Tier[]).map((key) => {
|
|
||||||
const t = TIERS[key];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className={`relative rounded-3xl p-8 flex flex-col border transition-all ${
|
|
||||||
t.highlight
|
|
||||||
? 'border-violet-400 bg-gradient-to-br from-violet-900/40 to-slate-900 shadow-[0_0_60px_rgba(139,92,246,0.35)] md:scale-[1.03] md:-translate-y-2'
|
|
||||||
: 'border-white/10 bg-white/[0.02] hover:border-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.highlight && (
|
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
||||||
<span className="inline-flex items-center gap-1 bg-gradient-to-r from-violet-500 to-pink-500 text-white text-[10px] font-extrabold px-3 py-1.5 rounded-full uppercase tracking-wider">
|
|
||||||
🔥 80%가 선택
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h3 className="font-extrabold text-2xl mb-1">{t.name}</h3>
|
|
||||||
<p className="text-sm text-slate-400 mb-6">{t.desc}</p>
|
|
||||||
<div className="mb-6">
|
|
||||||
<span className="text-4xl font-extrabold font-mono">{t.price}</span>
|
|
||||||
<span className="text-xs text-slate-500 ml-2">1회 결제</span>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-3 text-sm text-slate-200 mb-8 flex-1">
|
|
||||||
{t.features.map((f) => (
|
|
||||||
<li key={f} className="flex gap-2.5">
|
|
||||||
<svg className="w-4 h-4 text-emerald-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<span className="leading-relaxed">{f}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTier(key)}
|
|
||||||
className={`w-full py-4 rounded-xl font-extrabold text-sm transition-colors ${
|
|
||||||
t.highlight
|
|
||||||
? 'bg-violet-500 hover:bg-violet-400 text-white'
|
|
||||||
: 'bg-white/10 hover:bg-white/20 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.name} 구매하기 →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 text-center mt-8">
|
|
||||||
구매 전 <Link href="/legal/refund" className="underline hover:text-white">환불 정책</Link>을 반드시 확인해주세요.
|
|
||||||
디지털 콘텐츠 특성상 제공 시작 후 청약철회가 제한됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* B2B */}
|
{/* B2B */}
|
||||||
<section className="px-6 py-16 lg:px-14 bg-gradient-to-br from-slate-900 to-slate-950 border-y border-white/5">
|
<section className="px-6 py-16 lg:px-14 bg-gradient-to-br from-slate-900 to-slate-950 border-y border-white/5">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
|
|||||||
511
app/studio/page.tsx
Normal file
511
app/studio/page.tsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type Mode = 'simple' | 'custom';
|
||||||
|
type Clip = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
video_url?: string;
|
||||||
|
metadata?: { tags?: string; prompt?: string; duration?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS = [
|
||||||
|
{ id: 'chirp-v3-5', label: 'v3.5 (고품질)', desc: '가장 풍부한 사운드' },
|
||||||
|
{ id: 'chirp-v3-0', label: 'v3.0 (균형)', desc: '속도·품질 밸런스' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAG_PRESETS = [
|
||||||
|
'k-pop', 'lo-fi', 'city pop', 'ballad', 'edm', 'trap',
|
||||||
|
'rock', 'jazz', 'acoustic', 'cinematic', 'synthwave', 'ambient',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LS_KEY = 'jsm_studio_clip_ids';
|
||||||
|
|
||||||
|
export default function StudioPage() {
|
||||||
|
const [mode, setMode] = useState<Mode>('simple');
|
||||||
|
const [model, setModel] = useState('chirp-v3-5');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [lyrics, setLyrics] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [instrumental, setInstrumental] = useState(false);
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [clips, setClips] = useState<Clip[]>([]);
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const activeIds = useMemo(() => clips.map((c) => c.id).join(','), [clips]);
|
||||||
|
|
||||||
|
const loadFromLS = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveToLS = useCallback((ids: string[]) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 30)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async (idsCsv: string) => {
|
||||||
|
if (!idsCsv) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/studio/status?ids=${encodeURIComponent(idsCsv)}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok && Array.isArray(json.data)) {
|
||||||
|
setClips((prev) => {
|
||||||
|
const map = new Map<string, Clip>(prev.map((c) => [c.id, c]));
|
||||||
|
for (const c of json.data as Clip[]) map.set(c.id, { ...map.get(c.id), ...c });
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = loadFromLS();
|
||||||
|
if (ids.length) {
|
||||||
|
setClips(ids.map((id) => ({ id, status: 'loading' })));
|
||||||
|
fetchStatus(ids.join(','));
|
||||||
|
}
|
||||||
|
}, [loadFromLS, fetchStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = clips.some((c) => c.status !== 'complete' && c.status !== 'error');
|
||||||
|
if (pollRef.current) window.clearInterval(pollRef.current);
|
||||||
|
if (pending && activeIds) {
|
||||||
|
pollRef.current = window.setInterval(() => fetchStatus(activeIds), 8000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) window.clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [clips, activeIds, fetchStatus]);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (mode === 'simple' && !prompt.trim()) {
|
||||||
|
setError('프롬프트를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'custom' && !lyrics.trim() && !instrumental) {
|
||||||
|
setError('가사를 입력하거나 Instrumental을 켜주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/studio/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode,
|
||||||
|
model,
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
title: title.trim(),
|
||||||
|
lyrics: lyrics.trim(),
|
||||||
|
tags: tags.trim(),
|
||||||
|
make_instrumental: instrumental,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) {
|
||||||
|
setError(json.error ?? '생성 실패');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newClips: Clip[] = (Array.isArray(json.data) ? json.data : []).map((c: Clip) => ({
|
||||||
|
...c,
|
||||||
|
status: c.status ?? 'submitted',
|
||||||
|
}));
|
||||||
|
if (!newClips.length) {
|
||||||
|
setError('응답에 결과가 없습니다. API URL 응답 포맷을 확인하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClips((prev) => {
|
||||||
|
const merged = [...newClips, ...prev];
|
||||||
|
saveToLS(merged.map((c) => c.id));
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = (t: string) => {
|
||||||
|
const cur = tags.split(',').map((x) => x.trim()).filter(Boolean);
|
||||||
|
if (cur.includes(t)) return;
|
||||||
|
setTags([...cur, t].join(', '));
|
||||||
|
};
|
||||||
|
|
||||||
|
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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs px-3 py-1.5 rounded-full border"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(204,151,255,0.35)',
|
||||||
|
background: 'rgba(204,151,255,0.1)',
|
||||||
|
color: 'var(--kx-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ v1 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) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
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)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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="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) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => addTag(t)}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
color: 'var(--kx-on-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {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="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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Generate */}
|
||||||
|
<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>
|
||||||
|
</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="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<span className="kx-label">RECENT TRACKS</span>
|
||||||
|
<h2 className="kx-display text-xl font-bold mt-1">최근 생성 결과</h2>
|
||||||
|
</div>
|
||||||
|
{clips.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setClips([]);
|
||||||
|
saveToLS([]);
|
||||||
|
}}
|
||||||
|
className="text-[11px] underline underline-offset-4"
|
||||||
|
style={{ color: 'var(--kx-on-variant)' }}
|
||||||
|
>
|
||||||
|
기록 지우기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clips.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)' }}
|
||||||
|
>
|
||||||
|
아직 생성된 트랙이 없습니다.
|
||||||
|
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3 max-h-[620px] overflow-y-auto pr-1">
|
||||||
|
{clips.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className="rounded-xl p-4 transition"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(20,31,56,0.6)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
||||||
|
{c.title || '제목 없음'}
|
||||||
|
</p>
|
||||||
|
{c.metadata?.tags && (
|
||||||
|
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
||||||
|
{c.metadata.tags}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={c.status} />
|
||||||
|
</div>
|
||||||
|
{c.audio_url ? (
|
||||||
|
<audio controls src={c.audio_url} className="w-full mt-2" style={{ height: 36 }} />
|
||||||
|
) : (
|
||||||
|
<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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
오디오 생성 중… (보통 1~3분)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
||||||
|
{c.audio_url && (
|
||||||
|
<a href={c.audio_url} download className="underline underline-offset-4 hover:text-white">
|
||||||
|
MP3 다운로드
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{c.video_url && (
|
||||||
|
<a href={c.video_url} target="_blank" rel="noreferrer" className="underline underline-offset-4 hover:text-white">
|
||||||
|
영상 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="opacity-50">id: {c.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||||
|
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
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 className="flex items-baseline justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status?: string }) {
|
||||||
|
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||||
|
complete: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
||||||
|
streaming: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '스트리밍' },
|
||||||
|
submitted: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
|
||||||
|
queued: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '큐' },
|
||||||
|
error: { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '오류' },
|
||||||
|
};
|
||||||
|
const s = map[status ?? ''] ?? { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status ?? '…' };
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||||
|
style={{ background: s.bg, color: s.fg }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)' }}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="leading-relaxed">{body}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user