Jiseoup/showmycodePublic
EN|KO
  • Code
  • Commits
  • Pull Requests
← Back to list

Initialize the showmycode project

JiseoupJiseoup · Mar 31, 2026696cff6

Files changed44+8718 -0

Changed files

+8718 -0 · 44

@@ -0,0 +1,4 @@
+{
+ "css.lint.unknownAtRules": "ignore",
+ "json.schemaDownload.enable": false
+}
@@ -0,0 +1,67 @@
+import Image from "next/image";
+import { getCommits } from "@/lib/github";
+import { formatDate } from "@/lib/utils";
+import { getDictionary, type Locale } from "@/lib/i18n.server";
+
+type Props = { params: Promise<{ lang: string; owner: string; repo: string }> };
+
+export default async function CommitsPage({ params }: Props) {
+ const { lang, owner, repo } = await params;
+ const [commits, dict] = await Promise.all([
+ getCommits(owner, repo, 50),
+ getDictionary(lang as Locale),
+ ]);
+
+ return (
+ <main className="flex-1 overflow-auto max-w-4xl mx-auto w-full px-6 py-6">
+ <h2 className="text-lg font-semibold mb-4">
+ {dict.commits.title}
+ <span className="ml-2 text-sm font-normal text-muted-foreground">
+ {commits.length}{dict.commits.countSuffix}
+ </span>
+ </h2>
+
+ <ul className="space-y-px">
+ {commits.map((c) => {
+ const [title, ...bodyLines] = c.commit.message.split("\n");
+ const body = bodyLines.join("\n").trim();
+
+ return (
+ <li
+ key={c.sha}
+ className="flex items-start gap-3 py-3 border-b border-border last:border-0"
+ >
+ {c.author?.avatar_url ? (
+ <Image
+ src={c.author.avatar_url}
+ alt={c.author.login}
+ width={32}
+ height={32}
+ className="rounded-full shrink-0 mt-0.5"
+ />
+ ) : (
+ <div className="w-8 h-8 rounded-full bg-muted shrink-0 mt-0.5" />
+ )}
+
+ <div className="min-w-0 flex-1">
+ <p className="font-medium text-sm leading-snug">{title}</p>
+ {body && (
+ <p className="text-xs text-muted-foreground mt-1 whitespace-pre-line line-clamp-3">
+ {body}
+ </p>
+ )}
+ <p className="text-xs text-muted-foreground mt-1">
+ {c.commit.author.name} · {formatDate(c.commit.author.date, lang)}
+ </p>
+ </div>
+
+ <code className="text-xs font-mono text-muted-foreground bg-muted px-2 py-1 rounded shrink-0">
+ {c.sha.slice(0, 7)}
+ </code>
+ </li>
+ );
+ })}
+ </ul>
+ </main>
+ );
+}
@@ -0,0 +1,110 @@
+import Image from "next/image";
+import { getPulls } from "@/lib/github";
+import { formatDate } from "@/lib/utils";
+import { getDictionary, type Locale } from "@/lib/i18n.server";
+
+type Props = { params: Promise<{ lang: string; owner: string; repo: string }> };
+
+function PRBadge({ merged, state, dict }: {
+ merged: boolean;
+ state: string;
+ dict: { merged: string; open: string; closed: string };
+}) {
+ if (merged)
+ return (
+ <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300 font-medium shrink-0">
+ {dict.merged}
+ </span>
+ );
+ if (state === "open")
+ return (
+ <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 font-medium shrink-0">
+ {dict.open}
+ </span>
+ );
+ return (
+ <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300 font-medium shrink-0">
+ {dict.closed}
+ </span>
+ );
+}
+
+export default async function PullsPage({ params }: Props) {
+ const { lang, owner, repo } = await params;
+ const [pulls, dict] = await Promise.all([
+ getPulls(owner, repo, "all"),
+ getDictionary(lang as Locale),
+ ]);
+
+ return (
+ <main className="flex-1 overflow-auto max-w-4xl mx-auto w-full px-6 py-6">
+ <h2 className="text-lg font-semibold mb-4">
+ {dict.pulls.title}
+ <span className="ml-2 text-sm font-normal text-muted-foreground">
+ {pulls.length}{dict.pulls.countSuffix}
+ </span>
+ </h2>
+
+ {pulls.length === 0 ? (
+ <p className="text-muted-foreground text-sm">{dict.pulls.empty}</p>
+ ) : (
+ <ul className="space-y-px">
+ {pulls.map((pr) => (
+ <li
+ key={pr.number}
+ className="flex items-start gap-3 py-4 border-b border-border last:border-0"
+ >
+ <Image
+ src={pr.user.avatar_url}
+ alt={pr.user.login}
+ width={32}
+ height={32}
+ className="rounded-full shrink-0 mt-0.5"
+ />
+
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-2 flex-wrap">
+ <PRBadge merged={!!pr.merged_at} state={pr.state} dict={dict.pulls} />
+ <p className="font-medium text-sm">{pr.title}</p>
+ <span className="text-xs text-muted-foreground">#{pr.number}</span>
+ </div>
+
+ <p className="text-xs text-muted-foreground mt-1 font-mono">
+ {pr.head.ref} → {pr.base.ref}
+ </p>
+
+ {pr.body && (
+ <p className="text-xs text-muted-foreground mt-1.5 line-clamp-2">
+ {pr.body}
+ </p>
+ )}
+
+ <div className="flex items-center gap-2 mt-2 flex-wrap">
+ <span className="text-xs text-muted-foreground">
+ {pr.user.login} · {formatDate(pr.created_at, lang)}
+ </span>
+ {pr.labels.map((l) => {
+ const safeColor = /^[0-9a-fA-F]{6}$/.test(l.color) ? l.color : "8b949e";
+ return (
+ <span
+ key={l.name}
+ className="text-xs px-1.5 py-0.5 rounded"
+ style={{
+ background: `#${safeColor}33`,
+ color: `#${safeColor}`,
+ border: `1px solid #${safeColor}55`,
+ }}
+ >
+ {l.name}
+ </span>
+ );
+ })}
+ </div>
+ </div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </main>
+ );
+}
@@ -0,0 +1,32 @@
+"use client";
+
+import { useParams } from "next/navigation";
+
+const messages = {
+ ko: { title: "데이터를 불러오지 못했습니다.", retry: "다시 시도" },
+ en: { title: "Failed to load data.", retry: "Try again" },
+} as const;
+
+export default function RepoError({
+ error,
+ unstable_retry,
+}: {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}) {
+ const { lang } = useParams<{ lang: string }>();
+ const t = messages[lang as keyof typeof messages] ?? messages.ko;
+
+ return (
+ <div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
+ <p className="text-sm font-medium">{t.title}</p>
+ <p className="text-xs text-muted-foreground">{error.message}</p>
+ <button
+ onClick={unstable_retry}
+ className="text-xs px-3 py-1.5 rounded border border-border hover:bg-muted transition-colors"
+ >
+ {t.retry}
+ </button>
+ </div>
+ );
+}
@@ -0,0 +1,78 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getRepo, getAllowedRepos } from "@/lib/github";
+import { ThemeToggle } from "@/components/ThemeToggle";
+import { LangSwitcher } from "@/components/LangSwitcher";
+import { getDictionary, type Locale } from "@/lib/i18n.server";
+
+type Props = {
+ children: React.ReactNode;
+ params: Promise<{ lang: string; owner: string; repo: string }>;
+};
+
+export default async function RepoLayout({ children, params }: Props) {
+ const { lang, owner, repo } = await params;
+ const locale = lang as Locale;
+
+ const allowed = getAllowedRepos().some(
+ (r) => r.owner === owner && r.repo === repo
+ );
+ if (!allowed) notFound();
+
+ const [repoData, dict] = await Promise.all([
+ getRepo(owner, repo),
+ getDictionary(locale),
+ ]);
+
+ const tabs = [
+ { label: dict.nav.code, href: `/${locale}/repository/${owner}/${repo}` },
+ { label: dict.nav.commits, href: `/${locale}/repository/${owner}/${repo}/commits` },
+ { label: dict.nav.pulls, href: `/${locale}/repository/${owner}/${repo}/pulls` },
+ ];
+
+ return (
+ <div className="min-h-screen bg-background flex flex-col">
+ <header className="border-b border-border px-6 py-3 flex items-center justify-between gap-4">
+ <div className="flex items-center gap-2 min-w-0">
+ <Link href={`/${locale}`} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
+ <svg viewBox="0 0 16 16" className="w-5 h-5 fill-current" aria-hidden>
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
+ </svg>
+ </Link>
+ <span className="text-muted-foreground">/</span>
+ <Link href={`/${locale}`} className="text-muted-foreground hover:text-foreground text-sm transition-colors">
+ {owner}
+ </Link>
+ <span className="text-muted-foreground">/</span>
+ <span className="font-semibold text-sm truncate">{repo}</span>
+ <span className="shrink-0 text-xs border border-border rounded-full px-2 py-0.5 text-muted-foreground">
+ {repoData.private ? dict.repo.private : dict.repo.public}
+ </span>
+ </div>
+ <div className="flex items-center gap-3">
+ <LangSwitcher currentLang={locale} />
+ <ThemeToggle />
+ </div>
+ </header>
+
+ <nav className="border-b border-border px-6">
+ <ul className="flex gap-1">
+ {tabs.map((tab) => (
+ <li key={tab.href}>
+ <Link
+ href={tab.href}
+ className="inline-block px-3 py-2.5 text-sm text-muted-foreground hover:text-foreground border-b-2 border-transparent hover:border-foreground/30 transition-colors"
+ >
+ {tab.label}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </nav>
+
+ <div className="flex-1 flex overflow-hidden">
+ {children}
+ </div>
+ </div>
+ );
+}
@@ -0,0 +1,35 @@
+import { Sidebar } from "@/components/Sidebar";
+import { CodeViewer } from "@/components/CodeViewer";
+import { getDictionary, type Locale } from "@/lib/i18n.server";
+
+type Props = {
+ params: Promise<{ lang: string; owner: string; repo: string }>;
+ searchParams: Promise<{ path?: string }>;
+};
+
+export default async function CodePage({ params, searchParams }: Props) {
+ const { lang, owner, repo } = await params;
+ const { path: selectedPath } = await searchParams;
+ const dict = await getDictionary(lang as Locale);
+
+ return (
+ <div className="flex flex-1 overflow-hidden">
+ <Sidebar owner={owner} repo={repo} selectedPath={selectedPath} lang={lang as Locale} filesLabel={dict.code.files} />
+
+ <main className="flex-1 overflow-auto">
+ {selectedPath ? (
+ <div>
+ <div className="px-4 py-2 border-b border-border bg-muted/40 text-sm font-mono text-muted-foreground">
+ {selectedPath}
+ </div>
+ <CodeViewer owner={owner} repo={repo} path={selectedPath} />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
+ {dict.code.selectFile}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { hasLocale } from "@/lib/i18n.server";
+
+export default async function LangLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ if (!hasLocale(lang)) notFound();
+ return <>{children}</>;
+}
+
+export function generateStaticParams() {
+ return [{ lang: "ko" }, { lang: "en" }];
+}
@@ -0,0 +1,45 @@
+import { getAllowedRepos, getRepo } from "@/lib/github";
+import { RepoCard } from "@/components/RepoCard";
+import { ThemeToggle } from "@/components/ThemeToggle";
+import { LangSwitcher } from "@/components/LangSwitcher";
+import { getDictionary, type Locale } from "@/lib/i18n.server";
+
+export default async function HomePage({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ const locale = lang as Locale;
+ const [repos] = await Promise.all([
+ Promise.all(getAllowedRepos().map(({ owner, repo }) => getRepo(owner, repo))),
+ ]);
+ const dict = await getDictionary(locale);
+
+ return (
+ <div className="min-h-screen bg-background">
+ <header className="border-b border-border px-6 py-4 flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <svg viewBox="0 0 16 16" className="w-6 h-6 fill-current" aria-hidden>
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
+ </svg>
+ <span className="font-semibold text-lg">showmycode</span>
+ </div>
+ <div className="flex items-center gap-3">
+ <LangSwitcher currentLang={locale} />
+ <ThemeToggle />
+ </div>
+ </header>
+
+ <main className="max-w-3xl mx-auto px-6 py-10">
+ <ul className="space-y-3">
+ {repos.map((repo) => (
+ <li key={repo.full_name}>
+ <RepoCard repo={repo} lang={locale} dict={dict.repo} />
+ </li>
+ ))}
+ </ul>
+ </main>
+ </div>
+ );
+}
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAllowedRepos } from "@/lib/github";
+
+const BASE = "https://api.github.com";
+const PAT = process.env.GITHUB_PAT!;
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: { path: string[] } }
+) {
+ const ghPath = params.path.join("/");
+
+ // repos/{owner}/{repo}/... 형태에서 owner, repo 추출 후 허용 목록 검사
+ const match = ghPath.match(/^repos\/([^/]+)\/([^/]+)/);
+ if (match) {
+ const [, owner, repo] = match;
+ const allowed = getAllowedRepos().some((r) => r.owner === owner && r.repo === repo);
+ if (!allowed) return NextResponse.json({ message: "Not Found" }, { status: 404 });
+ }
+
+ const search = req.nextUrl.search;
+
+ const res = await fetch(`${BASE}/${ghPath}${search}`, {
+ headers: {
+ Authorization: `Bearer ${PAT}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ next: { revalidate: 60 },
+ });
+
+ const data = await res.json();
+ return NextResponse.json(data, { status: res.status });
+}

Binary file

@@ -0,0 +1,38 @@
+"use client";
+
+export default function GlobalError({
+ unstable_retry,
+}: {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}) {
+ return (
+ <html>
+ <body
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ minHeight: "100vh",
+ flexDirection: "column",
+ gap: "12px",
+ fontFamily: "sans-serif",
+ }}
+ >
+ <p style={{ fontSize: "14px", fontWeight: 500 }}>Something went wrong.</p>
+ <button
+ onClick={unstable_retry}
+ style={{
+ fontSize: "12px",
+ padding: "6px 12px",
+ border: "1px solid #ccc",
+ borderRadius: "4px",
+ cursor: "pointer",
+ }}
+ >
+ Try again
+ </button>
+ </body>
+ </html>
+ );
+}
@@ -0,0 +1,55 @@
+@import "tailwindcss";
+
+@variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-border: hsl(var(--border));
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+ --radius-lg: var(--radius);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-sm: calc(var(--radius) - 4px);
+}
+
+:root {
+ --background: 0 0% 100%;
+ --foreground: 222 47% 11%;
+ --card: 0 0% 100%;
+ --card-foreground: 222 47% 11%;
+ --border: 214 32% 91%;
+ --muted: 210 40% 96%;
+ --muted-foreground: 215 16% 47%;
+ --accent: 210 40% 96%;
+ --accent-foreground: 222 47% 11%;
+ --radius: 0.5rem;
+}
+
+.dark {
+ --background: 222 47% 7%;
+ --foreground: 213 31% 91%;
+ --card: 222 47% 10%;
+ --card-foreground: 213 31% 91%;
+ --border: 216 34% 17%;
+ --muted: 223 47% 13%;
+ --muted-foreground: 215 20% 65%;
+ --accent: 216 34% 17%;
+ --accent-foreground: 213 31% 91%;
+}
+
+@layer base {
+ * { @apply border-border; }
+ body { @apply bg-background text-foreground; }
+}
+
+/* Shiki 다크모드 */
+.dark .shiki,
+.dark .shiki span {
+ color: var(--shiki-dark) !important;
+ background-color: var(--shiki-dark-bg) !important;
+}
@@ -0,0 +1,32 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "showmycode",
+ description: "Private repository viewer for interviewers",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="ko" suppressHydrationWarning>
+ <head>
+ {/* 새로고침 시 다크모드 깜빡임 방지 */}
+ <script dangerouslySetInnerHTML={{
+ __html: `
+ try {
+ const saved = localStorage.getItem("theme");
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ if (saved === "dark" || (!saved && prefersDark)) {
+ document.documentElement.classList.add("dark");
+ }
+ } catch {}
+ `
+ }} />
+ </head>
+ <body className={inter.className}>{children}</body>
+ </html>
+ );
+}
@@ -0,0 +1,34 @@
+import { codeToHtml } from "shiki";
+import { getContents } from "@/lib/github";
+import { getLanguage } from "@/lib/utils";
+
+export async function CodeViewer({
+ owner,
+ repo,
+ path,
+}: {
+ owner: string;
+ repo: string;
+ path: string;
+}) {
+ const file = await getContents(owner, repo, path);
+
+ // base64 디코딩
+ const raw = file.encoding === "base64"
+ ? Buffer.from(file.content.replace(/\n/g, ""), "base64").toString("utf-8")
+ : file.content;
+
+ const lang = getLanguage(path);
+
+ const html = await codeToHtml(raw, {
+ lang,
+ themes: { light: "github-light", dark: "github-dark" },
+ });
+
+ return (
+ <div
+ className="text-sm overflow-auto [&>pre]:p-5 [&>pre]:min-h-full"
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ );
+}
@@ -0,0 +1,159 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import type { GhTreeItem } from "@/lib/github";
+
+type TreeNode = {
+ name: string;
+ path: string;
+ type: "blob" | "tree";
+ children?: TreeNode[];
+};
+
+function buildTree(items: GhTreeItem[]): TreeNode[] {
+ const root: TreeNode[] = [];
+
+ for (const item of items) {
+ const parts = item.path.split("/");
+ let current = root;
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const path = parts.slice(0, i + 1).join("/");
+ const isLast = i === parts.length - 1;
+
+ let node = current.find((n) => n.name === part);
+ if (!node) {
+ node = {
+ name: part,
+ path,
+ type: isLast ? item.type : "tree",
+ children: isLast && item.type === "blob" ? undefined : [],
+ };
+ current.push(node);
+ }
+ current = node.children ?? [];
+ }
+ }
+
+ // 폴더 먼저, 파일 나중 정렬
+ const sort = (nodes: TreeNode[]): TreeNode[] =>
+ nodes
+ .sort((a, b) => {
+ if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ })
+ .map((n) => ({ ...n, children: n.children ? sort(n.children) : undefined }));
+
+ return sort(root);
+}
+
+function TreeNodeItem({
+ node,
+ owner,
+ repo,
+ lang,
+ selectedPath,
+ depth,
+}: {
+ node: TreeNode;
+ owner: string;
+ repo: string;
+ lang: string;
+ selectedPath?: string;
+ depth: number;
+}) {
+ const [open, setOpen] = useState(
+ depth < 1 || (selectedPath?.startsWith(node.path + "/") ?? false)
+ );
+
+ if (node.type === "tree") {
+ return (
+ <li>
+ <button
+ onClick={() => setOpen((o) => !o)}
+ className="flex items-center gap-1.5 w-full text-left px-2 py-0.5 rounded text-sm hover:bg-accent transition-colors"
+ style={{ paddingLeft: `${8 + depth * 12}px` }}
+ >
+ <svg viewBox="0 0 16 16" className={cn("w-4 h-4 fill-current text-yellow-500 shrink-0 transition-transform", open && "rotate-0")} aria-hidden>
+ {open
+ ? <path d="M1.75 2.5a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H7.5c-.55 0-1.07-.26-1.4-.7l-.9-1.2a.25.25 0 00-.2-.1H1.75z"/>
+ : <path d="M1.75 2.5a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H7.5c-.55 0-1.07-.26-1.4-.7l-.9-1.2a.25.25 0 00-.2-.1H1.75z"/>
+ }
+ </svg>
+ <span className="truncate">{node.name}</span>
+ </button>
+ {open && node.children && (
+ <ul>
+ {node.children.map((child) => (
+ <TreeNodeItem
+ key={child.path}
+ node={child}
+ owner={owner}
+ repo={repo}
+ lang={lang}
+ selectedPath={selectedPath}
+ depth={depth + 1}
+ />
+ ))}
+ </ul>
+ )}
+ </li>
+ );
+ }
+
+ const isSelected = selectedPath === node.path;
+ return (
+ <li>
+ <Link
+ href={`/${lang}/repository/${owner}/${repo}?path=${encodeURIComponent(node.path)}`}
+ className={cn(
+ "flex items-center gap-1.5 w-full text-sm px-2 py-0.5 rounded transition-colors truncate",
+ isSelected
+ ? "bg-accent text-accent-foreground font-medium"
+ : "hover:bg-accent text-muted-foreground hover:text-foreground"
+ )}
+ style={{ paddingLeft: `${8 + depth * 12}px` }}
+ >
+ <svg viewBox="0 0 16 16" className="w-4 h-4 fill-current shrink-0 opacity-50" aria-hidden>
+ <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113.25 16h-9.5A1.75 1.75 0 012 14.25V1.75z"/>
+ </svg>
+ <span className="truncate">{node.name}</span>
+ </Link>
+ </li>
+ );
+}
+
+export function FileTree({
+ items,
+ owner,
+ repo,
+ lang,
+ selectedPath,
+}: {
+ items: GhTreeItem[];
+ owner: string;
+ repo: string;
+ lang: string;
+ selectedPath?: string;
+}) {
+ const tree = buildTree(items);
+
+ return (
+ <ul className="text-sm space-y-0.5">
+ {tree.map((node) => (
+ <TreeNodeItem
+ key={node.path}
+ node={node}
+ owner={owner}
+ repo={repo}
+ lang={lang}
+ selectedPath={selectedPath}
+ depth={0}
+ />
+ ))}
+ </ul>
+ );
+}
@@ -0,0 +1,38 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { locales, type Locale } from "@/lib/i18n";
+
+const labels: Record<Locale, string> = { ko: "KO", en: "EN" };
+
+export function LangSwitcher({ currentLang }: { currentLang: Locale }) {
+ const pathname = usePathname();
+
+ function switchTo(lang: Locale) {
+ // /ko/... → /en/... or /ko → /en
+ const segments = pathname.split("/");
+ segments[1] = lang;
+ return segments.join("/") || "/";
+ }
+
+ return (
+ <div className="flex items-center gap-0.5 text-xs">
+ {locales.map((lang, i) => (
+ <span key={lang} className="flex items-center gap-0.5">
+ {i > 0 && <span className="text-muted-foreground/40">|</span>}
+ {lang === currentLang ? (
+ <span className="font-semibold text-foreground">{labels[lang]}</span>
+ ) : (
+ <Link
+ href={switchTo(lang)}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {labels[lang]}
+ </Link>
+ )}
+ </span>
+ ))}
+ </div>
+ );
+}
@@ -0,0 +1,86 @@
+import Link from "next/link";
+import { formatDate } from "@/lib/utils";
+import type { GhRepo } from "@/lib/github";
+
+const LANG_COLORS: Record<string, string> = {
+ TypeScript: "#3178c6",
+ JavaScript: "#f1e05a",
+ Python: "#3572A5",
+ Go: "#00ADD8",
+ Rust: "#dea584",
+ Java: "#b07219",
+ Kotlin: "#A97BFF",
+ Swift: "#F05138",
+ Ruby: "#701516",
+ PHP: "#4F5D95",
+ CSS: "#563d7c",
+ HTML: "#e34c26",
+ Shell: "#89e051",
+};
+
+export function RepoCard({
+ repo,
+ lang,
+ dict,
+}: {
+ repo: GhRepo;
+ lang: string;
+ dict: { noDescription: string; updated: string; private: string; public: string };
+}) {
+ const [owner, name] = repo.full_name.split("/");
+ const langColor = repo.language ? (LANG_COLORS[repo.language] ?? "#888") : null;
+
+ return (
+ <Link
+ href={`/${lang}/repository/${owner}/${name}`}
+ className="block rounded-lg border border-border bg-card p-5 hover:border-foreground/30 hover:shadow-sm transition-all"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <div className="min-w-0">
+ {/* 레포 이름 */}
+ <p className="font-semibold text-blue-600 dark:text-blue-400 truncate">
+ {name}
+ </p>
+
+ {/* 설명 */}
+ {repo.description ? (
+ <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
+ {repo.description}
+ </p>
+ ) : (
+ <p className="text-sm text-muted-foreground/50 mt-1 italic">
+ {dict.noDescription}
+ </p>
+ )}
+
+ {/* 메타 정보 */}
+ <div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground flex-wrap">
+ {langColor && repo.language && (
+ <span className="flex items-center gap-1.5">
+ <span
+ className="w-2.5 h-2.5 rounded-full shrink-0"
+ style={{ background: langColor }}
+ />
+ {repo.language}
+ </span>
+ )}
+
+ <span className="flex items-center gap-1">
+ <svg viewBox="0 0 16 16" className="w-3.5 h-3.5 fill-current" aria-hidden>
+ <path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
+ </svg>
+ {repo.stargazers_count}
+ </span>
+
+ <span>{dict.updated} {formatDate(repo.updated_at, lang)}</span>
+ </div>
+ </div>
+
+ {/* Private / Public 뱃지 */}
+ <span className="shrink-0 text-xs border border-border rounded-full px-2 py-0.5 text-muted-foreground">
+ {repo.private ? "Private" : "Public"}
+ </span>
+ </div>
+ </Link>
+ );
+}
@@ -0,0 +1,34 @@
+import { getTree } from "@/lib/github";
+import { FileTree } from "@/components/FileTree";
+
+type Props = {
+ owner: string;
+ repo: string;
+ lang: string;
+ filesLabel: string;
+ selectedPath?: string;
+};
+
+export async function Sidebar({ owner, repo, lang, filesLabel, selectedPath }: Props) {
+ const { tree } = await getTree(owner, repo);
+
+ return (
+ <aside className="w-64 shrink-0 border-r border-border overflow-y-auto flex flex-col">
+ <div className="px-3 py-2.5 border-b border-border">
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
+ {filesLabel}
+ </p>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-2">
+ <FileTree
+ items={tree}
+ owner={owner}
+ repo={repo}
+ lang={lang}
+ selectedPath={selectedPath}
+ />
+ </div>
+ </aside>
+ );
+}
@@ -0,0 +1,42 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+function subscribe(cb: () => void) {
+ const mo = new MutationObserver(cb);
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+ return () => mo.disconnect();
+}
+
+export function ThemeToggle() {
+ const dark = useSyncExternalStore(
+ subscribe,
+ () => document.documentElement.classList.contains("dark"),
+ () => false
+ );
+
+ const toggle = () => {
+ const next = !dark;
+ document.documentElement.classList.toggle("dark", next);
+ localStorage.setItem("theme", next ? "dark" : "light");
+ };
+
+ return (
+ <button
+ onClick={toggle}
+ className="p-2 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
+ aria-label="테마 전환"
+ suppressHydrationWarning
+ >
+ {dark ? (
+ <svg viewBox="0 0 16 16" className="w-4 h-4 fill-current" aria-hidden>
+ <path d="M8 12a4 4 0 100-8 4 4 0 000 8zM8 0a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0V.75A.75.75 0 018 0zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 018 13zM2.343 2.343a.75.75 0 011.061 0l1.06 1.061a.75.75 0 01-1.06 1.06l-1.06-1.06a.75.75 0 010-1.061zm9.193 9.193a.75.75 0 011.06 0l1.061 1.06a.75.75 0 01-1.06 1.061l-1.061-1.06a.75.75 0 010-1.061zM16 8a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0116 8zM3 8a.75.75 0 01-.75.75H.75a.75.75 0 010-1.5h1.5A.75.75 0 013 8zm10.657-5.657a.75.75 0 010 1.061l-1.061 1.06a.75.75 0 11-1.06-1.06l1.06-1.06a.75.75 0 011.061 0zm-9.193 9.193a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0z"/>
+ </svg>
+ ) : (
+ <svg viewBox="0 0 16 16" className="w-4 h-4 fill-current" aria-hidden>
+ <path d="M9.598 1.591a.75.75 0 01.785-.175 7 7 0 11-8.967 8.967.75.75 0 01.961-.96 5.5 5.5 0 007.046-7.046.75.75 0 01.175-.786z"/>
+ </svg>
+ )}
+ </button>
+ );
+}
@@ -0,0 +1,98 @@
+const BASE = "https://api.github.com";
+const PAT = process.env.GITHUB_PAT!;
+
+const headers = {
+ Authorization: `Bearer ${PAT}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+};
+
+async function ghFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
+ const url = new URL(`${BASE}/${path}`);
+ if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
+ const res = await fetch(url.toString(), { headers, next: { revalidate: 60 } });
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${path}`);
+ return res.json();
+}
+
+// 레포 정보
+export async function getRepo(owner: string, repo: string) {
+ return ghFetch<GhRepo>(`repos/${owner}/${repo}`);
+}
+
+// 허용된 레포 목록 (환경변수 기반)
+export function getAllowedRepos(): { owner: string; repo: string }[] {
+ const owner = process.env.GITHUB_OWNER!;
+ const repos = (process.env.GITHUB_REPOS ?? "").split(",").map((r) => r.trim()).filter(Boolean);
+ return repos.map((repo) => ({ owner, repo }));
+}
+
+// 파일 트리
+export async function getTree(owner: string, repo: string, sha = "HEAD") {
+ return ghFetch<GhTree>(`repos/${owner}/${repo}/git/trees/${sha}`, { recursive: "1" });
+}
+
+// 파일 내용
+export async function getContents(owner: string, repo: string, path: string) {
+ return ghFetch<GhContent>(`repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`);
+}
+
+// 커밋 목록
+export async function getCommits(owner: string, repo: string, perPage = 30) {
+ return ghFetch<GhCommit[]>(`repos/${owner}/${repo}/commits`, { per_page: String(perPage) });
+}
+
+// PR 목록
+export async function getPulls(owner: string, repo: string, state: "open" | "closed" | "all" = "all") {
+ return ghFetch<GhPull[]>(`repos/${owner}/${repo}/pulls`, { state, per_page: "50" });
+}
+
+// --- 타입 ---
+export type GhRepo = {
+ name: string;
+ full_name: string;
+ description: string | null;
+ private: boolean;
+ default_branch: string;
+ stargazers_count: number;
+ language: string | null;
+ updated_at: string;
+};
+
+export type GhTreeItem = {
+ path: string;
+ type: "blob" | "tree";
+ sha: string;
+ size?: number;
+};
+
+export type GhTree = { tree: GhTreeItem[]; truncated: boolean };
+
+export type GhContent = {
+ name: string;
+ path: string;
+ content: string;
+ encoding: string;
+};
+
+export type GhCommit = {
+ sha: string;
+ commit: {
+ message: string;
+ author: { name: string; email: string; date: string };
+ };
+ author: { login: string; avatar_url: string } | null;
+};
+
+export type GhPull = {
+ number: number;
+ title: string;
+ state: "open" | "closed";
+ user: { login: string; avatar_url: string };
+ created_at: string;
+ merged_at: string | null;
+ body: string | null;
+ labels: { name: string; color: string }[];
+ head: { ref: string };
+ base: { ref: string };
+};
@@ -0,0 +1,15 @@
+import "server-only";
+import type { Locale } from "@/lib/i18n";
+import ko from "@/locales/ko.json";
+
+export type { Locale } from "@/lib/i18n";
+export { locales, defaultLocale, hasLocale } from "@/lib/i18n";
+
+type Dictionary = typeof ko;
+
+const load: Record<Locale, () => Promise<Dictionary>> = {
+ ko: () => import("@/locales/ko.json").then((m) => m.default),
+ en: () => import("@/locales/en.json").then((m) => m.default),
+};
+
+export const getDictionary = (locale: Locale): Promise<Dictionary> => load[locale]();
@@ -0,0 +1,5 @@
+export const locales = ["ko", "en"] as const;
+export type Locale = (typeof locales)[number];
+export const defaultLocale: Locale = "ko";
+export const hasLocale = (locale: string): locale is Locale =>
+ (locales as readonly string[]).includes(locale);
@@ -0,0 +1,30 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatDate(iso: string, lang = "ko") {
+ return new Date(iso).toLocaleDateString(lang === "ko" ? "ko-KR" : "en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export function getLanguage(filename: string) {
+ const ext = filename.split(".").pop() ?? "";
+ const map: Record<string, string> = {
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
+ py: "python", go: "go", rs: "rust", java: "java",
+ kt: "kotlin", swift: "swift", rb: "ruby", php: "php",
+ md: "markdown", mdx: "mdx", json: "json",
+ yaml: "yaml", yml: "yaml", toml: "toml",
+ css: "css", scss: "scss", html: "html",
+ sh: "bash", bash: "bash", zsh: "bash",
+ sql: "sql", graphql: "graphql",
+ dockerfile: "dockerfile",
+ };
+ return map[ext.toLowerCase()] ?? "text";
+}
@@ -0,0 +1,33 @@
+{
+ "nav": {
+ "code": "Code",
+ "commits": "Commits",
+ "pulls": "Pull Requests"
+ },
+ "repo": {
+ "private": "Private",
+ "public": "Public",
+ "noDescription": "No description",
+ "updated": "Updated"
+ },
+ "code": {
+ "files": "Files",
+ "selectFile": "Select a file from the left"
+ },
+ "commits": {
+ "title": "Commit History",
+ "countSuffix": ""
+ },
+ "pulls": {
+ "title": "Pull Requests",
+ "countSuffix": "",
+ "empty": "No pull requests.",
+ "merged": "Merged",
+ "open": "Open",
+ "closed": "Closed"
+ },
+ "error": {
+ "title": "Failed to load data.",
+ "retry": "Try again"
+ }
+}
@@ -0,0 +1,33 @@
+{
+ "nav": {
+ "code": "코드",
+ "commits": "커밋",
+ "pulls": "PR"
+ },
+ "repo": {
+ "private": "Private",
+ "public": "Public",
+ "noDescription": "설명 없음",
+ "updated": "업데이트"
+ },
+ "code": {
+ "files": "파일",
+ "selectFile": "왼쪽에서 파일을 선택하세요"
+ },
+ "commits": {
+ "title": "커밋 히스토리",
+ "countSuffix": "개"
+ },
+ "pulls": {
+ "title": "Pull Requests",
+ "countSuffix": "개",
+ "empty": "PR이 없습니다.",
+ "merged": "Merged",
+ "open": "Open",
+ "closed": "Closed"
+ },
+ "error": {
+ "title": "데이터를 불러오지 못했습니다.",
+ "retry": "다시 시도"
+ }
+}
@@ -0,0 +1 @@
+<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
@@ -0,0 +1,9 @@
+# GitHub Personal Access Token (Fine-grained, Read-only)
+# https://github.com/settings/personal-access-tokens
+GITHUB_PAT=
+
+# GitHub 유저명
+GITHUB_OWNER=
+
+# 공개할 레포 이름 (쉼표로 구분)
+GITHUB_REPOS=
@@ -0,0 +1,42 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+!.env.example
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
@@ -0,0 +1,5 @@
+<!-- BEGIN:nextjs-agent-rules -->
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
+<!-- END:nextjs-agent-rules -->
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 JISUB LIM
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
@@ -0,0 +1,14 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "avatars.githubusercontent.com",
+ },
+ ],
+ },
+};
+
+export default nextConfig;

Binary file

@@ -0,0 +1,29 @@
+{
+ "name": "showmycode",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "next": "16.2.1",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "shiki": "^4.0.2",
+ "tailwind-merge": "^3.5.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20.19.37",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.1",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { locales, defaultLocale, hasLocale } from "@/lib/i18n";
+
+export function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // 이미 로케일 prefix가 있으면 통과
+ const pathnameHasLocale = locales.some(
+ (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
+ );
+ if (pathnameHasLocale) return;
+
+ // Accept-Language 헤더에서 로케일 감지
+ const acceptLang = request.headers.get("accept-language") ?? "";
+ const detected = acceptLang.split(",")[0].split("-")[0].toLowerCase();
+ const locale = hasLocale(detected) ? detected : defaultLocale;
+
+ request.nextUrl.pathname = `/${locale}${pathname}`;
+ return NextResponse.redirect(request.nextUrl);
+}
+
+export const config = {
+ matcher: ["/((?!_next|api|.*\\..*).*)"],
+};
@@ -0,0 +1,121 @@
+# Portfolio Viewer
+
+GitHub Private 레포지토리를 면접관에게 안전하게 공유할 수 있는 소스 코드 뷰어입니다.
+
+## Features
+
+- 파일 트리 + 소스 코드 뷰어 (신택스 하이라이팅)
+- 커밋 히스토리
+- Pull Request 목록
+- 다크모드 지원
+- GitHub PAT 서버사이드 보호 (클라이언트 노출 없음)
+
+## Tech Stack
+
+- [Next.js 15](https://nextjs.org/) (App Router)
+- [Tailwind CSS](https://tailwindcss.com/)
+- [shadcn/ui](https://ui.shadcn.com/)
+- [Shiki](https://shiki.style/) (신택스 하이라이팅)
+- [Vercel](https://vercel.com/) (배포)
+
+---
+
+## Getting Started
+
+### 1. 레포지토리 클론
+
+```bash
+git clone https://github.com/your-username/showmycode.git
+cd showmycode
+```
+
+### 2. 패키지 설치
+
+```bash
+npm install
+```
+
+### 3. 환경 변수 설정
+
+`.env.local` 파일을 생성하고 아래 값을 입력합니다.
+
+```bash
+cp .env.example .env.local
+```
+
+```env
+GITHUB_PAT=ghp_xxxxxxxxxxxxxxxxxxxx
+GITHUB_OWNER=your-github-username
+GITHUB_REPOS=repo-name-1,repo-name-2
+```
+
+### 4. 개발 서버 실행
+
+```bash
+npm run dev
+```
+
+http://localhost:3000 에서 확인할 수 있습니다.
+
+---
+
+## GitHub PAT 발급 방법
+
+1. GitHub → Settings → Developer settings → Personal access tokens → **Fine-grained tokens**
+2. Repository access: 공개할 레포만 선택
+3. Permissions 설정
+ - **Contents**: Read-only
+ - **Pull requests**: Read-only
+4. 생성된 토큰을 `.env.local`의 `GITHUB_PAT`에 입력
+
+> PAT는 절대 커밋하지 마세요. `.env.local`은 `.gitignore`에 포함되어 있습니다.
+
+---
+
+## Deployment (Vercel)
+
+1. [Vercel](https://vercel.com)에 레포지토리 연결
+2. 대시보드 → Settings → Environment Variables에 아래 3개 등록
+
+| Key | Value |
+|---|---|
+| `GITHUB_PAT` | GitHub Personal Access Token |
+| `GITHUB_OWNER` | GitHub 유저명 |
+| `GITHUB_REPOS` | 쉼표로 구분된 레포 이름 목록 |
+
+3. Deploy
+
+---
+
+## 오픈소스 전환 체크리스트
+
+### 보안
+- [ ] `.env.local`이 `.gitignore`에 포함되어 있는지 확인
+- [ ] `.env.example` 파일 생성 (실제 값 없이 키 이름만 포함)
+- [ ] git log에 PAT 등 민감한 정보가 없는지 확인 (`git log --all`)
+- [ ] `GITHUB_OWNER`, `GITHUB_REPOS` 환경 변수로만 제어되는지 확인
+
+### 코드 정리
+- [ ] 하드코딩된 개인 정보 제거 (이름, 이메일, URL 등)
+- [ ] 불필요한 `console.log` 제거
+- [ ] TypeScript 타입 오류 없는지 확인 (`npm run build`)
+- [ ] 사용하지 않는 패키지 제거 (`npm prune`)
+
+### 문서화
+- [ ] `README.md` 작성 (이 파일)
+- [ ] `.env.example` 작성
+- [ ] `CONTRIBUTING.md` 작성 (기여 방법 안내)
+- [ ] `LICENSE` 파일 추가 (MIT 권장)
+- [ ] 주요 컴포넌트에 JSDoc 주석 추가
+
+### GitHub 설정
+- [ ] 레포지토리 Public으로 전환
+- [ ] `Description` 및 `Topics` 태그 입력 (예: `nextjs`, `github-api`, `portfolio`)
+- [ ] Issue 템플릿 추가 (`.github/ISSUE_TEMPLATE/`)
+- [ ] PR 템플릿 추가 (`.github/pull_request_template.md`)
+- [ ] `About` 섹션에 데모 URL 입력
+
+### 선택 사항
+- [ ] 데모 사이트 배포 후 README에 링크 추가
+- [ ] 스크린샷 또는 GIF 데모 추가
+- [ ] GitHub Actions CI 설정 (빌드 자동 검증)
@@ -0,0 +1,39 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ darkMode: "class",
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./lib/**/*.{ts,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}