Function bodies 248 total
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 failedWant 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-wSectionLabel 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={{
fontSLogisticsCard 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-fEmptyState 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="absoQueueEditForm 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-meQueueMediaSection 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 cAll 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-${conteuseToast 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-2usePolling 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
istartPolling 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 });
}