← back to hyunwoooim-star__autoshorts-mvp-

Function bodies 248 total

All specs Real LLM only Function bodies
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 ho
handleDelete 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" />
        {Ar
BrandDetailPage 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 text
BrandsLoading 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 if
handleNicheSelect 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-foregr
Repobility · 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 [pers
handleSave 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) {
        rout
handleDelete 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-4
GenerateLoading 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";
  }
‹ prevpage 2 / 5next ›