Function bodies 248 total
PatternRow function · typescript · L337-L366 (30 LOC)src/app/dashboard/analytics/page.tsx
function PatternRow({ pattern, maxEngagement }: { pattern: PatternPerformance; maxEngagement: number }) {
const pct = maxEngagement > 0 ? (pattern.avgEngagement / maxEngagement) * 100 : 0;
const isTop = pattern.rank === "top";
return (
<div>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${isTop ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{HOOK_TYPE_LABELS[pattern.hookType] ?? pattern.hookType}
</span>
<span className="text-muted-foreground">+</span>
<span className="text-xs text-muted-foreground">
{CONTENT_FORMAT_LABELS[pattern.contentFormat] ?? pattern.contentFormat}
</span>
</div>
<span className="font-medium tabular-nums text-xs">
{pattern.avgEngagement}% 참여 / {pattern.avgViews.toLocaleString()}뷰 / {pattern.avgSaves}저장
ContentRow function · typescript · L368-L418 (51 LOC)src/app/dashboard/analytics/page.tsx
function ContentRow({ content }: { content: ContentItem }) {
const meta = content.metadata;
const hookLabel = meta?.hookType
? HOOK_TYPE_LABELS[meta.hookType as HookType] ?? meta.hookType
: null;
const formatLabel = meta?.contentFormat
? CONTENT_FORMAT_LABELS[meta.contentFormat as ContentFormat] ?? meta.contentFormat
: null;
const excerpt = content.copyText
? content.copyText.slice(0, 60) + (content.copyText.length > 60 ? "..." : "")
: "(카피 없음)";
return (
<div className="px-4 py-3 hover:bg-slate-50 transition">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{excerpt}</p>
<div className="flex items-center gap-2 mt-1">
{hookLabel && (
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-xs text-muted-foreground">
{hookLabel}
</span>
)}
{formatLabel && (
BrandActions function · typescript · L12-L59 (48 LOC)src/app/dashboard/brands/[id]/BrandActions.tsx
export default function BrandActions({ brandId }: BrandActionsProps) {
const router = useRouter();
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
const confirmed = confirm(
"이 브랜드를 삭제하시겠습니까? 관련 콘텐츠도 모두 삭제됩니다."
);
if (!confirmed) return;
setDeleting(true);
try {
const res = await apiFetch("/api/brands", {
method: "DELETE",
body: JSON.stringify({ id: brandId }),
});
const data = await res.json();
if (data.success) {
router.push("/dashboard/brands");
} else {
alert(data.error || "브랜드 삭제에 실패했습니다");
setDeleting(false);
}
} catch {
alert("브랜드 삭제 중 오류가 발생했습니다");
setDeleting(false);
}
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 px-4 py-2 text-sm text-red-600 hohandleDelete function · typescript · L16-L40 (25 LOC)src/app/dashboard/brands/[id]/BrandActions.tsx
async function handleDelete() {
const confirmed = confirm(
"이 브랜드를 삭제하시겠습니까? 관련 콘텐츠도 모두 삭제됩니다."
);
if (!confirmed) return;
setDeleting(true);
try {
const res = await apiFetch("/api/brands", {
method: "DELETE",
body: JSON.stringify({ id: brandId }),
});
const data = await res.json();
if (data.success) {
router.push("/dashboard/brands");
} else {
alert(data.error || "브랜드 삭제에 실패했습니다");
setDeleting(false);
}
} catch {
alert("브랜드 삭제 중 오류가 발생했습니다");
setDeleting(false);
}
}BrandEditPage function · typescript · L19-L247 (229 LOC)src/app/dashboard/brands/[id]/edit/page.tsx
export default function BrandEditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [form, setForm] = useState({
name: "",
igHandle: "",
ytChannel: "",
tiktokHandle: "",
tone: "",
description: "",
});
// 기존 브랜드 데이터 불러오기
useEffect(() => {
async function fetchBrand() {
try {
const res = await apiFetch(`/api/brands/${id}`);
const data = await res.json();
if (!data.success) {
if (res.status === 404) {
setNotFound(true);
} else {
setError(data.error || "브랜드를 불러올 수 없습니다");
}
setLoading(false);
return;
}
const brand: Brand = data.data;
setForm({
fetchBrand function · typescript · L43-L72 (30 LOC)src/app/dashboard/brands/[id]/edit/page.tsx
async function fetchBrand() {
try {
const res = await apiFetch(`/api/brands/${id}`);
const data = await res.json();
if (!data.success) {
if (res.status === 404) {
setNotFound(true);
} else {
setError(data.error || "브랜드를 불러올 수 없습니다");
}
setLoading(false);
return;
}
const brand: Brand = data.data;
setForm({
name: brand.name,
igHandle: brand.igHandle ?? "",
ytChannel: brand.ytChannel ?? "",
tiktokHandle: brand.tiktokHandle ?? "",
tone: brand.tone,
description: brand.description ?? "",
});
} catch {
setError("브랜드를 불러오는 중 오류가 발생했습니다");
} finally {
setLoading(false);
}
}handleSubmit function · typescript · L77-L105 (29 LOC)src/app/dashboard/brands/[id]/edit/page.tsx
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) {
setError("브랜드 이름은 필수입니다");
return;
}
setSaving(true);
setError(null);
try {
const res = await apiFetch("/api/brands", {
method: "PUT",
body: JSON.stringify({ id, ...form }),
});
const data = await res.json();
if (data.success) {
router.push(`/dashboard/brands/${id}`);
} else {
setError(data.error || "수정에 실패했습니다");
}
} catch {
setError("브랜드 수정 중 오류가 발생했습니다");
} finally {
setSaving(false);
}
}About: code-quality intelligence by Repobility · https://repobility.com
BrandDetailLoading function · typescript · L1-L39 (39 LOC)src/app/dashboard/brands/[id]/loading.tsx
export default function BrandDetailLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
<div className="h-8 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
</div>
<div className="h-9 w-28 bg-slate-200 rounded-lg animate-pulse" />
</div>
<div className="bg-white rounded-lg border p-5 grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-28 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
<div className="space-y-3">
<div className="h-6 w-28 bg-slate-200 rounded animate-pulse" />
{ArBrandDetailPage function · typescript · L8-L151 (144 LOC)src/app/dashboard/brands/[id]/page.tsx
export default async function BrandDetailPage({
params,
}: {
params: { id: string };
}) {
const session = await getServerSession(authOptions);
const brand = await prisma.brand.findFirst({
where: {
id: params.id,
userId: session!.user.id,
isActive: true,
},
include: {
contents: {
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
copyText: true,
status: true,
complianceStatus: true,
createdAt: true,
},
},
_count: { select: { contents: true } },
},
});
if (!brand) notFound();
const statusLabel: Record<string, string> = {
DRAFT: "초안",
PENDING: "대기",
APPROVED: "승인",
PUBLISHED: "발행",
REJECTED: "거절",
};
const statusColor: Record<string, string> = {
DRAFT: "bg-gray-100 text-gray-700",
PENDING: "bg-blue-100 text-blue-700",
APPROVED: "bg-green-100 text-green-700",
PUBLISHED: "bg-emerald-100 textBrandsLoading function · typescript · L1-L26 (26 LOC)src/app/dashboard/brands/loading.tsx
export default function BrandsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 w-36 bg-slate-200 rounded animate-pulse" />
<div className="h-9 w-32 bg-slate-200 rounded-lg animate-pulse" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg border p-5 space-y-3">
<div className="space-y-1.5">
<div className="h-5 w-28 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-16 bg-slate-200 rounded animate-pulse" />
</div>
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
<div className="flex gap-3">
<div className="h-3 w-20 bg-slate-200 rounded animate-pulse" />
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
<NewBrandPage function · typescript · L47-L299 (253 LOC)src/app/dashboard/brands/new/page.tsx
export default function NewBrandPage() {
const router = useRouter();
const [niches, setNiches] = useState<NicheItem[]>([]);
const [selectedNiche, setSelectedNiche] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
name: "",
igHandle: "",
ytChannel: "",
tiktokHandle: "",
tone: "",
description: "",
});
// Tone preset state
const [selectedTonePreset, setSelectedTonePreset] = useState<string | null>(null);
const [showCustomTone, setShowCustomTone] = useState(false);
// SNS collapsible
const [showSns, setShowSns] = useState(false);
useEffect(() => {
apiFetch("/api/niches")
.then((res) => res.json())
.then((data) => {
if (data.success) setNiches(data.data);
});
}, []);
function handleNicheSelect(nicheId: string) {
setSelectedNiche(nicheId);
// Auto-set tone from niche default — only ifhandleNicheSelect function · typescript · L78-L85 (8 LOC)src/app/dashboard/brands/new/page.tsx
function handleNicheSelect(nicheId: string) {
setSelectedNiche(nicheId);
// Auto-set tone from niche default — only if user hasn't manually chosen a preset
const defaultTone = NICHE_TONE_DEFAULTS[nicheId];
if (defaultTone && !selectedTonePreset) {
setForm((prev) => ({ ...prev, tone: defaultTone }));
}
}handleTonePreset function · typescript · L87-L101 (15 LOC)src/app/dashboard/brands/new/page.tsx
function handleTonePreset(presetId: string) {
setSelectedTonePreset(presetId);
if (presetId === "custom") {
setShowCustomTone(true);
setForm((prev) => ({ ...prev, tone: "" }));
return;
}
setShowCustomTone(false);
const toneMap: Record<string, string> = {
"professional-friendly": "전문적이면서도 친근한 톤. 신뢰감과 편안함을 동시에.",
"casual-fun": "캐주얼하고 재미있는 톤. 가볍고 유쾌하게 소통.",
"emotional-authentic": "감성적이고 진정성 있는 톤. 공감과 진심을 담아서.",
};
setForm((prev) => ({ ...prev, tone: toneMap[presetId] || "" }));
}handleSubmit function · typescript · L103-L130 (28 LOC)src/app/dashboard/brands/new/page.tsx
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedNiche) {
setError("업종을 선택해주세요");
return;
}
setLoading(true);
setError(null);
try {
const res = await apiFetch("/api/brands", {
method: "POST",
body: JSON.stringify({ ...form, niche: selectedNiche }),
});
const data = await res.json();
if (data.success) {
router.push("/dashboard/brands");
} else {
setError(data.error);
}
} catch {
setError("브랜드 등록에 실패했습니다");
} finally {
setLoading(false);
}
}BrandsPage function · typescript · L6-L73 (68 LOC)src/app/dashboard/brands/page.tsx
export default async function BrandsPage() {
const session = await getServerSession(authOptions);
const brands = await prisma.brand.findMany({
where: { userId: session!.user.id, isActive: true },
include: { _count: { select: { contents: true } } },
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">브랜드 관리</h1>
<Link
href="/dashboard/brands/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 transition"
>
+ 브랜드 등록
</Link>
</div>
{brands.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<div className="text-4xl mb-4">🏷️</div>
<h2 className="text-lg font-semibold">등록된 브랜드가 없습니다</h2>
<p className="text-muted-foregrRepobility · MCP-ready · https://repobility.com
CalendarPage function · typescript · L36-L335 (300 LOC)src/app/dashboard/calendar/page.tsx
export default function CalendarPage() {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const loadEvents = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch(`/api/calendar?year=${year}&month=${month}`);
const data = await res.json();
if (data.success) setEvents(data.data);
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
function prevMonth() {
if (month === 1) {
setYear(year - 1);
setMonth(12);
} else {
setMonth(month - 1);
}
setSelectedDate(null);
}
function nextMonth() {
if (month === 12) {
setYear(year + 1);
setMonth(1);
} else {
prevMonth function · typescript · L58-L66 (9 LOC)src/app/dashboard/calendar/page.tsx
function prevMonth() {
if (month === 1) {
setYear(year - 1);
setMonth(12);
} else {
setMonth(month - 1);
}
setSelectedDate(null);
}nextMonth function · typescript · L68-L76 (9 LOC)src/app/dashboard/calendar/page.tsx
function nextMonth() {
if (month === 12) {
setYear(year + 1);
setMonth(1);
} else {
setMonth(month + 1);
}
setSelectedDate(null);
}getDateStr function · typescript · L88-L90 (3 LOC)src/app/dashboard/calendar/page.tsx
function getDateStr(day: number) {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}getEventsForDate function · typescript · L92-L95 (4 LOC)src/app/dashboard/calendar/page.tsx
function getEventsForDate(day: number) {
const dateStr = getDateStr(day);
return events.filter((e) => e.date === dateStr);
}CharacterDetailPage function · typescript · L27-L420 (394 LOC)src/app/dashboard/character/[id]/page.tsx
export default function CharacterDetailPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const characterId = params.id;
const [loadingData, setLoadingData] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Form state
const [name, setName] = useState("");
const [gender, setGender] = useState("female");
const [ageRange, setAgeRange] = useState("");
const [basePrompt, setBasePrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [faceReferenceUrl, setFaceReferenceUrl] = useState("");
const [hairStyle, setHairStyle] = useState("");
const [facialFeatures, setFacialFeatures] = useState("");
const [fashionStyle, setFashionStyle] = useState("");
const [lightingStyle, setLightingStyle] = useState("");
const [pershandleSave function · typescript · L84-L126 (43 LOC)src/app/dashboard/character/[id]/page.tsx
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const speechPatterns = speechPatternsRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
try {
const res = await apiFetch(`/api/characters/${characterId}`, {
method: "PATCH",
body: JSON.stringify({
name,
gender,
ageRange: ageRange || undefined,
basePrompt,
negativePrompt: negativePrompt || undefined,
faceReferenceUrl: faceReferenceUrl || undefined,
hairStyle: hairStyle || undefined,
facialFeatures: facialFeatures || undefined,
fashionStyle: fashionStyle || undefined,
lightingStyle: lightingStyle || undefined,
personality,
speechPatterns,
voicePreset: voicePreset || undefined,
isActive,
}),
});
const data = await res.json();
if (data.success) {
routhandleDelete function · typescript · L128-L150 (23 LOC)src/app/dashboard/character/[id]/page.tsx
async function handleDelete() {
setDeleting(true);
setError(null);
try {
const res = await apiFetch(`/api/characters/${characterId}`, {
method: "DELETE",
});
const data = await res.json();
if (data.success) {
router.push("/dashboard/character");
} else {
setError(data.error || "삭제에 실패했습니다");
setConfirmDelete(false);
}
} catch {
setError("삭제에 실패했습니다");
setConfirmDelete(false);
} finally {
setDeleting(false);
}
}Same scanner, your repo: https://repobility.com — Repobility
applyVisualPreset function · typescript · L78-L93 (16 LOC)src/app/dashboard/character/new/page.tsx
function applyVisualPreset(preset: VisualPreset) {
setSelectedPreset(preset.id);
// Gender-aware: swap "female" / "male" in basePrompt
let prompt = preset.basePrompt;
if (gender === "male") {
prompt = prompt.replace(/female/gi, "male");
} else if (gender === "neutral") {
prompt = prompt.replace(/\s*female\s*/gi, " ").replace(/\s*male\s*/gi, " ");
}
setBasePrompt(prompt);
setNegativePrompt(preset.negativePrompt);
setHairStyle(preset.hairStyle);
setFacialFeatures(preset.facialFeatures);
setFashionStyle(preset.fashionStyle);
setLightingStyle(preset.lightingStyle);
}applyPersonalityPreset function · typescript · L95-L101 (7 LOC)src/app/dashboard/character/new/page.tsx
function applyPersonalityPreset(presetId: string) {
setSelectedPersonality(presetId);
const preset = PERSONALITY_PRESETS.find((p) => p.id === presetId);
if (preset) {
setPersonality(`${preset.label} 캐릭터. ${preset.description}.`);
}
}toggleSpeechPattern function · typescript · L103-L109 (7 LOC)src/app/dashboard/character/new/page.tsx
function toggleSpeechPattern(pattern: string) {
setSelectedSpeechPatterns((prev) =>
prev.includes(pattern)
? prev.filter((p) => p !== pattern)
: [...prev, pattern]
);
}canProceedStep1 function · typescript · L111-L113 (3 LOC)src/app/dashboard/character/new/page.tsx
function canProceedStep1() {
return brandId && name.trim();
}canProceedStep2 function · typescript · L115-L117 (3 LOC)src/app/dashboard/character/new/page.tsx
function canProceedStep2() {
return basePrompt.trim();
}handleNext function · typescript · L119-L125 (7 LOC)src/app/dashboard/character/new/page.tsx
function handleNext() {
if (step === 1 && canProceedStep1()) {
setStep(2);
} else if (step === 2 && canProceedStep2()) {
setStep(3);
}
}handleBack function · typescript · L127-L129 (3 LOC)src/app/dashboard/character/new/page.tsx
function handleBack() {
if (step > 1) setStep(step - 1);
}handleSubmit function · typescript · L131-L168 (38 LOC)src/app/dashboard/character/new/page.tsx
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await apiFetch("/api/characters", {
method: "POST",
body: JSON.stringify({
brandId,
name,
gender,
ageRange: ageRange || undefined,
basePrompt,
negativePrompt: negativePrompt || undefined,
faceReferenceUrl: faceReferenceUrl || undefined,
hairStyle: hairStyle || undefined,
facialFeatures: facialFeatures || undefined,
fashionStyle: fashionStyle || undefined,
lightingStyle: lightingStyle || undefined,
personality,
speechPatterns: selectedSpeechPatterns,
voicePreset: voicePreset || undefined,
}),
});
const data = await res.json();
if (data.success) {
router.push("/dashboard/character");
} else {
setError(data.error || "캐릭터 생성에 실패했습니다");
}
Powered by Repobility — scan your code at https://repobility.com
CharacterListPage function · typescript · L27-L152 (126 LOC)src/app/dashboard/character/page.tsx
export default function CharacterListPage() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch("/api/characters")
.then((res) => res.json())
.then((data) => {
if (data.success) {
setCharacters(data.data);
} else {
setError(data.error || "캐릭터 목록을 불러오지 못했습니다");
}
})
.catch(() => setError("캐릭터 목록을 불러오지 못했습니다"))
.finally(() => setLoading(false));
}, []);
return (
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">캐릭터 관리</h1>
<p className="text-muted-foreground mt-1">
브랜드별 가상 인플루언서 캐릭터를 관리합니다
</p>
</div>
<Link
href="/dashboard/character/new"
className="rounded-lg bg-primary px-4GenerateLoading function · typescript · L1-L36 (36 LOC)src/app/dashboard/generate/loading.tsx
export default function GenerateLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="h-8 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-64 bg-slate-200 rounded animate-pulse" />
</div>
<div className="bg-white rounded-lg border p-6 space-y-5">
{/* Brand selector */}
<div className="space-y-2">
<div className="h-4 w-16 bg-slate-200 rounded animate-pulse" />
<div className="h-10 w-full bg-slate-200 rounded-lg animate-pulse" />
</div>
{/* Topic input */}
<div className="space-y-2">
<div className="h-4 w-12 bg-slate-200 rounded animate-pulse" />
<div className="h-10 w-full bg-slate-200 rounded-lg animate-pulse" />
</div>
{/* Content type */}
<div className="space-y-2">
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
<div className="flex gap-3">
getCurrentSeason function · typescript · L52-L58 (7 LOC)src/app/dashboard/generate/page.tsx
function getCurrentSeason(): keyof SeasonalTopics {
const month = new Date().getMonth() + 1;
if (month >= 3 && month <= 5) return "spring";
if (month >= 6 && month <= 8) return "summer";
if (month >= 9 && month <= 11) return "autumn";
return "winter";
}handleGenerate function · typescript · L125-L157 (33 LOC)src/app/dashboard/generate/page.tsx
async function handleGenerate() {
if (!selectedBrand) return;
setLoading(true);
setError(null);
setResult(null);
try {
const res = await apiFetch("/api/generate", {
method: "POST",
body: JSON.stringify({
brandId: selectedBrand,
topic: topic || undefined,
type: contentType,
...(contentType === "CARD_NEWS" && { pageTypes }),
...(selectedCharacterId && { characterId: selectedCharacterId }),
}),
});
const data = await res.json();
if (data.success) {
setResult(data.data);
toast("콘텐츠 생성 완료", "success");
} else {
setError(data.error);
toast(data.error || "생성 실패", "error");
}
} catch {
setError("콘텐츠 생성에 실패했습니다");
toast("콘텐츠 생성에 실패했습니다", "error");
} finally {
setLoading(false);
}
}handleBatchGenerate function · typescript · L159-L194 (36 LOC)src/app/dashboard/generate/page.tsx
async function handleBatchGenerate() {
if (!selectedBrand) return;
setLoading(true);
setError(null);
setBatchResult(null);
try {
const topics = batchTopics
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
const res = await apiFetch("/api/generate/batch", {
method: "POST",
body: JSON.stringify({
brandId: selectedBrand,
count: batchCount,
topics: topics.length > 0 ? topics : undefined,
}),
});
const data = await res.json();
if (data.success) {
setBatchResult(data.data);
toast(`${data.data.generated}건 생성 완료`, "success");
} else {
setError(data.error);
toast(data.error || "배치 생성 실패", "error");
}
} catch {
setError("배치 생성에 실패했습니다");
toast("배치 생성에 실패했습니다", "error");
} finally {
setLoading(false);
}
}handleAction function · typescript · L627-L651 (25 LOC)src/app/dashboard/generate/page.tsx
async function handleAction(action: "approve" | "reject") {
setActionLoading(true);
setActionError(null);
try {
const res = await apiFetch("/api/queue", {
method: "PATCH",
body: JSON.stringify({ contentId, action }),
});
const data = await res.json();
if (data.success) {
onStatusChange(action === "approve" ? "APPROVED" : "REJECTED");
onToast(action === "approve" ? "승인 완료" : "거절 완료", "success");
} else {
const msg = data.error || "처리에 실패했습니다";
setActionError(msg);
onToast(msg, "error");
}
} catch {
const msg = "네트워크 오류가 발생했습니다";
setActionError(msg);
onToast(msg, "error");
} finally {
setActionLoading(false);
}
}DashboardLayout function · typescript · L6-L25 (20 LOC)src/app/dashboard/layout.tsx
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/");
}
return (
<div className="min-h-screen bg-slate-50">
<DashboardNav user={session.user} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
}DashboardLoading function · typescript · L1-L30 (30 LOC)src/app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg border p-4 space-y-2">
<div className="h-8 w-8 bg-slate-200 rounded animate-pulse" />
<div className="h-7 w-12 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg border p-6 space-y-3">
<div className="h-8 w-8 bg-slate-200 rounded animate-pulse" />
About: code-quality intelligence by Repobility · https://repobility.com
DashboardPage function · typescript · L7-L226 (220 LOC)src/app/dashboard/page.tsx
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
const userId = session!.user.id;
// 이번 달 시작일 (UTC)
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const [brandCount, pendingCount, approvedCount, publishedCount, redCount, usageGroups] =
await Promise.all([
prisma.brand.count({ where: { userId, isActive: true } }),
prisma.content.count({
where: { brand: { userId }, status: "PENDING" },
}),
prisma.content.count({
where: { brand: { userId }, status: "APPROVED" },
}),
prisma.content.count({
where: { brand: { userId }, status: "PUBLISHED" },
}),
prisma.content.count({
where: { brand: { userId }, complianceStatus: "RED" },
}),
prisma.usageRecord.groupBy({
by: ["service"],
where: { userId, createdAt: { gte: monthStart } },
_sum: { costUsd: true },
checkVideoEnabled function · typescript · L125-L142 (18 LOC)src/app/dashboard/queue/page.tsx
async function checkVideoEnabled() {
try {
const res = await apiFetch("/api/video/status?contentId=_probe");
const data = await res.json();
if (
res.status === 404 ||
(data.success === false && data.error?.includes("콘텐츠를 찾을 수 없습니다"))
) {
setVideoEnabled(true);
} else if (res.status === 503) {
setVideoEnabled(false);
} else {
setVideoEnabled(true);
}
} catch {
setVideoEnabled(false);
}
}checkImageEnabled function · typescript · L148-L164 (17 LOC)src/app/dashboard/queue/page.tsx
async function checkImageEnabled() {
try {
const res = await apiFetch("/api/image/status?contentId=_probe");
if (
res.status === 404 ||
res.status === 200
) {
setImageEnabled(true);
} else if (res.status === 503) {
setImageEnabled(false);
} else {
setImageEnabled(true);
}
} catch {
setImageEnabled(false);
}
}fetchBufferProfiles function · typescript · L170-L187 (18 LOC)src/app/dashboard/queue/page.tsx
async function fetchBufferProfiles() {
try {
const res = await apiFetch("/api/buffer");
const data = await res.json();
if (data.success) {
setBufferProfiles(data.data);
if (data.data.length === 0 && data.message) {
setBufferConfigured(false);
}
} else {
setBufferConfigured(false);
}
} catch {
setBufferConfigured(false);
} finally {
setBufferLoaded(true);
}
}copyToClipboard function · typescript · L207-L211 (5 LOC)src/app/dashboard/queue/page.tsx
function copyToClipboard(text: string, label: string) {
navigator.clipboard.writeText(text).then(() => {
toast(`${label} 복사됨`, "success");
});
}startEdit function · typescript · L214-L222 (9 LOC)src/app/dashboard/queue/page.tsx
function startEdit(content: QueueContent) {
setEditingId(content.id);
setEditForm({
copyText: content.copyText,
hashtags: content.hashtags || "",
videoPrompt: content.videoPrompt || "",
videoUrl: content.videoUrl || "",
});
}cancelEdit function · typescript · L224-L227 (4 LOC)src/app/dashboard/queue/page.tsx
function cancelEdit() {
setEditingId(null);
setEditForm({ copyText: "", hashtags: "", videoPrompt: "", videoUrl: "" });
}handleAction function · typescript · L230-L294 (65 LOC)src/app/dashboard/queue/page.tsx
async function handleAction(
contentId: string,
action: "approve" | "reject" | "edit" | "recheck",
extra?: Record<string, string>
) {
setActionLoading(contentId);
try {
const body: Record<string, string> = { contentId, action, ...extra };
if (action === "edit") {
body.editedCopyText = editForm.copyText;
body.editedHashtags = editForm.hashtags;
body.editedVideoPrompt = editForm.videoPrompt;
if (editForm.videoUrl) body.videoUrl = editForm.videoUrl;
}
// Card News image enforcement (Spec A)
if (action === "approve") {
const target = contents.find((c) => c.id === contentId);
if (target?.type === "CARD_NEWS" && (!target.cardImageUrls || target.cardImageUrls.length === 0)) {
toast("카드뉴스 이미지를 먼저 업로드해주세요", "error");
setActionLoading(null);
return;
}
}
if (action === "approve" && scheduledAt) {
body.scheduledAt = new Date(scheduledAt)Repobility · MCP-ready · https://repobility.com
handleImageGenerate function · typescript · L297-L330 (34 LOC)src/app/dashboard/queue/page.tsx
async function handleImageGenerate(contentId: string) {
setImageGenerating((prev) => new Set(prev).add(contentId));
try {
const res = await apiFetch("/api/image/generate", {
method: "POST",
body: JSON.stringify({ contentId }),
});
const data = await res.json();
if (data.success) {
setContents((prev) =>
prev.map((c) =>
c.id === contentId
? { ...c, imageStatus: "GENERATING" as const }
: c
)
);
toast(
`이미지 생성 시작 (${data.data.mode === "face-lock" ? "PuLID" : "Flux Pro"})`,
"success"
);
} else {
toast(data.error || "이미지 생성 실패", "error");
}
} catch {
toast("이미지 생성 요청 실패", "error");
} finally {
setImageGenerating((prev) => {
const next = new Set(prev);
next.delete(contentId);
return next;
});
}
}handleVideoGenerate function · typescript · L333-L361 (29 LOC)src/app/dashboard/queue/page.tsx
async function handleVideoGenerate(contentId: string) {
setVideoGenerating((prev) => new Set(prev).add(contentId));
try {
const res = await apiFetch("/api/video/generate", {
method: "POST",
body: JSON.stringify({ contentId, useImage: useImageMap[contentId] ?? false }),
});
const data = await res.json();
if (data.success) {
setContents((prev) =>
prev.map((c) => (c.id === contentId ? { ...c, videoStatus: "GENERATING" } : c))
);
setVideoProgress((prev) => ({ ...prev, [contentId]: 0 }));
toast(`영상 생성 시작 (${data.data.mode === "i2v" ? "I2V" : "T2V"})`, "success");
startPolling(contentId);
} else {
toast(data.error || "영상 생성 실패", "error");
}
} catch {
toast("영상 생성 요청 실패", "error");
} finally {
setVideoGenerating((prev) => {
const next = new Set(prev);
next.delete(contentId);
return next;
});
}
}getPublishChannel function · typescript · L364-L366 (3 LOC)src/app/dashboard/queue/page.tsx
function getPublishChannel(contentId: string): PublishChannel {
return publishChannel[contentId] ?? "buffer";
}