Function bodies 253 total
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)
useEfinputClass 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 gapMetricCard 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"
transforCWVCard 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 fetchedPeriodSelector 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(--forSiteConfigSection 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} classNamTsEslintContent 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" fiRoutesContent 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_COLLEApiCacheContent 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-exSearchTab 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 */}
{!lLoadingPlaceholder 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 />;
returClinicInfoEditor 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) returnHoursEditor 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 ›