← back to hyunwoooim-star__autoshorts-mvp-

Function bodies 248 total

All specs Real LLM only Function bodies
setChannelFor function · typescript · L368-L370 (3 LOC)
src/app/dashboard/queue/page.tsx
  function setChannelFor(contentId: string, channel: PublishChannel) {
    setPublishChannel((prev) => ({ ...prev, [contentId]: channel }));
  }
togglePublishPanel function · typescript · L372-L382 (11 LOC)
src/app/dashboard/queue/page.tsx
  function togglePublishPanel(contentId: string) {
    if (publishingId === contentId) {
      setPublishingId(null);
      setSelectedProfileId("");
      setPublishScheduledAt("");
    } else {
      setPublishingId(contentId);
      setSelectedProfileId(bufferProfiles.length > 0 ? bufferProfiles[0].id : "");
      setPublishScheduledAt("");
    }
  }
canPublish function · typescript · L384-L390 (7 LOC)
src/app/dashboard/queue/page.tsx
  function canPublish(content: QueueContent): boolean {
    if (content.type === "CARD_NEWS") {
      return !!(content.cardImageUrls && content.cardImageUrls.length > 0);
    }
    if (content.videoPrompt && !content.videoUrl) return false;
    return true;
  }
getPublishDisabledReason function · typescript · L392-L402 (11 LOC)
src/app/dashboard/queue/page.tsx
  function getPublishDisabledReason(content: QueueContent): string | null {
    if (content.type === "CARD_NEWS") {
      if (!content.cardImageUrls || content.cardImageUrls.length === 0) {
        return "카드뉴스 이미지를 먼저 업로드하세요";
      }
    }
    if (content.videoPrompt && !content.videoUrl) {
      return "영상 URL이 필요합니다";
    }
    return null;
  }
handlePublish function · typescript · L405-L466 (62 LOC)
src/app/dashboard/queue/page.tsx
  async function handlePublish(contentId: string) {
    const channel = getPublishChannel(contentId);

    if (channel === "instagram") {
      toast("Instagram Reels 직접 발행은 준비 중입니다", "info");
      return;
    }

    if (channel === "both") {
      if (!selectedProfileId) {
        toast("Buffer 프로필을 선택해주세요", "error");
        return;
      }
    } else {
      if (!selectedProfileId) {
        toast("프로필을 선택해주세요", "error");
        return;
      }
    }

    setPublishLoading(true);
    try {
      const body: Record<string, unknown> = {
        contentId,
        profileIds: [selectedProfileId],
        publishTo: channel,
      };
      if (publishScheduledAt) {
        body.scheduledAt = new Date(publishScheduledAt).toISOString();
      }

      const res = await apiFetch("/api/publish", {
        method: "POST",
        body: JSON.stringify(body),
      });
      const data = await res.json();

      if (data.success) {
        if (channel === "both") {
          toast("Buffer 발행
fetchBrands function · typescript · L75-L90 (16 LOC)
src/app/dashboard/studio/page.tsx
    async function fetchBrands() {
      try {
        const res = await apiFetch("/api/brands");
        const data = await res.json();
        if (data.success) {
          setBrands(data.data);
          if (data.data.length > 0) {
            setSelectedBrandId(data.data[0].id);
          }
        }
      } catch {
        setError("브랜드 목록을 불러오지 못했습니다");
      } finally {
        setLoadingBrands(false);
      }
    }
fetchCharacters function · typescript · L98-L110 (13 LOC)
src/app/dashboard/studio/page.tsx
    async function fetchCharacters() {
      setLoadingCharacters(true);
      setSelectedCharacterId("");
      try {
        const res = await apiFetch(`/api/characters?brandId=${selectedBrandId}`);
        const data = await res.json();
        if (data.success) {
          setCharacters(data.data);
        }
      } finally {
        setLoadingCharacters(false);
      }
    }
All rows above produced by Repobility · https://repobility.com
fetchContents function · typescript · L112-L138 (27 LOC)
src/app/dashboard/studio/page.tsx
    async function fetchContents() {
      setLoadingContents(true);
      setSelectedContentId("");
      setGalleryImages([]);
      try {
        const res = await apiFetch(`/api/queue?brandId=${selectedBrandId}&limit=20`);
        const data = await res.json();
        if (data.success) {
          const items: Content[] = data.data;
          setContents(items);
          if (items.length > 0) {
            setSelectedContentId(items[0].id);
          }
          // 이미지 있는 콘텐츠 갤러리 초기화
          const gallery: GalleryImage[] = items
            .filter((c) => c.imageUrl || c.imageStatus !== "NONE")
            .map((c) => ({
              id: c.id,
              url: c.imageUrl ?? "",
              status: c.imageStatus,
            }));
          setGalleryImages(gallery);
        }
      } finally {
        setLoadingContents(false);
      }
    }
handleGenerate function · typescript · L216-L270 (55 LOC)
src/app/dashboard/studio/page.tsx
  async function handleGenerate() {
    if (!selectedContentId) {
      setError("콘텐츠를 선택해주세요");
      return;
    }

    setGenerating(true);
    setError("");
    try {
      const body: Record<string, unknown> = {
        contentId: selectedContentId,
        useUpscale,
      };
      if (selectedCharacterId) {
        body.characterId = selectedCharacterId;
      }
      if (scene.trim()) {
        body.scene = scene.trim();
      }

      const res = await apiFetch("/api/image/generate", {
        method: "POST",
        body: JSON.stringify(body),
      });
      const data = await res.json();

      if (data.success) {
        setSuccess(
          `이미지 생성 시작! (${data.data.mode === "face-lock" ? "PuLID 얼굴 고정" : "Flux Pro"}, 예상 ${data.data.estimatedTime})`
        );

        // 갤러리에 GENERATING 상태로 추가
        setGalleryImages((prev) => {
          const exists = prev.find((g) => g.id === selectedContentId);
          if (exists) {
            return prev.map((g) =>
             
handleUpscale function · typescript · L273-L305 (33 LOC)
src/app/dashboard/studio/page.tsx
  async function handleUpscale(contentId: string) {
    setUpscalingIds((prev) => new Set(prev).add(contentId));
    try {
      const res = await apiFetch("/api/image/upscale", {
        method: "POST",
        body: JSON.stringify({ contentId }),
      });
      const data = await res.json();

      if (data.success) {
        setSuccess("업스케일 시작! (~30초~2분)");
        setGalleryImages((prev) =>
          prev.map((g) =>
            g.id === contentId ? { ...g, status: "UPSCALING" as const } : g
          )
        );
      } else {
        setError(data.error || "업스케일 실패");
        setUpscalingIds((prev) => {
          const next = new Set(prev);
          next.delete(contentId);
          return next;
        });
      }
    } catch {
      setError("업스케일 요청에 실패했습니다");
      setUpscalingIds((prev) => {
        const next = new Set(prev);
        next.delete(contentId);
        return next;
      });
    }
  }
RootLayout function · typescript · L13-L25 (13 LOC)
src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body className={inter.className}>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}
Home function · typescript · L7-L88 (82 LOC)
src/app/page.tsx
export default function Home() {
  const { data: session, status } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (session) {
      router.push("/dashboard");
    }
  }, [session, router]);

  if (status === "loading") {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
      </div>
    );
  }

  return (
    <main className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-slate-50 to-slate-100">
      <div className="max-w-md w-full mx-auto text-center space-y-8 p-8">
        <div className="space-y-2">
          <h1 className="text-4xl font-bold tracking-tight">AutoShorts</h1>
          <p className="text-muted-foreground text-lg">
            숏폼 콘텐츠 자동화
          </p>
        </div>

        <div className="space-y-4">
          <p className="text-sm text-muted-foreground">
            인스타그램 릴스 & 유튜브 쇼츠를
    
AuthProvider function · typescript · L6-L12 (7 LOC)
src/components/auth-provider.tsx
export function AuthProvider({ children }: { children: React.ReactNode }) {
  return (
    <SessionProvider>
      <ToastProvider>{children}</ToastProvider>
    </SessionProvider>
  );
}
CardNewsPreview function · typescript · L115-L374 (260 LOC)
src/components/card-news-preview.tsx
export function CardNewsPreview({ pages, contentId, cardImageUrls, onToast, onUploadComplete }: CardNewsPreviewProps) {
  const [paletteId, setPaletteId] = useState("lux-minimal");
  const [showPreview, setShowPreview] = useState(false);
  const [rendering, setRendering] = useState(false);
  const [uploading, setUploading] = useState(false);
  const renderRef = useRef<HTMLDivElement>(null);

  const selectedPalette =
    PALETTES.find((p) => p.id === paletteId) ?? PALETTES[0];

  async function handleDownload() {
    if (!renderRef.current) return;
    setRendering(true);

    try {
      const html2canvas = (await import("html2canvas")).default;
      const cards = renderRef.current.querySelectorAll("[data-card-index]");

      for (let i = 0; i < cards.length; i++) {
        const canvas = await html2canvas(cards[i] as HTMLElement, {
          width: 1080,
          height: 1350,
          scale: 1,
          useCORS: true,
        });

        const link = document.createElement("a"
handleDownload function · typescript · L125-L159 (35 LOC)
src/components/card-news-preview.tsx
  async function handleDownload() {
    if (!renderRef.current) return;
    setRendering(true);

    try {
      const html2canvas = (await import("html2canvas")).default;
      const cards = renderRef.current.querySelectorAll("[data-card-index]");

      for (let i = 0; i < cards.length; i++) {
        const canvas = await html2canvas(cards[i] as HTMLElement, {
          width: 1080,
          height: 1350,
          scale: 1,
          useCORS: true,
        });

        const link = document.createElement("a");
        link.download = `card-${contentId.slice(-6)}-${i + 1}.png`;
        link.href = canvas.toDataURL("image/png");
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        // Small delay between downloads to avoid browser throttling
        await new Promise((r) => setTimeout(r, 300));
      }

      onToast?.(`${cards.length}장 카드 이미지 다운로드 완료`, "success");
    } catch (error) {
      console.error("Card render failed
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
handleUpload function · typescript · L161-L203 (43 LOC)
src/components/card-news-preview.tsx
  async function handleUpload() {
    if (!renderRef.current) return;
    setUploading(true);

    try {
      const html2canvas = (await import("html2canvas")).default;
      const cards = renderRef.current.querySelectorAll("[data-card-index]");
      const formData = new FormData();
      formData.append("contentId", contentId);

      for (let i = 0; i < cards.length; i++) {
        const canvas = await html2canvas(cards[i] as HTMLElement, {
          width: 1080,
          height: 1350,
          scale: 1,
          useCORS: true,
        });

        const blob = await new Promise<Blob>((resolve) =>
          canvas.toBlob((b) => resolve(b!), "image/png")
        );
        formData.append(`image_${i}`, blob, `card-${i + 1}.png`);
      }

      const res = await apiFetch("/api/card-news/upload", {
        method: "POST",
        body: formData,
      });
      const data = await res.json();

      if (data.success) {
        onToast?.(`${data.data.count}장 이미지 업로드 완료`, "success");
hexToRgba function · typescript · L25-L40 (16 LOC)
src/components/card-templates.tsx
function hexToRgba(hex: string, alpha: number): string {
  const clean = hex.replace("#", "");
  let r: number, g: number, b: number;

  if (clean.length === 3) {
    r = parseInt(clean[0] + clean[0], 16);
    g = parseInt(clean[1] + clean[1], 16);
    b = parseInt(clean[2] + clean[2], 16);
  } else {
    r = parseInt(clean.slice(0, 2), 16);
    g = parseInt(clean.slice(2, 4), 16);
    b = parseInt(clean.slice(4, 6), 16);
  }

  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
lightenHex function · typescript · L44-L50 (7 LOC)
src/components/card-templates.tsx
function lightenHex(hex: string, amount: number): string {
  const clean = hex.replace("#", "");
  const r = Math.min(255, parseInt(clean.slice(0, 2), 16) + amount);
  const g = Math.min(255, parseInt(clean.slice(2, 4), 16) + amount);
  const b = Math.min(255, parseInt(clean.slice(4, 6), 16) + amount);
  return `rgb(${r}, ${g}, ${b})`;
}
CardTemplateWrapper function · typescript · L62-L117 (56 LOC)
src/components/card-templates.tsx
export function CardTemplateWrapper({ palette, children, variant = "default" }: CardTemplateWrapperProps) {
  // Background gradient differs per variant
  const bgGradient: Record<string, string> = {
    default: `linear-gradient(145deg, ${palette.bg} 0%, ${lightenHex(palette.bg, -8)} 100%)`,
    cover: `linear-gradient(160deg, ${palette.bg} 0%, ${hexToRgba(palette.highlight, 0.08)} 60%, ${lightenHex(palette.bg, -12)} 100%)`,
    cta: `linear-gradient(135deg, ${hexToRgba(palette.highlight, 0.06)} 0%, ${palette.bg} 50%, ${hexToRgba(palette.highlight, 0.12)} 100%)`,
  };

  return (
    <div
      style={{
        position: "relative",
        overflow: "hidden",
        width: "1080px",
        height: "1350px",
        flexShrink: 0,
        background: bgGradient[variant],
        fontFamily: "'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif",
        color: palette.text,
        wordBreak: "keep-all",
        overflowWrap: "break-w
SectionLabel function · typescript · L121-L146 (26 LOC)
src/components/card-templates.tsx
function SectionLabel({ label, palette }: { label: string; palette: Palette }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
      <div
        style={{
          width: "32px",
          height: "2px",
          backgroundColor: palette.highlight,
          opacity: 0.6,
        }}
      />
      <p
        style={{
          fontSize: "13px",
          fontWeight: 600,
          letterSpacing: "0.3em",
          textTransform: "uppercase",
          color: hexToRgba(palette.highlight, 0.7),
          margin: 0,
        }}
      >
        {label}
      </p>
    </div>
  );
}
AccentDivider function · typescript · L150-L163 (14 LOC)
src/components/card-templates.tsx
function AccentDivider({ palette }: { palette: Palette }) {
  return (
    <div
      style={{
        width: "56px",
        height: "2px",
        backgroundColor: palette.highlight,
        marginTop: "28px",
        marginBottom: "28px",
        borderRadius: "2px",
      }}
    />
  );
}
BrandBadge function · typescript · L167-L204 (38 LOC)
src/components/card-templates.tsx
function BrandBadge({ palette }: { palette: Palette }) {
  return (
    <div
      style={{
        position: "absolute",
        bottom: "48px",
        left: "80px",
        right: "80px",
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
      }}
    >
      <div
        style={{
          width: "32px",
          height: "1px",
          backgroundColor: hexToRgba(palette.accent, 0.3),
        }}
      />
      <div
        style={{
          width: "6px",
          height: "6px",
          borderRadius: "50%",
          backgroundColor: hexToRgba(palette.highlight, 0.4),
        }}
      />
      <div
        style={{
          width: "32px",
          height: "1px",
          backgroundColor: hexToRgba(palette.accent, 0.3),
        }}
      />
    </div>
  );
}
ContentArea function · typescript · L208-L225 (18 LOC)
src/components/card-templates.tsx
function ContentArea({ children, centered = false }: { children: React.ReactNode; centered?: boolean }) {
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: centered ? "center" : "flex-start",
        alignItems: centered ? "center" : "flex-start",
        padding: "100px 80px 120px",
        boxSizing: "border-box",
      }}
    >
      {children}
    </div>
  );
}
Repobility · code-quality intelligence platform · https://repobility.com
CoverCard function · typescript · L229-L359 (131 LOC)
src/components/card-templates.tsx
export function CoverCard({ page, palette }: CardProps) {
  return (
    <CardTemplateWrapper palette={palette} variant="cover">
      {/* Large decorative background text — creates depth */}
      <div
        style={{
          position: "absolute",
          bottom: "120px",
          right: "-20px",
          fontSize: "280px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.04),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
          letterSpacing: "-0.05em",
        }}
      >
        01
      </div>

      <ContentArea centered>
        {/* Brand/page label */}
        <SectionLabel label="CARD NEWS" palette={palette} />

        <div style={{ height: "40px" }} />

        {/* Headline — hook text */}
        <h1
          style={{
            fontSize: "62px",
            fontWeight: 800,
            lineHeight: 1.3,
            letterSpacing: "-0.02em",
            color: palette.text,
            textAlign: 
WhyCard function · typescript · L363-L488 (126 LOC)
src/components/card-templates.tsx
export function WhyCard({ page, palette }: CardProps) {
  const items = page.items ?? [];

  return (
    <CardTemplateWrapper palette={palette}>
      {/* Page number watermark */}
      <div
        style={{
          position: "absolute",
          top: "60px",
          right: "80px",
          fontSize: "120px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.05),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
        }}
      >
        02
      </div>

      <ContentArea>
        <SectionLabel label="WHY" palette={palette} />

        <h2
          style={{
            fontSize: "52px",
            fontWeight: 800,
            lineHeight: 1.35,
            letterSpacing: "-0.02em",
            color: palette.text,
            marginTop: "28px",
            marginBottom: 0,
            wordBreak: "keep-all",
          }}
        >
          {page.headline}
        </h2>

        {page.body && (
          <p
      
OptionsCard function · typescript · L492-L648 (157 LOC)
src/components/card-templates.tsx
export function OptionsCard({ page, palette }: CardProps) {
  const items = page.items ?? [];

  return (
    <CardTemplateWrapper palette={palette}>
      <div
        style={{
          position: "absolute",
          top: "60px",
          right: "80px",
          fontSize: "120px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.05),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
        }}
      >
        03
      </div>

      <ContentArea>
        <SectionLabel label="OPTIONS" palette={palette} />

        <h2
          style={{
            fontSize: "52px",
            fontWeight: 800,
            lineHeight: 1.35,
            letterSpacing: "-0.02em",
            color: palette.text,
            marginTop: "28px",
            marginBottom: 0,
            wordBreak: "keep-all",
          }}
        >
          {page.headline}
        </h2>

        {page.body && (
          <p
            style={{
             
TrustCard function · typescript · L652-L817 (166 LOC)
src/components/card-templates.tsx
export function TrustCard({ page, palette }: CardProps) {
  const items = page.items ?? [];

  // Star rating visual
  const StarRating = () => (
    <div style={{ display: "flex", gap: "6px", marginBottom: "12px" }}>
      {[1, 2, 3, 4, 5].map((i) => (
        <span
          key={i}
          style={{
            fontSize: "22px",
            color: "#F5A623",
            lineHeight: 1,
          }}
        >
          ★
        </span>
      ))}
    </div>
  );

  return (
    <CardTemplateWrapper palette={palette}>
      <div
        style={{
          position: "absolute",
          top: "60px",
          right: "80px",
          fontSize: "120px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.05),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
        }}
      >
        04
      </div>

      <ContentArea>
        <SectionLabel label="TRUST" palette={palette} />

        <h2
          style={{
            fontS
LogisticsCard function · typescript · L821-L951 (131 LOC)
src/components/card-templates.tsx
export function LogisticsCard({ page, palette }: CardProps) {
  const items = page.items ?? [];

  // Simple emoji icons for common logistics terms
  const getIcon = (text: string): string => {
    const lower = text.toLowerCase();
    if (lower.includes("당일") || lower.includes("익일") || lower.includes("배송")) return "🚚";
    if (lower.includes("교환") || lower.includes("반품")) return "🔄";
    if (lower.includes("무료")) return "🎁";
    if (lower.includes("포장")) return "📦";
    if (lower.includes("안전")) return "🛡️";
    return "✓";
  };

  return (
    <CardTemplateWrapper palette={palette}>
      <div
        style={{
          position: "absolute",
          top: "60px",
          right: "80px",
          fontSize: "120px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.05),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
        }}
      >
        05
      </div>

      <ContentArea>
        <SectionLabel label="LOGISTICS"
HowtoCard function · typescript · L955-L1102 (148 LOC)
src/components/card-templates.tsx
export function HowtoCard({ page, palette }: CardProps) {
  const items = page.items ?? [];

  return (
    <CardTemplateWrapper palette={palette}>
      <div
        style={{
          position: "absolute",
          top: "60px",
          right: "80px",
          fontSize: "120px",
          fontWeight: 900,
          color: hexToRgba(palette.highlight, 0.05),
          lineHeight: 1,
          userSelect: "none",
          pointerEvents: "none",
        }}
      >
        06
      </div>

      <ContentArea>
        <SectionLabel label="HOW TO ORDER" palette={palette} />

        <h2
          style={{
            fontSize: "52px",
            fontWeight: 800,
            lineHeight: 1.35,
            letterSpacing: "-0.02em",
            color: palette.text,
            marginTop: "28px",
            marginBottom: 0,
            wordBreak: "keep-all",
          }}
        >
          {page.headline}
        </h2>

        {page.body && (
          <p
            style={{
          
CtaCard function · typescript · L1106-L1251 (146 LOC)
src/components/card-templates.tsx
export function CtaCard({ page, palette }: CardProps) {
  return (
    <CardTemplateWrapper palette={palette} variant="cta">
      {/* Radial glow behind the main CTA */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          transform: "translate(-50%, -50%)",
          width: "700px",
          height: "700px",
          borderRadius: "50%",
          background: `radial-gradient(circle, ${hexToRgba(palette.highlight, 0.08)} 0%, transparent 70%)`,
          pointerEvents: "none",
        }}
      />

      <ContentArea centered>
        {/* Urgency badge at top */}
        {page.subText && (
          <div
            style={{
              backgroundColor: hexToRgba(palette.highlight, 0.12),
              border: `1px solid ${hexToRgba(palette.highlight, 0.3)}`,
              borderRadius: "40px",
              padding: "10px 28px",
              marginBottom: "40px",
            }}
          >
            <p
              
CardNewsRenderer function · typescript · L1289-L1339 (51 LOC)
src/components/card-templates.tsx
export function CardNewsRenderer({ pages, palette, renderRef }: CardNewsRendererProps) {
  return (
    <div
      ref={renderRef}
      style={{
        display: "flex",
        flexDirection: "row",
        width: `${1080 * pages.length}px`,
        height: "1350px",
      }}
    >
      {pages.map((page, index) => {
        const CardComponent = CARD_COMPONENTS[page.type];

        if (!CardComponent) {
          // Render a plain fallback for unknown types so we never crash.
          return (
            <div
              key={index}
              data-card-index={index}
              style={{
                width: "1080px",
                height: "1350px",
                flexShrink: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                backgroundColor: palette.bg,
                color: palette.text,
              }}
            >
              <p style={{ fontSize: "24px", opacity: 0.4 }}>
        
Want this analysis on your repo? https://repobility.com/scan/
CharacterSelector function · typescript · L20-L89 (70 LOC)
src/components/character-selector.tsx
export function CharacterSelector({ brandId, value, onChange }: CharacterSelectorProps) {
  const [characters, setCharacters] = useState<Character[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!brandId) {
      setCharacters([]);
      return;
    }

    setLoading(true);
    apiFetch(`/api/characters?brandId=${encodeURIComponent(brandId)}`)
      .then((res) => res.json())
      .then((data) => {
        if (data.success) {
          setCharacters(data.data.filter((c: Character) => c.isActive));
        } else {
          setCharacters([]);
        }
      })
      .catch(() => setCharacters([]))
      .finally(() => setLoading(false));
  }, [brandId]);

  // brandId가 바뀌면 선택 초기화
  useEffect(() => {
    onChange(null);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [brandId]);

  if (!brandId) return null;

  return (
    <div className="space-y-2">
      <label className="text-sm font-medium">캐릭터 선택 (선택)</label>
      {loading ? 
ComplianceBadge function · typescript · L8-L26 (19 LOC)
src/components/compliance-badge.tsx
export function ComplianceBadge({ status, size = "sm" }: ComplianceBadgeProps) {
  const config = (
    {
      GREEN: { label: "적합", bg: "bg-green-100", text: "text-green-700", dot: "bg-green-500" },
      AMBER: { label: "주의", bg: "bg-yellow-100", text: "text-yellow-700", dot: "bg-yellow-500" },
      RED: { label: "부적합", bg: "bg-red-100", text: "text-red-700", dot: "bg-red-500" },
      UNCHECKED: { label: "미검수", bg: "bg-gray-100", text: "text-gray-500", dot: "bg-gray-400" },
    } as Record<string, { label: string; bg: string; text: string; dot: string }>
  )[status] || { label: status, bg: "bg-gray-100", text: "text-gray-500", dot: "bg-gray-400" };

  const sizeClass = size === "sm" ? "text-xs px-2 py-0.5" : "text-sm px-3 py-1";

  return (
    <span className={`inline-flex items-center gap-1.5 rounded-full ${config.bg} ${config.text} ${sizeClass}`}>
      <span className={`w-1.5 h-1.5 rounded-full ${config.dot}`} />
      {config.label}
    </span>
  );
}
DashboardNav function · typescript · L23-L118 (96 LOC)
src/components/dashboard-nav.tsx
export function DashboardNav({ user }: DashboardNavProps) {
  const pathname = usePathname();
  const [mobileOpen, setMobileOpen] = useState(false);

  return (
    <header className="bg-white border-b">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex items-center justify-between h-14">
          <div className="flex items-center gap-8">
            <Link href="/dashboard" className="font-bold text-lg">
              AutoShorts
            </Link>
            <nav className="hidden md:flex items-center gap-1">
              {navItems.map((item) => (
                <Link
                  key={item.href}
                  href={item.href}
                  className={cn(
                    "px-3 py-2 rounded-md text-sm transition",
                    pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
                      ? "bg-slate-100 text-foreground font-medium"
                      : "text-muted-f
EmptyState function · typescript · L11-L27 (17 LOC)
src/components/empty-state.tsx
export function EmptyState({ icon, title, description, actionLabel, actionHref }: EmptyStateProps) {
  return (
    <div className="bg-white rounded-lg border p-12 text-center">
      <div className="text-4xl mb-4">{icon}</div>
      <h2 className="text-lg font-semibold">{title}</h2>
      <p className="text-sm text-muted-foreground mt-2">{description}</p>
      {actionLabel && actionHref && (
        <Link
          href={actionHref}
          className="inline-flex items-center gap-1 mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition"
        >
          {actionLabel}
        </Link>
      )}
    </div>
  );
}
ImageGallery function · typescript · L32-L60 (29 LOC)
src/components/image-gallery.tsx
export function ImageGallery({
  images,
  onUpscale,
  isUpscaling = new Set(),
}: ImageGalleryProps) {
  if (images.length === 0) {
    return (
      <div className="text-center py-12 bg-slate-50 rounded-lg border border-dashed border-slate-200">
        <p className="text-slate-400 text-sm">생성된 이미지가 없습니다</p>
        <p className="text-slate-300 text-xs mt-1">
          이미지 생성 버튼을 눌러 시작하세요
        </p>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
      {images.map((img) => (
        <ImageGalleryItem
          key={img.id}
          image={img}
          isUpscaling={isUpscaling.has(img.id)}
          onUpscale={() => onUpscale(img.id)}
        />
      ))}
    </div>
  );
}
ImageGalleryItem function · typescript · L68-L144 (77 LOC)
src/components/image-gallery.tsx
function ImageGalleryItem({ image, isUpscaling, onUpscale }: ImageGalleryItemProps) {
  const isGenerating =
    image.status === "GENERATING" || image.status === "UPSCALING";
  const isReady = image.status === "READY";
  const isFailed = image.status === "FAILED";

  return (
    <div className="relative rounded-lg overflow-hidden border border-slate-200 bg-slate-50 aspect-[9/16]">
      {/* 이미지 표시 */}
      {isReady && image.url ? (
        <img
          src={image.url}
          alt="생성된 이미지"
          className="w-full h-full object-cover"
        />
      ) : isGenerating ? (
        <div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
          <div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" />
          <p className="text-xs text-blue-600 font-medium">
            {image.status === "UPSCALING" ? "업스케일 중..." : "생성 중..."}
          </p>
        </div>
      ) : isFailed ? (
        <div className="abso
QueueEditForm function · typescript · L18-L79 (62 LOC)
src/components/queue/queue-edit-form.tsx
export function QueueEditForm({
  form,
  isLoading,
  onChange,
  onSave,
  onCancel,
}: QueueEditFormProps) {
  return (
    <div className="space-y-3">
      <div>
        <label className="text-xs font-medium text-muted-foreground">카피</label>
        <textarea
          value={form.copyText}
          onChange={(e) => onChange({ ...form, copyText: e.target.value })}
          className="w-full mt-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
          rows={5}
        />
      </div>
      <div>
        <label className="text-xs font-medium text-muted-foreground">해시태그</label>
        <input
          value={form.hashtags}
          onChange={(e) => onChange({ ...form, hashtags: e.target.value })}
          className="w-full mt-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
        />
      </div>
      <div>
        <label className="text-xs font-me
QueueMediaSection function · typescript · L29-L315 (287 LOC)
src/components/queue/queue-media-section.tsx
export function QueueMediaSection({
  contentId,
  contentType,
  // Image
  imageStatus,
  imageUrl,
  imageEnabled,
  isImageLoading,
  onImageGenerate,
  // Video
  videoPrompt,
  videoStatus,
  videoUrl,
  videoDuration,
  videoError,
  thumbnailUrl,
  videoEnabled,
  isVideoLoading,
  useImage,
  progress,
  onVideoGenerate,
  onUseImageChange,
  onSkipVideo,
  onCopyPrompt,
}: QueueMediaSectionProps) {
  // Card news has no video/image generation section
  if (contentType === "CARD_NEWS") return null;

  return (
    <>
      {/* ═══════════════════════════════════════════════════════════
          이미지 생성 섹션 (영상 섹션 위에 표시)
      ════════════════════════════════════════════════════════════ */}

      {/* ─── 이미지: NONE → 생성 버튼 ─── */}
      {imageStatus === "NONE" && (
        <div className="bg-amber-50 rounded p-3 space-y-2">
          <div className="flex items-center justify-between">
            <p className="text-xs font-medium text-amber-800">AI 이미지 생성</p>
            <span c
All rows above produced by Repobility · https://repobility.com
QueuePublishPanel function · typescript · L29-L221 (193 LOC)
src/components/queue/queue-publish-panel.tsx
export function QueuePublishPanel({
  contentId,
  channel,
  bufferProfiles,
  bufferLoaded,
  igConfigured,
  selectedProfileId,
  publishScheduledAt,
  publishLoading,
  onClose,
  onChannelChange,
  onProfileChange,
  onScheduledAtChange,
  onPublish,
}: QueuePublishPanelProps) {
  return (
    <div className="p-4 bg-indigo-50 rounded-lg space-y-4">
      <div className="flex items-center justify-between">
        <p className="text-sm font-medium text-indigo-800">발행 설정</p>
        <button
          onClick={onClose}
          className="text-xs text-indigo-600 hover:underline"
        >
          닫기
        </button>
      </div>

      {/* ── Channel Selection ── */}
      <div>
        <p className="text-xs font-medium text-indigo-700 mb-2">발행 채널</p>
        <div className="flex gap-3 flex-wrap">
          {/* Buffer option */}
          <label className="flex items-center gap-1.5 cursor-pointer">
            <input
              type="radio"
              name={`channel-${conte
useToast function · typescript · L21-L23 (3 LOC)
src/components/toast.tsx
export function useToast() {
  return useContext(ToastContext);
}
ToastProvider function · typescript · L27-L65 (39 LOC)
src/components/toast.tsx
export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const toast = useCallback((message: string, type: ToastType = "success") => {
    const id = nextId++;
    setToasts((prev) => [...prev, { id, message, type }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 3000);
  }, []);

  const dismiss = useCallback((id: number) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);

  const colors: Record<ToastType, string> = {
    success: "bg-green-600",
    error: "bg-red-600",
    info: "bg-blue-600",
  };

  return (
    <ToastContext.Provider value={{ toast }}>
      {children}
      {/* Toast container */}
      <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
        {toasts.map((t) => (
          <div
            key={t.id}
            onClick={() => dismiss(t.id)}
            className={`${colors[t.type]} text-white text-sm px-4 py-2
usePolling function · typescript · L26-L95 (70 LOC)
src/hooks/use-polling.ts
export function usePolling(options: UsePollingOptions) {
  const { onUpdate, onReady, onFailed, intervalMs = 15_000 } = options;

  const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
  const retryAfterRef = useRef<Map<string, number>>(new Map());

  // Cleanup all timers on unmount
  useEffect(() => {
    return () => {
      pollTimers.current.forEach((timer) => clearInterval(timer));
    };
  }, []);

  function startPolling(contentId: string) {
    // Clear existing timer for this content
    const existing = pollTimers.current.get(contentId);
    if (existing) clearInterval(existing);

    async function doPoll() {
      try {
        const res = await apiFetch(`/api/video/status?contentId=${contentId}`);
        const data = await res.json();
        if (!data.success) return;

        const payload: VideoStatusPayload = data.data;
        const { videoStatus, retryAfter } = payload;

        // Store retryAfter hint for external consumers
        i
startPolling function · typescript · L39-L79 (41 LOC)
src/hooks/use-polling.ts
  function startPolling(contentId: string) {
    // Clear existing timer for this content
    const existing = pollTimers.current.get(contentId);
    if (existing) clearInterval(existing);

    async function doPoll() {
      try {
        const res = await apiFetch(`/api/video/status?contentId=${contentId}`);
        const data = await res.json();
        if (!data.success) return;

        const payload: VideoStatusPayload = data.data;
        const { videoStatus, retryAfter } = payload;

        // Store retryAfter hint for external consumers
        if (typeof retryAfter === "number") {
          retryAfterRef.current.set(contentId, retryAfter * 1000);
        }

        onUpdate(contentId, payload);

        if (videoStatus === "READY") {
          clearInterval(pollTimers.current.get(contentId));
          pollTimers.current.delete(contentId);
          retryAfterRef.current.delete(contentId);
          onReady(contentId);
        } else if (videoStatus === "FAILED") {
          
doPoll function · typescript · L44-L75 (32 LOC)
src/hooks/use-polling.ts
    async function doPoll() {
      try {
        const res = await apiFetch(`/api/video/status?contentId=${contentId}`);
        const data = await res.json();
        if (!data.success) return;

        const payload: VideoStatusPayload = data.data;
        const { videoStatus, retryAfter } = payload;

        // Store retryAfter hint for external consumers
        if (typeof retryAfter === "number") {
          retryAfterRef.current.set(contentId, retryAfter * 1000);
        }

        onUpdate(contentId, payload);

        if (videoStatus === "READY") {
          clearInterval(pollTimers.current.get(contentId));
          pollTimers.current.delete(contentId);
          retryAfterRef.current.delete(contentId);
          onReady(contentId);
        } else if (videoStatus === "FAILED") {
          clearInterval(pollTimers.current.get(contentId));
          pollTimers.current.delete(contentId);
          retryAfterRef.current.delete(contentId);
          onFailed(contentId);
        }
stopPolling function · typescript · L81-L88 (8 LOC)
src/hooks/use-polling.ts
  function stopPolling(contentId: string) {
    const timer = pollTimers.current.get(contentId);
    if (timer) {
      clearInterval(timer);
      pollTimers.current.delete(contentId);
    }
    retryAfterRef.current.delete(contentId);
  }
isPolling function · typescript · L90-L92 (3 LOC)
src/hooks/use-polling.ts
  function isPolling(contentId: string) {
    return pollTimers.current.has(contentId);
  }
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
removeAiSlop function · typescript · L27-L39 (13 LOC)
src/lib/ai-slop-filter.ts
export function removeAiSlop(text: string): string {
  let result = text;
  for (const pattern of AI_SLOP_PATTERNS) {
    const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    result = result.replace(new RegExp(escaped, "g"), "");
  }
  // 깨진 문장 정리
  result = result.replace(/  +/g, " ");           // 이중 공백
  result = result.replace(/\n\s*\n\s*\n/g, "\n\n"); // 3줄+ 빈 줄 → 2줄
  result = result.replace(/^\s*[,.]+ */gm, "");    // 줄 시작 쉼표/마침표
  result = result.replace(/\s+([,.])/g, "$1");     // 공백 후 쉼표/마침표
  return result.trim();
}
getCsrfToken function · typescript · L6-L10 (5 LOC)
src/lib/api-client.ts
function getCsrfToken(): string | null {
  if (typeof document === "undefined") return null;
  const match = document.cookie.match(/(?:^|;\s*)csrf-token=([^;]*)/);
  return match ? decodeURIComponent(match[1]) : null;
}
apiFetch function · typescript · L12-L33 (22 LOC)
src/lib/api-client.ts
export async function apiFetch(
  input: string,
  init?: RequestInit
): Promise<Response> {
  const headers = new Headers(init?.headers);

  // Auto-attach CSRF token for state-changing methods
  const method = (init?.method ?? "GET").toUpperCase();
  if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
    const token = getCsrfToken();
    if (token) {
      headers.set("X-CSRF-Token", token);
    }
  }

  // Default Content-Type for JSON bodies (skip for FormData)
  if (init?.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
    headers.set("Content-Type", "application/json");
  }

  return fetch(input, { ...init, headers });
}
‹ prevpage 3 / 5next ›