← back to hyunwoooim-star__autoshorts-mvp-

Function bodies 248 total

All specs Real LLM only Function bodies
main function · typescript · L5-L37 (33 LOC)
prisma/seed.ts
async function main() {
  // 테스트 유저 생성
  const user = await prisma.user.upsert({
    where: { email: "[email protected]" },
    update: {},
    create: {
      email: "[email protected]",
      name: "테스트 유저",
    },
  });
  console.log("User:", user.id, user.email);

  // 테스트 브랜드 생성
  const brand = await prisma.brand.upsert({
    where: { id: "test-brand-001" },
    update: {},
    create: {
      id: "test-brand-001",
      userId: user.id,
      name: "글로우랩 뷰티",
      niche: "cosmetics",
      igHandle: "glowlab_beauty",
      tiktokHandle: "glowlab_beauty",
      tone: "친근하고 전문적인",
      description: "피부과학 기반 클린뷰티 브랜드. 20~30대 여성 타겟.",
    },
  });
  console.log("Brand:", brand.id, brand.name);

  console.log("\nSeed complete!");
  console.log("Test user ID:", user.id);
  console.log("Test brand ID:", brand.id);
}
parseCsvLine function · typescript · L89-L117 (29 LOC)
scripts/eval-auto-score.ts
function parseCsvLine(line: string): string[] {
  const cols: string[] = [];
  let current = "";
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQuotes) {
      if (ch === '"' && line[i + 1] === '"') {
        current += '"';
        i++;
      } else if (ch === '"') {
        inQuotes = false;
      } else {
        current += ch;
      }
    } else {
      if (ch === '"') {
        inQuotes = true;
      } else if (ch === ",") {
        cols.push(current);
        current = "";
      } else {
        current += ch;
      }
    }
  }
  cols.push(current);
  return cols;
}
scoreOne function · typescript · L119-L129 (11 LOC)
scripts/eval-auto-score.ts
async function scoreOne(
  niche: string,
  copyText: string
): Promise<{
  hookStrength: number;
  structure: number;
  brandFit: number;
  ctaClarity: number;
  complianceSafety: number;
  reasoning: string;
}> {
main function · typescript · L169-L274 (106 LOC)
scripts/eval-auto-score.ts
async function main() {
  const blindPath = path.join(__dirname, "..", "data", "eval-blind.csv");
  if (!fs.existsSync(blindPath)) {
    console.error("eval-blind.csv not found. Run: npx tsx scripts/eval-quality.ts export");
    process.exit(1);
  }

  const raw = fs.readFileSync(blindPath, "utf-8").trim().split("\n");
  const header = raw[0];
  const rows = raw.slice(1).map((line) => {
    const cols = parseCsvLine(line);
    return {
      contentId: cols[0]?.trim(),
      group: cols[1]?.trim(),
      niche: cols[2]?.trim(),
      copyText: cols[3]?.trim(),
    };
  }).filter((r) => r.contentId && r.group);

  console.log(`Scoring ${rows.length} items with Claude Haiku...\n`);

  const scored: EvalRow[] = [];
  let totalCost = 0;

  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    process.stdout.write(`  [${i + 1}/${rows.length}] ${row.contentId} (${row.group}, ${row.niche})... `);

    const score = await scoreOne(row.niche, row.copyText);

    const total = sco
loadNiche function · typescript · L25-L29 (5 LOC)
scripts/eval-generate.ts
function loadNiche(nicheId: string) {
  const filePath = path.join(__dirname, "..", "niche-templates", `${nicheId}.json`);
  if (!fs.existsSync(filePath)) return null;
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
buildSystemPrompt function · typescript · L32-L75 (44 LOC)
scripts/eval-generate.ts
function buildSystemPrompt(niche: any, recentHookTypes: string[]): string {
  const { prompt_builder } = require("../src/lib/prompt-builder");
  // fallback: 직접 구성
  const hookTypes = ["question", "shock", "empathy", "secret", "reversal", "list"];
  const formats = ["info", "empathy", "behind", "review", "event"];

  const avoidBlock = recentHookTypes.length > 0
    ? `\n<diversity>\n최근 사용된 훅: [${recentHookTypes.join(", ")}]. 이 훅은 피하고 다른 훅을 선택하세요.\n</diversity>`
    : "";

  return `당신은 한국 숏폼(인스타 릴스/유튜브 쇼츠) 성장 전문 카피라이터입니다.

<viral_framework>
6가지 훅 타입 중 하나를 선택하세요:
- question: 궁금증 유발 질문 ("이것도 모르고 바르셨어요?")
- shock: 충격/반전 사실 ("화장품 회사가 절대 안 알려주는 것")
- empathy: 공감 ("매일 아침 피부 때문에 우울했던 당신에게")
- secret: 비밀/인사이더 ("업계 10년차가 알려주는 꿀팁")
- reversal: 반전/파괴 ("비싼 게 다 좋은 줄 알았는데...")
- list: 숫자/리스트 ("피부 좋아지는 5가지 습관")
</viral_framework>

<content_formats>
5가지 콘텐츠 포맷:
- info: 정보 전달형 (팁, 노하우)
- empathy: 공감 스토리형 (경험, 감정)
- behind: 비하인드/과정 공개형
- review: 리뷰/비교형
- event: 이벤트/프로모션형
</content_formats>
${avoidBlock
generate function · typescript · L99-L130 (32 LOC)
scripts/eval-generate.ts
async function generate(brand: any, niche: any, recentHookTypes: string[], topic?: string) {
  const systemPrompt = buildSystemPrompt(niche, recentHookTypes);
  const userPrompt = `브랜드: ${brand.name}
톤: ${brand.tone}
${brand.description ? `설명: ${brand.description}` : ""}
${topic ? `주제: ${topic}` : "자유 주제로 생성해주세요."}

먼저 hookType과 contentFormat을 결정한 후, 카피를 작성하세요.`;

  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 900,
    temperature: 0.8,
    tools: [generateCopyTool],
    tool_choice: { type: "tool", name: "generate_copy" },
    system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
    messages: [{ role: "user", content: userPrompt }],
  });

  const toolBlock = response.content.find((b) => b.type === "tool_use");
  if (!toolBlock || toolBlock.type !== "tool_use") return null;

  const result = toolBlock.input as {
    hookType: string; contentFormat: string;
    copyText: string; hashtags: string; vid
Repobility (the analyzer behind this table) · https://repobility.com
main function · typescript · L132-L199 (68 LOC)
scripts/eval-generate.ts
async function main() {
  const countArg = Number(process.argv[2]) || 5;
  const brandIdArg = process.argv[3];

  const brands = await prisma.brand.findMany({
    where: brandIdArg ? { id: brandIdArg } : undefined,
  });

  if (brands.length === 0) {
    console.log("No brands found.");
    await prisma.$disconnect();
    return;
  }

  console.log(`Generating ${countArg} items per brand (${brands.length} brands)...`);
  let totalGenerated = 0;
  let totalTokens = 0;

  for (const brand of brands) {
    const niche = loadNiche(brand.niche);
    if (!niche) {
      console.log(`  Skipping ${brand.name}: niche '${brand.niche}' not found`);
      continue;
    }

    // 다양성 제어: 최근 hookType 누적
    const recentHookTypes: string[] = [];

    console.log(`\n  Brand: ${brand.name} (${brand.niche})`);

    for (let i = 0; i < countArg; i++) {
      process.stdout.write(`    [${i + 1}/${countArg}] generating...`);

      const result = await generate(brand, niche, recentHookTypes);
      if (!re
exportForEval function · typescript · L39-L75 (37 LOC)
scripts/eval-quality.ts
async function exportForEval() {
  // 최근 콘텐츠를 가져와서 metadata 유무로 old/new 구분
  const contents = await prisma.content.findMany({
    orderBy: { createdAt: "desc" },
    take: 100,
    include: { brand: { select: { niche: true, name: true } } },
  });

  const rows: EvalRow[] = contents.map((c) => ({
    contentId: c.id,
    group: c.metadata ? "new" : "old",
    niche: c.brand.niche,
    copyText: c.copyText,
  }));

  // 셔플 (블라인드)
  for (let i = rows.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [rows[i], rows[j]] = [rows[j], rows[i]];
  }

  // CSV 이스케이프: 줄바꿈을 \n 리터럴로, 큰따옴표는 "" 이스케이프
  const csvEscape = (s: string) =>
    `"${s.replace(/"/g, '""').replace(/\r?\n/g, "\\n")}"`;

  const header = "contentId,group,niche,copyText,hookStrength,structure,brandFit,ctaClarity,complianceSafety";
  const csv = [header, ...rows.map((r) =>
    `${r.contentId},${r.group},${r.niche},${csvEscape(r.copyText)},,,,,`
  )].join("\n");

  const outPath = path.join(__dirnam
analyzeResults function · typescript · L77-L177 (101 LOC)
scripts/eval-quality.ts
function analyzeResults() {
  const csvPath = path.join(__dirname, "..", "data", "eval-scored.csv");
  if (!fs.existsSync(csvPath)) {
    console.log("No eval-scored.csv found. Run evaluation first.");
    console.log("1. Export: npx tsx scripts/eval-quality.ts export");
    console.log("2. Score each row in data/eval-blind.csv (1~5 per column)");
    console.log("3. Save as data/eval-scored.csv");
    console.log("4. Analyze: npx tsx scripts/eval-quality.ts analyze");
    return;
  }

  // RFC 4180 CSV 파서: 큰따옴표 필드 지원
  function parseCsvLine(line: string): string[] {
    const cols: string[] = [];
    let current = "";
    let inQuotes = false;
    for (let i = 0; i < line.length; i++) {
      const ch = line[i];
      if (inQuotes) {
        if (ch === '"' && line[i + 1] === '"') {
          current += '"';
          i++;
        } else if (ch === '"') {
          inQuotes = false;
        } else {
          current += ch;
        }
      } else {
        if (ch === '"') {
          i
parseCsvLine function · typescript · L89-L117 (29 LOC)
scripts/eval-quality.ts
  function parseCsvLine(line: string): string[] {
    const cols: string[] = [];
    let current = "";
    let inQuotes = false;
    for (let i = 0; i < line.length; i++) {
      const ch = line[i];
      if (inQuotes) {
        if (ch === '"' && line[i + 1] === '"') {
          current += '"';
          i++;
        } else if (ch === '"') {
          inQuotes = false;
        } else {
          current += ch;
        }
      } else {
        if (ch === '"') {
          inQuotes = true;
        } else if (ch === ",") {
          cols.push(current);
          current = "";
        } else {
          current += ch;
        }
      }
    }
    cols.push(current);
    return cols;
  }
main function · typescript · L179-L192 (14 LOC)
scripts/eval-quality.ts
async function main() {
  const command = process.argv[2] || "export";
  try {
    if (command === "export") {
      await exportForEval();
    } else if (command === "analyze") {
      analyzeResults();
    } else {
      console.log("Usage: npx tsx scripts/eval-quality.ts [export|analyze]");
    }
  } finally {
    await prisma.$disconnect();
  }
}
GET function · typescript · L8-L62 (55 LOC)
src/app/api/analytics/patterns/killed/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "analytics-killed", 20, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다." },
        { status: 429 }
      );
    }

    const { searchParams } = new URL(req.url);
    const brandId = searchParams.get("brandId");

    if (!brandId) {
      return NextResponse.json(
        { success: false, error: "brandId가 필요합니다" },
        { status: 400 }
      );
    }

    // Verify ownership
    const brand = await prisma.brand.findFirst({
      where: { id: brandId, userId: session.user.id },
      select: { id: true },
    });

    if (!brand) {
      return NextResponse.json(
        { success: false, error: "브랜드를 찾을 수 없습니다" },
  
GET function · typescript · L8-L62 (55 LOC)
src/app/api/analytics/patterns/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "analytics-patterns", 20, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다." },
        { status: 429 }
      );
    }

    const { searchParams } = new URL(req.url);
    const brandId = searchParams.get("brandId");

    if (!brandId) {
      return NextResponse.json(
        { success: false, error: "brandId가 필요합니다" },
        { status: 400 }
      );
    }

    // Verify ownership
    const brand = await prisma.brand.findFirst({
      where: { id: brandId, userId: session.user.id },
      select: { id: true },
    });

    if (!brand) {
      return NextResponse.json(
        { success: false, error: "브랜드를 찾을 수 없습니다" },
POST function · typescript · L33-L118 (86 LOC)
src/app/api/analytics/route.ts
export async function POST(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rateLimit = checkRateLimit(session.user.id, "analytics", 30, 60_000);
    if (!rateLimit.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다." },
        { status: 429 }
      );
    }

    const body = await req.json();

    if (body === null || typeof body !== "object" || Array.isArray(body)) {
      return NextResponse.json(
        { success: false, error: "요청 본문은 JSON 객체여야 합니다" },
        { status: 400 }
      );
    }

    // Support both single and batch upsert
    const isBatch = "items" in body;
    const items = isBatch
      ? batchUpsertSchema.parse(body).items
      : [upsertSchema.parse(body)];

    const results: { contentId: string; updated: boolean }[] = [];

    f
Open data scored by Repobility · https://repobility.com
GET function · typescript · L122-L263 (142 LOC)
src/app/api/analytics/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rlGet = checkRateLimit(session.user.id, "analytics-get", 30, 60_000);
    if (!rlGet.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다." },
        { status: 429 }
      );
    }

    const { searchParams } = new URL(req.url);
    const brandId = searchParams.get("brandId");
    const from = searchParams.get("from");
    const to = searchParams.get("to");
    const limit = Math.min(Number(searchParams.get("limit")) || 50, 100);
    const sortBy = searchParams.get("sortBy") || "createdAt";
    const order = searchParams.get("order") === "asc" ? "asc" : "desc";

    // Build where clause
    const where: Record<string, unknown> = {
      brand: { userId: session.user.id },
      // 
GET function · typescript · L7-L49 (43 LOC)
src/app/api/brands/[id]/route.ts
export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "brand-get", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다." },
        { status: 429 }
      );
    }

    const { id } = await params;

    const brand = await prisma.brand.findFirst({
      where: { id, userId: session.user.id },
    });

    if (!brand) {
      return NextResponse.json(
        { success: false, error: "브랜드를 찾을 수 없습니다" },
        { status: 404 }
      );
    }

    return NextResponse.json({ success: true, data: brand });
  } catch (error) {
    console.error("브랜드 조회 실패:", error);
    return NextResponse.json(
      { success: false, error:
GET function · typescript · L34-L66 (33 LOC)
src/app/api/brands/route.ts
export async function GET() {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "brands-list", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const brands = await prisma.brand.findMany({
      where: { userId: session.user.id, isActive: true },
      include: { _count: { select: { contents: true } } },
      orderBy: { createdAt: "desc" },
    });

    return NextResponse.json({ success: true, data: brands });
  } catch (error) {
    console.error("브랜드 목록 조회 실패:", error);
    return NextResponse.json(
      { success: false, error: "브랜드 목록을 불러올 수 없습니다" },
      { status: 500 }
    );
  }
}
POST function · typescript · L69-L160 (92 LOC)
src/app/api/brands/route.ts
export async function POST(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "brands-create", 10, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = brandCreateSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    // 플랜별 브랜드 수 제한 체크
    const [existingCount, user] = await Promise.all([
      prisma.brand.count({
        where: { userId: session.user.id, isActive: true },
      }),
      prisma.user
PUT function · typescript · L163-L230 (68 LOC)
src/app/api/brands/route.ts
export async function PUT(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "brands-update", 10, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = brandUpdateSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    const { id, ...fields } = parsed.data;

    // 소유권 확인
    const existing = await prisma.brand.findFirst({
      where: { id, userId: session.user.id, isActive: true },
    });

    if (!ex
DELETE function · typescript · L233-L291 (59 LOC)
src/app/api/brands/route.ts
export async function DELETE(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "brands-delete", 5, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = brandDeleteSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    const { id } = parsed.data;

    // 소유권 확인
    const existing = await prisma.brand.findFirst({
      where: { id, userId: session.user.id, isActive: true },
    });

    if (!existing) {
GET function · typescript · L8-L43 (36 LOC)
src/app/api/buffer/route.ts
export async function GET() {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "buffer-profiles", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    if (!process.env.BUFFER_ACCESS_TOKEN) {
      return NextResponse.json({
        success: true,
        data: [],
        message: "Buffer 연동이 설정되지 않았습니다",
      });
    }

    const profiles = await getBufferProfiles();
    return NextResponse.json({ success: true, data: profiles });
  } catch (error) {
    console.error("Buffer 프로필 조회 실패:", error);
    return NextResponse.json(
      { success: false, error: "Buffer 프로필을 불러올 수 없습니다" },
      { status: 500 }
    );
  }
}
GET function · typescript · L9-L110 (102 LOC)
src/app/api/calendar/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "calendar", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const calendarParamsSchema = z.object({
      year: z.coerce.number().int().min(2000).max(2100).optional(),
      month: z.coerce.number().int().min(1).max(12).optional(),
    });

    const { searchParams } = new URL(req.url);
    const paramsParsed = calendarParamsSchema.safeParse({
      year: searchParams.get("year") ?? undefined,
      month: searchParams.get("month") ?? undefined,
    });

    if (!paramsParsed.success) {
      return NextResponse.json(
        {
          success: false,
Citation: Repobility (2026). State of AI-Generated Code. https://repobility.com/research/
isValidImage function · typescript · L18-L29 (12 LOC)
src/app/api/card-news/upload/route.ts
function isValidImage(buffer: Buffer): boolean {
  if (buffer.length < 12) return false;
  return IMAGE_SIGNATURES.some((sig) => {
    const headerMatch = sig.bytes.every((b, i) => buffer[i] === b);
    if (!headerMatch) return false;
    // WebP: also check "WEBP" at offset 8
    if (sig.mime === "image/webp") {
      return buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
    }
    return true;
  });
}
POST function · typescript · L32-L142 (111 LOC)
src/app/api/card-news/upload/route.ts
export async function POST(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // Rate limit: 분당 10회
    const rl = checkRateLimit(session.user.id, "card-news-upload", 10, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "업로드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const formData = await req.formData();
    const contentId = formData.get("contentId") as string;
    if (!contentId || typeof contentId !== "string") {
      return NextResponse.json(
        { success: false, error: "contentId가 필요합니다" },
        { status: 400 }
      );
    }

    // 소유권 + 타입 확인
    const content = await prisma.content.findFirst({
      where: { id: contentId, brand: { userId: session.user.id } },
    });
    if (!content) {
      return 
getOwnedCharacter function · typescript · L29-L37 (9 LOC)
src/app/api/characters/[id]/route.ts
async function getOwnedCharacter(id: string, userId: string) {
  return prisma.character.findFirst({
    where: {
      id,
      brand: { userId },
    },
    include: { brand: { select: { id: true, name: true, userId: true } } },
  });
}
GET function · typescript · L40-L78 (39 LOC)
src/app/api/characters/[id]/route.ts
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "characters-get", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const character = await getOwnedCharacter(params.id, session.user.id);

    if (!character) {
      return NextResponse.json(
        { success: false, error: "캐릭터를 찾을 수 없습니다" },
        { status: 404 }
      );
    }

    return NextResponse.json({ success: true, data: character });
  } catch (error) {
    console.error("캐릭터 조회 실패:", error);
    return NextResponse.json(
      { success: false, error: "캐릭터를 불러올 수 없습니다" },
      { status: 500 }
PATCH function · typescript · L81-L156 (76 LOC)
src/app/api/characters/[id]/route.ts
export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "characters-update", 10, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = characterUpdateSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    // 소유권 확인
    const existing = await getOwnedCharacter(params.id, session.user.id);
    if (!existing) {
      return NextResponse.j
DELETE function · typescript · L159-L202 (44 LOC)
src/app/api/characters/[id]/route.ts
export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "characters-delete", 5, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    // 소유권 확인
    const existing = await getOwnedCharacter(params.id, session.user.id);
    if (!existing) {
      return NextResponse.json(
        { success: false, error: "캐릭터를 찾을 수 없습니다" },
        { status: 404 }
      );
    }

    await prisma.character.update({
      where: { id: params.id },
      data: { isActive: false },
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("캐릭터 삭제 실패:", er
GET function · typescript · L29-L84 (56 LOC)
src/app/api/characters/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "characters-list", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const { searchParams } = new URL(req.url);
    const brandId = searchParams.get("brandId");

    // 유저 소유 브랜드 ID 목록 조회 (소유권 보장)
    const userBrands = await prisma.brand.findMany({
      where: { userId: session.user.id, isActive: true },
      select: { id: true },
    });
    const userBrandIds = userBrands.map((b) => b.id);

    const whereClause = {
      brandId: brandId
        ? // 특정 브랜드 필터 + 소유권 확인
          userBrandIds.includes(brandId)
          ? brandId
          : "__none__
POST function · typescript · L87-L158 (72 LOC)
src/app/api/characters/route.ts
export async function POST(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "characters-create", 10, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = characterCreateSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    // 브랜드 소유권 확인
    const brand = await prisma.brand.findFirst({
      where: { id: parsed.data.brandId, userId: session.user.id, isActive: true },
    });

    if (!brand) {
      
Want this analysis on your repo? https://repobility.com/scan/
POST function · typescript · L25-L255 (231 LOC)
src/app/api/image/generate/route.ts
export async function POST(req: NextRequest) {
  const idempotencyKey = getIdempotencyKey(req);

  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 멱등성 체크
    const idem = await checkIdempotency(
      idempotencyKey,
      "image-generate",
      session.user.id
    );
    if (idem.isDuplicate) {
      if (idem.cachedResponse) return idem.cachedResponse;
      return NextResponse.json(
        { success: false, error: "이전 요청이 처리 중입니다" },
        { status: 409 }
      );
    }

    if (!isImageEnabled()) {
      await failIdempotency(idempotencyKey);
      return NextResponse.json(
        {
          success: false,
          error: "이미지 생성이 설정되지 않았습니다 (FAL_KEY 필요)",
        },
        { status: 503 }
      );
    }

    // Rate limit: 10 requests per 60 seconds
    const rateLimit = checkRateLimit(
      session.us
GET function · typescript · L14-L290 (277 LOC)
src/app/api/image/status/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // Rate limit: 30 polls per minute
    const rateLimit = checkRateLimit(
      session.user.id,
      "image-status",
      30,
      60_000
    );
    if (!rateLimit.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const contentIdSchema = z.string().min(1, "contentId가 필요합니다");
    const contentIdParam = req.nextUrl.searchParams.get("contentId");
    const contentIdParsed = contentIdSchema.safeParse(contentIdParam);
    if (!contentIdParsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: contentIdParsed.error.issues
            .map((i) => i.message)
            .join(", "),
POST function · typescript · L22-L202 (181 LOC)
src/app/api/image/upscale/route.ts
export async function POST(req: NextRequest) {
  const idempotencyKey = getIdempotencyKey(req);

  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 멱등성 체크
    const idem = await checkIdempotency(
      idempotencyKey,
      "image-upscale",
      session.user.id
    );
    if (idem.isDuplicate) {
      if (idem.cachedResponse) return idem.cachedResponse;
      return NextResponse.json(
        { success: false, error: "이전 요청이 처리 중입니다" },
        { status: 409 }
      );
    }

    if (!isImageEnabled()) {
      await failIdempotency(idempotencyKey);
      return NextResponse.json(
        {
          success: false,
          error: "이미지 업스케일이 설정되지 않았습니다 (FAL_KEY 필요)",
        },
        { status: 503 }
      );
    }

    // Rate limit: 5 requests per 60 seconds (업스케일은 비용 더 높음)
    const rateLimit = checkRateLimit(
GET function · typescript · L5-L29 (25 LOC)
src/app/api/niches/route.ts
export async function GET(req: NextRequest) {
  // IP-based rate limit for this intentionally public endpoint
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    req.headers.get("x-real-ip") ??
    "unknown";
  const rl = checkRateLimit(`ip:${ip}`, "niches", 60, 60_000);
  if (!rl.allowed) {
    return NextResponse.json(
      { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
      { status: 429 }
    );
  }

  try {
    const niches = getNicheList();
    return NextResponse.json({ success: true, data: niches });
  } catch (error) {
    console.error("니치 목록 조회 실패:", error);
    return NextResponse.json(
      { success: false, error: "니치 목록을 불러올 수 없습니다" },
      { status: 500 }
    );
  }
}
loadInstagramModule function · typescript · L33-L41 (9 LOC)
src/app/api/publish/route.ts
async function loadInstagramModule(): Promise<InstagramModule | null> {
  try {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const mod = (await import("@/lib/instagram")) as any;
    return mod as InstagramModule;
  } catch {
    return null;
  }
}
cleanupZombieLocks function · typescript · L44-L62 (19 LOC)
src/app/api/publish/route.ts
async function cleanupZombieLocks(): Promise<void> {
  const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
  await prisma.content.updateMany({
    where: {
      status: "APPROVED",
      bufferPostId: { startsWith: "pending_" },
      updatedAt: { lt: tenMinutesAgo },
    },
    data: { bufferPostId: null, igMediaId: null },
  });
  await prisma.content.updateMany({
    where: {
      status: "APPROVED",
      igMediaId: { startsWith: "pending_" },
      updatedAt: { lt: tenMinutesAgo },
    },
    data: { igMediaId: null },
  });
}
POST function · typescript · L65-L459 (395 LOC)
src/app/api/publish/route.ts
export async function POST(req: NextRequest) {
  const idempotencyKey = getIdempotencyKey(req);

  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 멱등성 체크
    const idem = await checkIdempotency(idempotencyKey, "publish", session.user.id);
    if (idem.isDuplicate) {
      if (idem.cachedResponse) return idem.cachedResponse;
      return NextResponse.json(
        { success: false, error: "이전 요청이 처리 중입니다" },
        { status: 409 }
      );
    }

    const rl = checkRateLimit(session.user.id, "publish", 5, 60_000);
    if (!rl.allowed) {
      await failIdempotency(idempotencyKey);
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = publishSchema.safeParse(body);
    if (!parse
GET function · typescript · L11-L62 (52 LOC)
src/app/api/queue/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "queue-list", 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const url = new URL(req.url);
    const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 50, 1), 100);
    const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);

    const [contents, total] = await Promise.all([
      prisma.content.findMany({
        where: {
          brand: { userId: session.user.id },
          status: { in: ["PENDING", "DRAFT", "APPROVED"] },
        },
        include: {
          brand: { select: { id: true, name: true, niche: true }
Repobility (the analyzer behind this table) · https://repobility.com
PATCH function · typescript · L76-L262 (187 LOC)
src/app/api/queue/route.ts
export async function PATCH(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    const rl = checkRateLimit(session.user.id, "queue-action", 20, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const body = await req.json();
    const parsed = queueActionSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        {
          success: false,
          error: parsed.error.issues.map((i) => i.message).join(", "),
        },
        { status: 400 }
      );
    }

    // 콘텐츠 소유권 확인
    const content = await prisma.content.findFirst({
      where: {
        id: parsed.data.contentId,
        brand: { userId: session.user.id },
      },
    });

    if (!
GET function · typescript · L13-L60 (48 LOC)
src/app/api/uploads/[...path]/route.ts
export async function GET(
  _req: NextRequest,
  { params }: { params: { path: string[] } }
) {
  const segments = params.path;

  // Path traversal 방지
  if (segments.some((s) => s.includes("..") || s.includes("~"))) {
    return NextResponse.json({ error: "Invalid path" }, { status: 400 });
  }

  const filePath = path.join(process.cwd(), "uploads", ...segments);
  const resolved = path.resolve(filePath);
  const uploadRoot = path.resolve(path.join(process.cwd(), "uploads"));

  if (!resolved.startsWith(uploadRoot)) {
    return NextResponse.json({ error: "Invalid path" }, { status: 400 });
  }

  try {
    const ext = path.extname(resolved).toLowerCase();
    const ALLOWED_MIME: Record<string, string> = {
      ".png": "image/png",
      ".jpg": "image/jpeg",
      ".jpeg": "image/jpeg",
      ".webp": "image/webp",
      ".mp3": "audio/mpeg",
      ".wav": "audio/wav",
      ".mp4": "video/mp4",
    };
    const contentType = ALLOWED_MIME[ext];
    if (!contentType) {
      return 
GET function · typescript · L24-L68 (45 LOC)
src/app/api/usage/route.ts
export async function GET() {
  const session = await getServerSession(authOptions);
  if (!session?.user?.id) {
    return NextResponse.json(
      { success: false, error: "로그인이 필요합니다" },
      { status: 401 }
    );
  }

  // 이번 달 시작일 (UTC)
  const now = new Date();
  const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
  const monthLabel = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;

  // 서비스별 GROUP BY 집계 — Prisma groupBy 사용
  const groups = await prisma.usageRecord.groupBy({
    by: ["service"],
    where: {
      userId: session.user.id,
      createdAt: { gte: monthStart },
    },
    _sum: { costUsd: true, units: true },
    _count: { id: true },
  });

  const breakdown: UsageSummaryItem[] = groups.map((g) => ({
    service: g.service,
    totalUnits: Number(g._sum.units ?? 0),
    totalCostUsd: Number(g._sum.costUsd ?? 0),
    recordCount: g._count.id,
  }));

  const totalCostUsd = breakdown.reduce((acc, b) => ac
POST function · typescript · L24-L210 (187 LOC)
src/app/api/video/generate/route.ts
export async function POST(req: NextRequest) {
  const idempotencyKey = getIdempotencyKey(req);

  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 멱등성 체크
    const idem = await checkIdempotency(idempotencyKey, "video-generate", session.user.id);
    if (idem.isDuplicate) {
      if (idem.cachedResponse) return idem.cachedResponse;
      return NextResponse.json(
        { success: false, error: "이전 요청이 처리 중입니다" },
        { status: 409 }
      );
    }

    if (!isVideoEnabled()) {
      await failIdempotency(idempotencyKey);
      return NextResponse.json(
        { success: false, error: "영상 생성이 설정되지 않았습니다 (FAL_KEY 필요)" },
        { status: 503 }
      );
    }

    // Rate limit: 5 requests per 60 seconds
    const rateLimit = checkRateLimit(session.user.id, "video-generate", 5, 60_000);
    if (!rateLimit.allowe
GET function · typescript · L13-L242 (230 LOC)
src/app/api/video/status/route.ts
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // Rate limit: 30 polls per minute (min 2s client-side + 10s server-side guard is
    // already enforced by videoPolledAt, but this caps raw HTTP flood)
    const rateLimit = checkRateLimit(session.user.id, "video-status", 30, 60_000);
    if (!rateLimit.allowed) {
      return NextResponse.json(
        { success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
        { status: 429 }
      );
    }

    const contentIdSchema = z.string().min(1, "contentId가 필요합니다");
    const contentIdParam = req.nextUrl.searchParams.get("contentId");
    const contentIdParsed = contentIdSchema.safeParse(contentIdParam);
    if (!contentIdParsed.success) {
      return NextResponse.json(
        { success: false, error: contentIdParsed.err
POST function · typescript · L29-L235 (207 LOC)
src/app/api/voice/generate/route.ts
export async function POST(req: NextRequest) {
  const idempotencyKey = getIdempotencyKey(req);

  try {
    // 인증 확인
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 멱등성 체크
    const idem = await checkIdempotency(
      idempotencyKey,
      "voice-generate",
      session.user.id
    );
    if (idem.isDuplicate) {
      if (idem.cachedResponse) return idem.cachedResponse;
      return NextResponse.json(
        { success: false, error: "이전 요청이 처리 중입니다" },
        { status: 409 }
      );
    }

    // 음성 기능 활성화 여부 확인
    if (!isVoiceEnabled()) {
      await failIdempotency(idempotencyKey);
      return NextResponse.json(
        {
          success: false,
          error: "음성 생성이 설정되지 않았습니다 (ELEVENLABS_API_KEY 필요)",
        },
        { status: 503 }
      );
    }

    // Rate limit: 10 requests per 60 seconds (TTS는 비교
GET function · typescript · L34-L94 (61 LOC)
src/app/api/voice/list/route.ts
export async function GET() {
  try {
    // 인증 확인
    const session = await getServerSession(authOptions);
    if (!session?.user?.id) {
      return NextResponse.json(
        { success: false, error: "로그인이 필요합니다" },
        { status: 401 }
      );
    }

    // 음성 기능 비활성화 시 — graceful empty response
    if (!isVoiceEnabled()) {
      return NextResponse.json({
        success: true,
        data: {
          voices: [],
          disabled: true,
          message: "음성 기능이 비활성화되어 있습니다 (ELEVENLABS_API_KEY 미설정)",
        },
      });
    }

    // 캐시 히트 확인
    const now = Date.now();
    if (cache && now - cache.cachedAt < CACHE_TTL_MS) {
      return NextResponse.json({
        success: true,
        data: {
          voices: sortVoices(cache.voices),
          cached: true,
          cachedAt: new Date(cache.cachedAt).toISOString(),
        },
      });
    }

    // ElevenLabs API 호출
    const provider = getVoiceProvider();
    const voices = await provider.listVoices();

    // 캐시
sortVoices function · typescript · L100-L107 (8 LOC)
src/app/api/voice/list/route.ts
function sortVoices(voices: VoiceInfo[]): VoiceInfo[] {
  return [...voices].sort((a, b) => {
    const aKorean = KOREAN_PRIORITY_IDS.has(a.id) ? 0 : 1;
    const bKorean = KOREAN_PRIORITY_IDS.has(b.id) ? 0 : 1;
    if (aKorean !== bKorean) return aKorean - bKorean;
    return a.name.localeCompare(b.name);
  });
}
Open data scored by Repobility · https://repobility.com
AnalyticsPage function · typescript · L39-L270 (232 LOC)
src/app/dashboard/analytics/page.tsx
export default function AnalyticsPage() {
  const [brands, setBrands] = useState<Brand[]>([]);
  const [brandsLoading, setBrandsLoading] = useState(true);
  const [brandsError, setBrandsError] = useState<string | null>(null);
  const [selectedBrandId, setSelectedBrandId] = useState<string>("");
  const [contents, setContents] = useState<ContentItem[]>([]);
  const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
  const [insight, setInsight] = useState<PerformanceInsight | null>(null);
  const [killedPatterns, setKilledPatterns] = useState<KilledPattern[]>([]);
  const [loading, setLoading] = useState(false);
  const [sortBy, setSortBy] = useState<string>("publishedAt");

  // Fetch brands on mount
  useEffect(() => {
    fetch("/api/brands")
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((res) => {
        if (res.success && res.data?.length > 0) {
          setBrands(res.data);
          setSe
SummaryCard function · typescript · L274-L282 (9 LOC)
src/app/dashboard/analytics/page.tsx
function SummaryCard({ icon, label, value }: { icon: string; label: string; value: string }) {
  return (
    <div className="bg-white rounded-lg border p-4">
      <div className="text-2xl mb-1">{icon}</div>
      <div className="text-2xl font-bold">{value}</div>
      <div className="text-sm text-muted-foreground">{label}</div>
    </div>
  );
}
PatternSection function · typescript · L284-L335 (52 LOC)
src/app/dashboard/analytics/page.tsx
function PatternSection({ insight }: { insight: PerformanceInsight | null }) {
  if (!insight) {
    return (
      <div className="bg-white rounded-lg border p-8 text-center">
        <div className="text-3xl mb-3">🔬</div>
        <p className="text-sm text-muted-foreground">
          패턴 분석에는 최소 3건의 성과 데이터가 필요합니다
        </p>
      </div>
    );
  }

  const allPatterns = [...insight.topPatterns, ...insight.bottomPatterns];
  if (allPatterns.length === 0) return null;

  const maxEngagement = Math.max(...allPatterns.map((p) => p.avgEngagement), 1);

  return (
    <div className="bg-white rounded-lg border">
      <div className="p-4 border-b">
        <h2 className="text-base font-semibold">패턴 성과 분석</h2>
        <p className="text-xs text-muted-foreground mt-1">
          최근 {insight.periodDays}일 / {insight.totalSamples}건 분석
        </p>
      </div>

      <div className="divide-y">
        {insight.topPatterns.length > 0 && (
          <div className="p-4">
            <h3 classNa
page 1 / 5next ›