← back to kcgcom__born2smile-website

Function bodies 253 total

All specs Real LLM only Function bodies
AdminErrorState function · typescript · L10-L25 (16 LOC)
app/admin/(dashboard)/components/AdminErrorState.tsx
export function AdminErrorState({ message, onRetry }: AdminErrorStateProps) {
  return (
    <div className="flex flex-col items-center gap-3 rounded-xl border border-red-100 bg-red-50 px-6 py-10 text-center" role="alert">
      <AlertCircle className="h-8 w-8 text-red-400" aria-hidden="true" />
      <p className="text-sm text-red-700">{message}</p>
      {onRetry && (
        <button
          onClick={onRetry}
          className="mt-1 rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm text-white transition-colors hover:bg-[var(--color-primary-dark)]"
        >
          다시 시도
        </button>
      )}
    </div>
  );
}
Bone function · typescript · L7-L9 (3 LOC)
app/admin/(dashboard)/components/AdminLoadingSkeleton.tsx
function Bone({ className }: { className?: string }) {
  return <div className={`animate-pulse rounded bg-[var(--border)] ${className ?? ""}`} />;
}
MetricsSkeleton function · typescript · L11-L22 (12 LOC)
app/admin/(dashboard)/components/AdminLoadingSkeleton.tsx
function MetricsSkeleton() {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="rounded-lg bg-[var(--background)] p-3 text-center">
          <Bone className="mx-auto h-8 w-16" />
          <Bone className="mx-auto mt-2 h-3 w-20" />
        </div>
      ))}
    </div>
  );
}
ChartSkeleton function · typescript · L24-L31 (8 LOC)
app/admin/(dashboard)/components/AdminLoadingSkeleton.tsx
function ChartSkeleton() {
  return (
    <div className="rounded-xl bg-[var(--surface)] p-4 shadow-sm">
      <Bone className="mb-4 h-5 w-32" />
      <Bone className="h-32 w-full" />
    </div>
  );
}
TableSkeleton function · typescript · L33-L44 (12 LOC)
app/admin/(dashboard)/components/AdminLoadingSkeleton.tsx
function TableSkeleton() {
  return (
    <div className="rounded-xl bg-[var(--surface)] p-4 shadow-sm">
      <Bone className="mb-4 h-5 w-32" />
      <div className="space-y-2">
        {(["w-full", "w-[85%]", "w-[70%]", "w-[90%]", "w-[60%]"] as const).map((w, i) => (
          <Bone key={i} className={`h-8 ${w}`} />
        ))}
      </div>
    </div>
  );
}
AdminLoadingSkeleton function · typescript · L46-L69 (24 LOC)
app/admin/(dashboard)/components/AdminLoadingSkeleton.tsx
export function AdminLoadingSkeleton({ variant }: AdminLoadingSkeletonProps) {
  if (variant === "metrics") return <MetricsSkeleton />;
  if (variant === "chart") return <ChartSkeleton />;
  if (variant === "table") return <TableSkeleton />;

  // full: metrics + chart grid + table
  return (
    <div className="space-y-6">
      <MetricsSkeleton />
      <div className="grid gap-6 lg:grid-cols-2">
        {Array.from({ length: 4 }).map((_, i) => (
          <div key={i} className="rounded-xl bg-[var(--surface)] p-4 shadow-sm">
            <Bone className="mb-4 h-5 w-32" />
            <div className="space-y-2">
              {Array.from({ length: 5 }).map((_, j) => (
                <Bone key={j} className="h-8 w-full" />
              ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
AdminTabs function · typescript · L21-L49 (29 LOC)
app/admin/(dashboard)/components/AdminTabs.tsx
export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) {
  return (
    <nav
      className="flex flex-row gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
      aria-label="대시보드 탭"
    >
      {TABS.map((tab) => {
        const isActive = tab.id === activeTab;
        const Icon = tab.icon;
        return (
          <button
            key={tab.id}
            title={tab.label}
            onClick={() => onTabChange(tab.id)}
            className={`flex items-center gap-1.5 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
              isActive
                ? "bg-[var(--color-primary)] text-white"
                : "text-[var(--muted)] hover:bg-[var(--background)]"
            }`}
            aria-current={isActive ? "page" : undefined}
          >
            <Icon size={16} aria-hidden="true" />
            <span className="hidden sm:inline">{tab.label}</span>
          </button>
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
validate function · typescript · L48-L74 (27 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
function validate(form: BlogEditorData): Record<string, string> {
  const errors: Record<string, string> = {};

  if (!SLUG_RE.test(form.slug)) {
    errors.slug = "슬러그는 영소문자·숫자·하이픈만 허용, 첫·끝은 영소문자·숫자여야 합니다";
  }
  if (form.title.length < 5) errors.title = "제목은 5자 이상이어야 합니다";
  if (form.title.length > 100) errors.title = "제목은 100자 이하여야 합니다";
  if (form.subtitle.length < 5) errors.subtitle = "부제는 5자 이상이어야 합니다";
  if (form.subtitle.length > 150) errors.subtitle = "부제는 150자 이하여야 합니다";
  if (form.excerpt.length < 20) errors.excerpt = "요약은 20자 이상이어야 합니다";
  if (form.excerpt.length > 500) errors.excerpt = "요약은 500자 이하여야 합니다";
  if (form.content.length === 0) errors.content = "섹션이 최소 1개 필요합니다";
  else {
    for (let i = 0; i < form.content.length; i++) {
      const sec = form.content[i];
      if (!sec.heading.trim()) {
        errors[`content_heading_${i}`] = `섹션 ${i + 1} 제목이 비어 있습니다`;
      }
      if (sec.content.length < 50) {
        errors[`content_body_${i}`] = `섹션 ${i + 1} 내용은 50자 이상이
calcReadTime function · typescript · L80-L84 (5 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
function calcReadTime(sections: { heading: string; content: string }[]): string {
  const chars = sections.reduce((sum, s) => sum + s.content.length, 0);
  const mins = Math.max(1, Math.ceil(chars / 500));
  return `${mins}분`;
}
emptySection function · typescript · L90-L92 (3 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
function emptySection() {
  return { heading: "", content: "" };
}
BlogEditor function · typescript · L98-L466 (369 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
export default function BlogEditor({ mode, initialData, onSave, onClose }: BlogEditorProps) {
  const today = new Date().toISOString().slice(0, 10);

  const [form, setForm] = useState<BlogEditorData>({
    slug: initialData?.slug ?? "",
    title: initialData?.title ?? "",
    subtitle: initialData?.subtitle ?? "",
    excerpt: initialData?.excerpt ?? "",
    category: initialData?.category ?? (BLOG_CATEGORIES.find((c) => c !== "전체") ?? ""),
    tags: initialData?.tags ?? [],
    date: initialData?.date ?? today,
    content: initialData?.content?.length ? initialData.content : [emptySection()],
    published: initialData?.published ?? false,
  });

  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
  const [saveError, setSaveError] = useState<string | null>(null);
  const [saving, setSaving] = useState(false);
  const [fetchingContent, setFetchingContent] = useState(false);

  // In edit mode, fetch full post content (list API returns metadata only)
  useEf
inputClass function · typescript · L472-L482 (11 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
function inputClass(hasError: boolean, readOnly = false): string {
  return [
    "w-full rounded-lg border px-3 py-2 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2",
    hasError
      ? "border-red-400 bg-red-50 focus:border-red-400 focus:ring-red-100"
      : "border-[var(--border)] bg-white focus:border-[var(--color-primary)] focus:ring-[var(--color-primary)]/15",
    readOnly ? "bg-[var(--background)] cursor-not-allowed" : "",
  ]
    .filter(Boolean)
    .join(" ");
}
Field function · typescript · L492-L506 (15 LOC)
app/admin/(dashboard)/components/BlogEditor.tsx
function Field({ label, required, error, hint, children }: FieldProps) {
  return (
    <div>
      <div className="mb-1 flex items-center justify-between">
        <label className="text-sm font-semibold text-[var(--foreground)]">
          {label}
          {required && <span className="ml-1 text-red-500">*</span>}
        </label>
        {hint && <span className="text-xs text-[var(--muted)]">{hint}</span>}
      </div>
      {children}
      {error && <p className="mt-1 text-xs text-red-500">{error}</p>}
    </div>
  );
}
Chart function · typescript · L44-L91 (48 LOC)
app/admin/(dashboard)/components/BlogTab.tsx
      function Chart({ data }: { data: CategoryData[] }) {
        const pieData = data.map((d) => ({
          name: d.category,
          value: d.count,
          fill: CATEGORY_HEX[d.category] ?? "#6B7280",
        }));
        return (
          <mod.ResponsiveContainer width="100%" height={260}>
            <mod.PieChart>
              <mod.Pie
                data={pieData}
                dataKey="value"
                nameKey="name"
                cx="50%"
                cy="50%"
                outerRadius={90}
                label={(props) => {
                  const { cx: cxVal, cy: cyVal, midAngle, innerRadius: ir, outerRadius: or, percent } = props as unknown as { cx: number; cy: number; midAngle: number; innerRadius: number; outerRadius: number; percent: number };
                  const RADIAN = Math.PI / 180;
                  const radius = ir + (or - ir) * 0.5;
                  const x = cxVal + radius * Math.cos(-midAngle * RADIAN);
                  const y =
calcDraftRecommendationOrder function · typescript · L127-L160 (34 LOC)
app/admin/(dashboard)/components/BlogTab.tsx
function calcDraftRecommendationOrder(
  drafts: AdminBlogPost[],
  allPosts: AdminBlogPost[],
  today: string,
): AdminBlogPost[] {
  // 카테고리별 가장 최근 발행일 계산
  const lastPublishedByCategory: Record<string, string> = {};
  for (const p of allPosts) {
    if (p.published && p.date <= today) {
      const prev = lastPublishedByCategory[p.category];
      if (!prev || p.date > prev) {
        lastPublishedByCategory[p.category] = p.date;
      }
    }
  }

  const todayMs = new Date(today).getTime();
  const DAY_MS = 86400000;

  return [...drafts].sort((a, b) => {
    // 카테고리 점수: 마지막 발행일로부터의 일수 (미발행 카테고리 = 9999)
    const lastA = lastPublishedByCategory[a.category];
    const lastB = lastPublishedByCategory[b.category];
    const scoreA = lastA ? Math.floor((todayMs - new Date(lastA).getTime()) / DAY_MS) : 9999;
    const scoreB = lastB ? Math.floor((todayMs - new Date(lastB).getTime()) / DAY_MS) : 9999;

    if (scoreA !== scoreB) return scoreB - scoreA; // 높은 점수 우선

    // 동점: 먼저 작성한 초안 
Repobility analyzer · published findings · https://repobility.com
ScheduleEditor function · typescript · L844-L938 (95 LOC)
app/admin/(dashboard)/components/BlogTab.tsx
function ScheduleEditor() {
  const { data, loading, refetch } = useAdminApi<{ publishDays: number[] }>(
    "/api/admin/site-config/schedule",
  );
  const { mutate, loading: saving } = useAdminMutation();
  const [formEdits, setFormEdits] = useState<number[] | null>(null);
  const [saved, setSaved] = useState(false);

  const days = formEdits ?? data?.publishDays ?? [1, 3, 5];

  const toggleDay = (day: number) => {
    setFormEdits((prev) => {
      const current = prev ?? data?.publishDays ?? [1, 3, 5];
      if (current.includes(day)) {
        if (current.length <= 1) return current;
        return current.filter((d) => d !== day);
      }
      return [...current, day].sort((a, b) => a - b);
    });
  };

  const handleSave = async () => {
    const { error } = await mutate(
      "/api/admin/site-config/schedule",
      "PUT",
      { publishDays: days },
    );
    if (!error) {
      setFormEdits(null);
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
    
HeartIcon function · typescript · L944-L959 (16 LOC)
app/admin/(dashboard)/components/BlogTab.tsx
function HeartIcon() {
  return (
    <svg
      className="h-3.5 w-3.5 text-rose-400"
      fill="currentColor"
      viewBox="0 0 20 20"
      aria-hidden="true"
    >
      <path
        fillRule="evenodd"
        d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
        clipRule="evenodd"
      />
    </svg>
  );
}
ConfigRow function · typescript · L8-L29 (22 LOC)
app/admin/(dashboard)/components/ConfigRow.tsx
export function ConfigRow({ item }: { item: ConfigItem }) {
  return (
    <li className="flex items-center gap-2 text-sm">
      {item.configured ? (
        <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600">
          <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
          </svg>
        </span>
      ) : (
        <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--background)] text-[var(--muted-light)]">
          <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
          </svg>
        </span>
      )}
      <span className={item.configured ? "text-[var(--foreground)]" : "text-[var(--muted)]"}>
        {item.label}
      </span>
SortIcon function · typescript · L23-L27 (5 LOC)
app/admin/(dashboard)/components/DataTable.tsx
function SortIcon({ direction }: { direction: "asc" | "desc" | null }) {
  if (direction === "asc") return <span aria-hidden="true" className="ml-1 text-[var(--color-primary)]">▲</span>;
  if (direction === "desc") return <span aria-hidden="true" className="ml-1 text-[var(--color-primary)]">▼</span>;
  return <span aria-hidden="true" className="ml-1 text-[var(--border)]">⇅</span>;
}
DevTab function · typescript · L21-L71 (51 LOC)
app/admin/(dashboard)/components/DevTab.tsx
export function DevTab() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const rawSub = searchParams.get("sub");
  const validSubIds = SUB_TABS.map((t) => t.id) as string[];
  const activeSub: SubTabId = validSubIds.includes(rawSub ?? "")
    ? (rawSub as SubTabId)
    : "project";

  const handleSubChange = (sub: SubTabId) => {
    router.replace(`${pathname}?tab=dev&sub=${sub}`);
  };

  return (
    <div>
      {/* 서브탭 네비게이션 */}
      <nav
        className="mb-6 flex flex-row gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
        aria-label="개발 서브탭"
      >
        {SUB_TABS.map((tab) => {
          const isActive = tab.id === activeSub;
          const Icon = tab.icon;
          return (
            <button
              key={tab.id}
              title={tab.label}
              onClick={() => handleSubChange(tab.id)}
              className={`flex items-center gap
MetricCard function · typescript · L12-L49 (38 LOC)
app/admin/(dashboard)/components/MetricCard.tsx
export function MetricCard({
  label,
  value,
  change,
  color,
  loading = false,
  invertChange = false,
}: MetricCardProps) {
  return (
    <div className="rounded-lg bg-[var(--background)] p-3 text-center">
      {loading ? (
        <div className="mx-auto h-8 w-16 animate-pulse rounded bg-[var(--border)]" />
      ) : (
        <p className={`text-2xl font-bold ${color ?? "text-[var(--foreground)]"}`}>
          {value}
        </p>
      )}
      <p className="mt-0.5 text-xs text-[var(--muted)]">{label}</p>
      {change !== undefined && (
        <p className="mt-1 text-xs">
          {change === null ? (
            <span className="text-[var(--muted)]">—</span>
          ) : change >= 0 ? (
            invertChange ? (
              <span className="text-red-600">▼ {change}%</span>
            ) : (
              <span className="text-green-600">▲ {change}%</span>
            )
          ) : invertChange ? (
            <span className="text-green-600">▲ {Math.abs(change)}
scoreColor function · typescript · L49-L54 (6 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
function scoreColor(score: number | null): string {
  if (score == null) return "#9CA3AF";
  if (score >= 90) return "#0CCE6B";
  if (score >= 50) return "#FFA400";
  return "#FF4E42";
}
scoreBg function · typescript · L56-L61 (6 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
function scoreBg(score: number | null): string {
  if (score == null) return "bg-gray-100 text-gray-500";
  if (score >= 90) return "bg-green-50 text-green-700";
  if (score >= 50) return "bg-orange-50 text-orange-700";
  return "bg-red-50 text-red-700";
}
All rows above produced by Repobility · https://repobility.com
formatCWVValue function · typescript · L76-L82 (7 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
function formatCWVValue(metric: CWVMetric): string {
  if (metric.percentile == null) return "—";
  if (metric.label === "CLS") return (metric.percentile / 100).toFixed(2);
  if (metric.percentile >= 1000)
    return `${(metric.percentile / 1000).toFixed(1)}s`;
  return `${metric.percentile}ms`;
}
ScoreGauge function · typescript · L86-L143 (58 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
function ScoreGauge({
  score,
  label,
}: {
  score: number | null;
  label: string;
}) {
  const size = 80;
  const stroke = 6;
  const radius = (size - stroke) / 2;
  const circumference = 2 * Math.PI * radius;
  const progress = score != null ? (score / 100) * circumference : 0;
  const color = scoreColor(score);

  return (
    <div className="flex flex-col items-center gap-1">
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        aria-hidden="true"
      >
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="#E5E7EB"
          strokeWidth={stroke}
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke={color}
          strokeWidth={stroke}
          strokeDasharray={circumference}
          strokeDashoffset={circumference - progress}
          strokeLinecap="round"
          transfor
CWVCard function · typescript · L145-L162 (18 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
function CWVCard({ metric }: { metric: CWVMetric }) {
  const badge = categoryBadge(metric.category);
  return (
    <div className="flex flex-col items-center gap-1 rounded-lg bg-[var(--background)] p-3 text-center">
      <span className="text-lg font-bold text-[var(--foreground)]">
        {formatCWVValue(metric)}
      </span>
      <span className="text-xs font-medium text-[var(--muted)]">
        {metric.label}
      </span>
      <span
        className={`mt-0.5 rounded-full px-2 py-0.5 text-[10px] font-medium ${badge.className}`}
      >
        {badge.label}
      </span>
    </div>
  );
}
PerformanceTab function · typescript · L166-L321 (156 LOC)
app/admin/(dashboard)/components/PerformanceTab.tsx
export function PerformanceTab() {
  const { data, loading, error, refetch } =
    useAdminApi<PSIResponseData>("/api/dev/pagespeed");
  const [strategy, setStrategy] = useState<"mobile" | "desktop">("mobile");

  if (loading) {
    return (
      <div className="space-y-4">
        <div className="flex items-center gap-2 text-sm text-[var(--muted)]">
          <div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-primary)] border-t-transparent" />
          PageSpeed 분석 중... (최대 30초 소요)
        </div>
        <AdminLoadingSkeleton variant="full" />
      </div>
    );
  }

  if (error) {
    return <AdminErrorState message={error} onRetry={() => refetch()} />;
  }

  if (!data) return null;

  const result = strategy === "mobile" ? data.mobile : data.desktop;

  if (!result) {
    return (
      <AdminErrorState
        message={`${strategy === "mobile" ? "모바일" : "데스크톱"} 분석 결과를 가져올 수 없습니다`}
        onRetry={() => refetch()}
      />
    );
  }

  const fetched
PeriodSelector function · typescript · L9-L36 (28 LOC)
app/admin/(dashboard)/components/PeriodSelector.tsx
export function PeriodSelector({
  periods,
  selected,
  onChange,
}: PeriodSelectorProps) {
  return (
    <div className="flex flex-row gap-1">
      {periods.map((period) => {
        const isSelected = period.value === selected;
        return (
          <button
            key={period.value}
            onClick={() => onChange(period.value)}
            aria-label={`${period.label} 기간 선택`}
            aria-pressed={isSelected}
            className={`rounded px-3 py-1.5 text-sm transition-colors ${
              isSelected
                ? "bg-[var(--color-primary)] text-white"
                : "bg-[var(--background)] text-[var(--muted)] hover:bg-[var(--border)]"
            }`}
          >
            {period.label}
          </button>
        );
      })}
    </div>
  );
}
PriorityBadge function · typescript · L39-L54 (16 LOC)
app/admin/(dashboard)/components/ProjectTab.tsx
function PriorityBadge({ priority }: { priority: string }) {
  const styles: Record<string, string> = {
    CRITICAL: "bg-red-100 text-red-700",
    HIGH: "bg-orange-100 text-orange-700",
    MEDIUM: "bg-blue-100 text-blue-700",
    LOW: "bg-[var(--background)] text-[var(--muted)]",
  };

  return (
    <span
      className={`inline-block w-20 rounded px-2 py-0.5 text-center text-xs font-semibold ${styles[priority] ?? styles.LOW}`}
    >
      {priority}
    </span>
  );
}
StatusIcon function · typescript · L60-L86 (27 LOC)
app/admin/(dashboard)/components/ProjectTab.tsx
function StatusIcon({ status }: { status: ImprovementStatus }) {
  if (status === "done") {
    return (
      <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600">
        <svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
        </svg>
      </span>
    );
  }
  if (status === "owner-decision") {
    return (
      <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-600">
        <svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6m0 4h.01" />
        </svg>
      </span>
    );
  }
  return (
    <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600">
      
EnvHealthSection function · typescript · L92-L246 (155 LOC)
app/admin/(dashboard)/components/ProjectTab.tsx
function EnvHealthSection() {
  const [expanded, setExpanded] = useState(false);
  const {
    data: envData,
    loading: envLoading,
    error: envError,
    refetch: envRefetch,
  } = useAdminApi<EnvStatusData>("/api/dev/env-status");

  if (envLoading) {
    return (
      <div className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
        <h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">
          환경변수 상태
        </h3>
        <AdminLoadingSkeleton variant="table" />
      </div>
    );
  }

  if (envError) {
    return (
      <div className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
        <h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">
          환경변수 상태
        </h3>
        <AdminErrorState message={envError} onRetry={envRefetch} />
      </div>
    );
  }

  if (!envData) return null;

  const hasMissing = envData.summary.missing > 0;
  const configuredVars = envData.variables.filter((v) => v.configured);
  const missingVars = 
Repobility · code-quality intelligence platform · https://repobility.com
ProjectTab function · typescript · L252-L387 (136 LOC)
app/admin/(dashboard)/components/ProjectTab.tsx
export function ProjectTab() {
  const [expandedPriority, setExpandedPriority] = useState<string | null>(null);
  const stats = getImprovementStats();
  const pct = Math.round((stats.done / stats.total) * 100);

  const pendingItems = IMPROVEMENT_ITEMS.filter((i) => i.status === "pending");
  const ownerItems = IMPROVEMENT_ITEMS.filter((i) => i.status === "owner-decision");

  const togglePriority = (priority: string) => {
    setExpandedPriority((prev) => (prev === priority ? null : priority));
  };

  return (
    <div className="space-y-6">
      {/* 개선 항목 현황 */}
      <div className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
        <h3 className="mb-4 text-lg font-bold text-[var(--foreground)]">
          개선 항목 현황
        </h3>

        {/* 전체 진행률 */}
        <div className="mb-4">
          <div className="mb-1 flex items-center justify-between text-sm">
            <span className="text-[var(--muted)]">전체 진행률</span>
            <span className="font-semibold text-[var(--for
SiteConfigSection function · typescript · L393-L423 (31 LOC)
app/admin/(dashboard)/components/ProjectTab.tsx
function SiteConfigSection({ config }: { config: SiteConfigStatus }) {
  return (
    <div className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
      <h3 className="mb-4 text-lg font-bold text-[var(--foreground)]">
        사이트 설정 상태
      </h3>

      <div className="grid gap-6 sm:grid-cols-2">
        {/* SNS 링크 */}
        <div>
          <h4 className="mb-3 text-sm font-semibold text-[var(--foreground)]">SNS 링크</h4>
          <ul className="space-y-2">
            {config.snsLinks.map((item) => (
              <ConfigRow key={item.label} item={item} />
            ))}
          </ul>
        </div>

        {/* Firebase */}
        <div>
          <h4 className="mb-3 text-sm font-semibold text-[var(--foreground)]">Firebase</h4>
          <ul className="space-y-2">
            {config.firebase.map((item) => (
              <ConfigRow key={item.label} item={item} />
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}
DependenciesContent function · typescript · L93-L149 (57 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function DependenciesContent() {
  const { dependencies, dependencyStats } = DEV_MANIFEST;

  const keyDeps = KEY_PACKAGES.map((name) => {
    const dep = dependencies.find((d) => d.name === name);
    return { name, version: dep?.version ?? "—" };
  });

  const columns = [
    { key: "name", label: "패키지명", sortable: true },
    { key: "version", label: "버전" },
    {
      key: "isDev",
      label: "구분",
      align: "center" as const,
      render: (row: Record<string, unknown>) => (
        <span
          className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
            row.isDev
              ? "bg-purple-100 text-purple-700"
              : "bg-green-100 text-green-700"
          }`}
        >
          {row.isDev ? "dev" : "prod"}
        </span>
      ),
    },
  ];

  return (
    <div className="space-y-4">
      {/* 주요 기술 스택 */}
      <div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
        {keyDeps.map((d) => (
          <div key={d.name} classNam
TsEslintContent function · typescript · L155-L229 (75 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function TsEslintContent() {
  const { tsConfig } = DEV_MANIFEST;

  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {/* TypeScript 설정 */}
      <div className="rounded-lg border border-[var(--border)] p-4">
        <h4 className="mb-3 text-sm font-semibold text-[var(--foreground)]">
          TypeScript 설정
        </h4>
        <ul className="space-y-2 text-sm">
          <li className="flex items-center gap-2">
            {tsConfig.strict ? (
              <span className="flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-green-600">
                <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                </svg>
              </span>
            ) : (
              <span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600">
                <svg className="h-3 w-3" fi
RoutesContent function · typescript · L235-L296 (62 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function RoutesContent() {
  const { routes, routeStats, projectStats } = DEV_MANIFEST;

  const routeColumns = [
    { key: "path", label: "경로", sortable: true },
    {
      key: "type",
      label: "타입",
      align: "center" as const,
      render: (row: Record<string, unknown>) => (
        <span
          className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${TYPE_STYLES[row.type as string] ?? "bg-[var(--background)] text-[var(--foreground)]"}`}
        >
          {row.type as string}
        </span>
      ),
    },
    {
      key: "rendering",
      label: "렌더링",
      align: "center" as const,
      render: (row: Record<string, unknown>) => (
        <span
          className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${RENDERING_STYLES[row.rendering as string] ?? "bg-[var(--background)] text-[var(--foreground)]"}`}
        >
          {row.rendering as string}
        </span>
      ),
    },
  ];

  return (
    <div className="space-y-4">
InfraContent function · typescript · L302-L362 (61 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function InfraContent() {
  return (
    <div className="space-y-4">
      {/* Next.js & Cloud Run 설정 */}
      <div className="grid gap-4 sm:grid-cols-2">
        <div className="rounded-lg border border-[var(--border)] p-4">
          <h4 className="mb-3 text-sm font-semibold text-[var(--foreground)]">
            Next.js 설정
          </h4>
          <ul className="space-y-1.5 text-sm">
            <li className="text-[var(--muted)]">
              프레임워크: <strong className="text-[var(--foreground)]">{NEXTJS_CONFIG.framework}</strong>
            </li>
            <li className="text-[var(--muted)]">
              출력 모드: <strong className="text-[var(--foreground)]">{NEXTJS_CONFIG.output}</strong>
            </li>
            <li className="text-[var(--muted)]">
              배포: <strong className="text-[var(--foreground)]">{NEXTJS_CONFIG.deployment}</strong>
            </li>
          </ul>
        </div>

        <div className="rounded-lg border border-[var(--border)] p-4">
      
FirestoreContent function · typescript · L368-L462 (95 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function FirestoreContent() {
  const { firestoreIndexes, firestoreRules } = DEV_MANIFEST;

  const indexColumns = [
    { key: "collectionGroup", label: "컬렉션" },
    {
      key: "fields",
      label: "필드",
      render: (row: Record<string, unknown>) => {
        const fields = row.fields as { fieldPath: string; order: string }[];
        return (
          <span className="text-sm">
            {fields.map((f) => `${f.fieldPath} (${f.order === "ASCENDING" ? "ASC" : "DESC"})`).join(" + ")}
          </span>
        );
      },
    },
  ];

  const indexRows = firestoreIndexes.map((idx, i) => ({
    _id: `idx-${i}`,
    collectionGroup: idx.collectionGroup,
    fields: idx.fields,
  }));

  return (
    <div className="space-y-4">
      {/* Firestore 컬렉션 */}
      <div>
        <h4 className="mb-3 text-sm font-semibold text-[var(--foreground)]">
          컬렉션 ({FIRESTORE_COLLECTIONS.length}개)
        </h4>
        <div className="grid gap-3 sm:grid-cols-3">
          {FIRESTORE_COLLE
ApiCacheContent function · typescript · L468-L543 (76 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
function ApiCacheContent() {
  const apiColumns = [
    { key: "path", label: "경로", sortable: true },
    {
      key: "methods",
      label: "메서드",
      render: (row: Record<string, unknown>) => {
        const methods = row.methods as string[];
        return (
          <span className="flex flex-wrap gap-1">
            {methods.map((m) => (
              <span
                key={m}
                className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${METHOD_STYLES[m] ?? "bg-[var(--background)] text-[var(--foreground)]"}`}
              >
                {m}
              </span>
            ))}
          </span>
        );
      },
    },
    {
      key: "auth",
      label: "인증",
      align: "center" as const,
      render: (row: Record<string, unknown>) =>
        row.auth ? (
          <span className="text-green-600" title="인증 필요">
            <svg className="mx-auto h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
ReferenceTab function · typescript · L549-L612 (64 LOC)
app/admin/(dashboard)/components/ReferenceTab.tsx
export function ReferenceTab() {
  const [expanded, setExpanded] = useState<string | null>(null);

  const { dependencies, routes, firestoreIndexes } = DEV_MANIFEST;

  const toggle = (id: string) => {
    setExpanded((prev) => (prev === id ? null : id));
  };

  const sections = [
    {
      id: "dependencies",
      title: "의존성",
      summary: `${dependencies.length}개`,
      content: <DependenciesContent />,
    },
    {
      id: "ts-eslint",
      title: "TypeScript & ESLint",
      summary: "설정 요약",
      content: <TsEslintContent />,
    },
    {
      id: "routes",
      title: "라우트 & 렌더링",
      summary: `${routes.length}개`,
      content: <RoutesContent />,
    },
    {
      id: "infra",
      title: "인프라 설정",
      summary: "Next.js / Cloud Run",
      content: <InfraContent />,
    },
    {
      id: "firestore",
      title: "Firestore",
      summary: `${FIRESTORE_COLLECTIONS.length}개 컬렉션, ${firestoreIndexes.length}개 인덱스`,
      content: <FirestoreContent />,
    },
  
Chart function · typescript · L25-L81 (57 LOC)
app/admin/(dashboard)/components/SearchTab.tsx
      function Chart({ data }: { data: KeywordChartItem[] }) {
        const truncate = (s: string, n = 10) =>
          s.length > n ? s.slice(0, n) + "…" : s;
        const chartData = data.slice(0, 10).map((d) => ({
          ...d,
          label: truncate(d.query),
        }));
        return (
          <mod.ResponsiveContainer width="100%" height={300}>
            <mod.BarChart
              data={chartData}
              margin={{ top: 4, right: 8, left: 0, bottom: 52 }}
            >
              <mod.CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
              <mod.XAxis
                dataKey="label"
                tick={{ fontSize: 11, fill: "#6B7280" }}
                angle={-35}
                textAnchor="end"
                interval={0}
              />
              <mod.YAxis tick={{ fontSize: 11, fill: "#6B7280" }} width={44} />
              <mod.Tooltip
                formatter={
                  // eslint-disable-next-line @typescript-eslint/no-ex
SearchTab function · typescript · L144-L332 (189 LOC)
app/admin/(dashboard)/components/SearchTab.tsx
export function SearchTab() {
  const [period, setPeriod] = useState<"7d" | "28d" | "90d">("28d");

  const { data, loading, error, refetch } = useAdminApi<SearchConsoleData>(
    `/api/admin/search-console?period=${period}`,
  );

  const handlePeriodChange = (value: string) => {
    setPeriod(value as "7d" | "28d" | "90d");
  };

  return (
    <div className="space-y-6">
      {/* Period selector */}
      <div className="flex flex-wrap items-center gap-3">
        <PeriodSelector periods={PERIODS} selected={period} onChange={handlePeriodChange} />
        {data?.dataAsOf && (
          <span className="rounded-full bg-[var(--background)] px-2.5 py-1 text-xs text-[var(--muted)]">
            ⓘ 데이터 기준: {data.dataAsOf} (2~3일 지연)
          </span>
        )}
      </div>

      {/* Error state */}
      {error && <AdminErrorState message={error} onRetry={refetch} />}

      {/* Loading state */}
      {loading && <AdminLoadingSkeleton variant="metrics" />}

      {/* Data */}
      {!l
LoadingPlaceholder function · typescript · L75-L84 (10 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function LoadingPlaceholder() {
  return (
    <section className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
      <div className="flex items-center gap-2 text-sm text-[var(--muted)]">
        <Loader2 className="h-4 w-4 animate-spin" />
        <span>불러오는 중...</span>
      </div>
    </section>
  );
}
SnsLinksEditor function · typescript · L117-L191 (75 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function SnsLinksEditor() {
  const { data, loading, refetch } = useAdminApi<SiteLinks>(
    "/api/admin/site-config/links",
  );
  const { mutate, loading: saving } = useAdminMutation();
  const [formEdits, setFormEdits] = useState<SiteLinks | null>(null);
  const [saved, setSaved] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const form = useMemo(() => formEdits ?? data ?? null, [formEdits, data]);

  const handleSave = async () => {
    if (!form) return;
    setSaveError(null);
    const { error } = await mutate("/api/admin/site-config/links", "PUT", form);
    if (error) {
      setSaveError(error);
    } else {
      setFormEdits(null);
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
      refetch();
    }
  };

  const set = (key: keyof SiteLinks) => (value: string) =>
    setFormEdits((prev) => ({ ...(prev ?? data ?? {} as SiteLinks), [key]: value }));

  if (loading || !form) return <LoadingPlaceholder />;

  retur
ClinicInfoEditor function · typescript · L197-L317 (121 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function ClinicInfoEditor() {
  const { data, loading, refetch } = useAdminApi<SiteClinic>(
    "/api/admin/site-config/clinic",
  );
  const { mutate, loading: saving } = useAdminMutation();
  const [formEdits, setFormEdits] = useState<SiteClinic | null>(null);
  const [saved, setSaved] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const form = useMemo(() => formEdits ?? data ?? null, [formEdits, data]);

  const handleSave = async () => {
    if (!form) return;
    setSaveError(null);
    const { error } = await mutate(
      "/api/admin/site-config/clinic",
      "PUT",
      form,
    );
    if (error) {
      setSaveError(error);
    } else {
      setFormEdits(null);
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
      refetch();
    }
  };

  const set = (key: keyof SiteClinic) => (value: string) =>
    setFormEdits((prev) => ({ ...(prev ?? data ?? {} as SiteClinic), [key]: value }));

  if (loading || !form) return
HoursEditor function · typescript · L323-L454 (132 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function HoursEditor() {
  const { data, loading, refetch } = useAdminApi<SiteHours>(
    "/api/admin/site-config/hours",
  );
  const { mutate, loading: saving } = useAdminMutation();
  const [formEdits, setFormEdits] = useState<SiteHours | null>(null);
  const [saved, setSaved] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const form = useMemo(() => formEdits ?? data ?? null, [formEdits, data]);

  const handleSave = async () => {
    if (!form) return;
    setSaveError(null);
    const { error } = await mutate(
      "/api/admin/site-config/hours",
      "PUT",
      form,
    );
    if (error) {
      setSaveError(error);
    } else {
      setFormEdits(null);
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
      refetch();
    }
  };

  const setScheduleField = (
    index: number,
    key: "time" | "open" | "note",
    value: string | boolean,
  ) => {
    setFormEdits((prev) => {
      const base = prev ?? data;
    
SettingsTab function · typescript · L460-L469 (10 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
export function SettingsTab() {
  return (
    <div className="grid gap-6">
      <SnsLinksEditor />
      <ClinicInfoEditor />
      <HoursEditor />
      <QuickLinksSection />
    </div>
  );
}
Repobility analyzer · published findings · https://repobility.com
QuickLinksSection function · typescript · L475-L488 (14 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function QuickLinksSection() {
  return (
    <section className="rounded-xl bg-[var(--surface)] p-6 shadow-sm">
      <h3 className="mb-4 text-lg font-bold text-[var(--foreground)]">
        빠른 링크
      </h3>
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
        {QUICK_LINKS.map((link) => (
          <QuickLinkCard key={link.label} link={link} />
        ))}
      </div>
    </section>
  );
}
QuickLinkCard function · typescript · L490-L510 (21 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function QuickLinkCard({
  link,
}: {
  link: { label: string; href: string; icon: IconType };
}) {
  return (
    <a
      href={link.href}
      target="_blank"
      rel="noopener noreferrer"
      className="flex flex-col items-center gap-2 rounded-lg border border-[var(--border)] p-4 text-center transition-colors hover:border-[var(--color-primary)] hover:bg-blue-50"
    >
      <span className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--background)] text-[var(--muted)]">
        <QuickLinkIcon icon={link.icon} />
      </span>
      <span className="text-xs font-medium text-[var(--foreground)] leading-tight">
        {link.label}
      </span>
    </a>
  );
}
QuickLinkIcon function · typescript · L512-L550 (39 LOC)
app/admin/(dashboard)/components/SettingsTab.tsx
function QuickLinkIcon({ icon }: { icon: IconType }) {
  switch (icon) {
    case "search":
      return (
        <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <circle cx="11" cy="11" r="8" />
          <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35" />
        </svg>
      );
    case "chart":
      return (
        <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18" />
          <path strokeLinecap="round" strokeLinejoin="round" d="M7 16l4-4 4 4 4-4" />
        </svg>
      );
    case "database":
      return (
        <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <ellipse cx="12" cy="5" rx="9" ry="3" />
          <path strokeLinecap="round" strokeLinejoin="round" d="M3 5v6c0 1.657 4.03 3 9 3s9-1.343 9-3V5" />
          <
page 1 / 6next ›