Add category view, pagination, and persist login across deployments

- Add 2-panel category view: sidebar tree + filtered item list
- Category counts use DISTINCT with descendant inclusion
- Hide empty categories, show category badges on item cards
- Add client-side pagination (10 items/page) for both views
- Persist access token in localStorage to survive page refresh
- Fix token refresh retry on backend restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 04:11:08 +00:00
parent f9f710ec90
commit 6c2129d42e
5 changed files with 515 additions and 49 deletions

View File

@@ -13,11 +13,26 @@ interface KnowledgeItem {
SOURCE_URL: string;
STATUS: string;
CREATED_AT: string;
CATEGORIES?: string;
}
interface Category {
ID: string;
NAME: string;
PARENT_ID: string | null;
DEPTH: number;
FULL_PATH: string;
ITEM_COUNT: number;
}
interface CategoryTree extends Category {
children: CategoryTree[];
}
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-500/20 text-yellow-400",
EXTRACTING: "bg-blue-500/20 text-blue-400",
STRUCTURING: "bg-orange-500/20 text-orange-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
@@ -31,10 +46,111 @@ const typeLabels: Record<string, string> = {
TEXT: "Text",
};
function buildTree(categories: Category[]): CategoryTree[] {
const map = new Map<string, CategoryTree>();
const roots: CategoryTree[] = [];
for (const cat of categories) {
map.set(cat.ID, { ...cat, children: [] });
}
for (const cat of categories) {
const node = map.get(cat.ID)!;
if (cat.PARENT_ID && map.has(cat.PARENT_ID)) {
map.get(cat.PARENT_ID)!.children.push(node);
} else {
roots.push(node);
}
}
// 항목이 0개인 카테고리 제거
function prune(nodes: CategoryTree[]): CategoryTree[] {
return nodes
.map((n) => ({ ...n, children: prune(n.children) }))
.filter((n) => n.ITEM_COUNT > 0 || n.children.length > 0);
}
return prune(roots);
}
// ITEM_COUNT는 백엔드에서 하위 카테고리 포함 고유 항목 수를 이미 계산함
function CategoryNode({
node,
selectedId,
onSelect,
expanded,
onToggle,
}: {
node: CategoryTree;
selectedId: string | null;
onSelect: (id: string, path: string) => void;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const isExpanded = expanded.has(node.ID);
const isSelected = selectedId === node.ID;
const hasChildren = node.children.length > 0;
const totalCount = node.ITEM_COUNT;
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${
isSelected
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
style={{ paddingLeft: `${(node.DEPTH - 1) * 16 + 8}px` }}
onClick={() => {
onSelect(node.ID, node.FULL_PATH);
if (hasChildren) onToggle(node.ID);
}}
>
<span className="w-4 text-center text-xs">
{hasChildren ? (isExpanded ? "▼" : "▶") : "·"}
</span>
<span className="flex-1 truncate">{node.NAME}</span>
{totalCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">
{totalCount}
</span>
)}
</div>
{isExpanded && hasChildren && (
<div>
{node.children.map((child) => (
<CategoryNode
key={child.ID}
node={child}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
export default function KnowledgePage() {
const { request } = useApi();
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<"list" | "category">("category");
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 10;
// 카테고리 뷰 상태
const [categoryTree, setCategoryTree] = useState<CategoryTree[]>([]);
const [uncategorized, setUncategorized] = useState<KnowledgeItem[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>("__all__");
const [selectedCategoryPath, setSelectedCategoryPath] = useState<string>("전체");
const [categoryItems, setCategoryItems] = useState<KnowledgeItem[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [catLoading, setCatLoading] = useState(false);
const fetchItems = async () => {
try {
@@ -47,19 +163,117 @@ export default function KnowledgePage() {
}
};
const fetchCategories = async () => {
try {
const data = await request<{ categories: Category[]; uncategorized: KnowledgeItem[] }>({
method: "GET",
url: "/api/knowledge/categories",
});
const tree = buildTree(data.categories);
setCategoryTree(tree);
setUncategorized(data.uncategorized);
// 최상위 카테고리를 펼침
setExpanded(new Set(tree.map((n) => n.ID)));
} catch (err) {
console.error("Failed to load categories:", err);
}
};
const fetchCategoryItems = async (categoryId: string) => {
setCatLoading(true);
try {
const data = await request<KnowledgeItem[]>({
method: "GET",
url: `/api/knowledge/categories/${categoryId}/items`,
});
setCategoryItems(data);
} catch (err) {
console.error("Failed to load category items:", err);
} finally {
setCatLoading(false);
}
};
const handleSelectCategory = (id: string, path: string) => {
setSelectedCategoryId(id);
setSelectedCategoryPath(path);
setCurrentPage(1);
fetchCategoryItems(id);
};
const handleSelectUncategorized = () => {
setSelectedCategoryId("__uncategorized__");
setSelectedCategoryPath("미분류");
setCategoryItems(uncategorized as KnowledgeItem[]);
setCurrentPage(1);
};
const handleSelectAll = () => {
setSelectedCategoryId("__all__");
setSelectedCategoryPath("전체");
setCategoryItems(items);
setCurrentPage(1);
};
const handleToggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
useEffect(() => {
fetchItems();
// Poll for status updates every 5 seconds
fetchCategories();
const interval = setInterval(fetchItems, 5000);
return () => clearInterval(interval);
}, []);
// 카테고리 뷰에서 표시할 항목 결정
const allDisplayItems =
viewMode === "list"
? items
: selectedCategoryId === "__all__"
? items
: selectedCategoryId === "__uncategorized__"
? uncategorized
: categoryItems;
const totalPages = Math.max(1, Math.ceil(allDisplayItems.length / PAGE_SIZE));
const displayItems = allDisplayItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Knowledge</h1>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Knowledge</h1>
<div className="flex gap-1 bg-[var(--color-bg-card)] rounded-lg p-0.5 border border-[var(--color-border)]">
<button
onClick={() => setViewMode("list")}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "list"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
<button
onClick={() => { setViewMode("category"); fetchCategories(); }}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
viewMode === "category"
? "bg-[var(--color-primary)] text-white"
: "text-[var(--color-text-muted)] hover:text-white"
}`}
>
</button>
</div>
</div>
<Link
href="/knowledge/add"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
@@ -70,44 +284,192 @@ export default function KnowledgePage() {
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : items.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
</div>
) : viewMode === "list" ? (
/* 목록 뷰 */
items.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
</div>
) : (
<>
<div className="space-y-3">
{displayItems.map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)
) : (
<div className="space-y-3">
{items.map((item) => (
<Link
key={item.ID}
href={`/knowledge/${item.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
/* 카테고리 뷰 — 2단 레이아웃 */
<div className="flex gap-4" style={{ minHeight: "60vh" }}>
{/* 왼쪽: 카테고리 트리 */}
<div className="w-64 shrink-0 bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] p-3 overflow-y-auto" style={{ maxHeight: "75vh" }}>
{/* 전체 보기 */}
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mb-1 ${
selectedCategoryId === "__all__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectAll}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
{typeLabels[item.TYPE] || item.TYPE}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
{item.STATUS}
</span>
</div>
<h3 className="font-medium truncate">
{item.TITLE || item.SOURCE_URL || "Untitled"}
</h3>
{item.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(item.CREATED_AT).toLocaleDateString()}
</span>
<span className="w-4 text-center text-xs">*</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{items.length}</span>
</div>
{/* 카테고리 트리 */}
{categoryTree.map((node) => (
<CategoryNode
key={node.ID}
node={node}
selectedId={selectedCategoryId}
onSelect={handleSelectCategory}
expanded={expanded}
onToggle={handleToggle}
/>
))}
{/* 미분류 */}
{uncategorized.length > 0 && (
<div
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mt-2 ${
selectedCategoryId === "__uncategorized__"
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={handleSelectUncategorized}
>
<span className="w-4 text-center text-xs">?</span>
<span className="flex-1"></span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{uncategorized.length}</span>
</div>
</Link>
))}
)}
</div>
{/* 오른쪽: 선택된 카테고리의 항목 목록 */}
<div className="flex-1 min-w-0">
{/* 브레드크럼 */}
<div className="text-sm text-[var(--color-text-muted)] mb-3">
{selectedCategoryPath.split("/").map((part, i, arr) => (
<span key={i}>
{i > 0 && <span className="mx-1">{">"}</span>}
<span className={i === arr.length - 1 ? "text-white" : ""}>{part}</span>
</span>
))}
</div>
{catLoading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : (displayItems as KnowledgeItem[]).length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">
{selectedCategoryId === "__all__"
? "등록된 지식이 없습니다."
: "이 카테고리에 항목이 없습니다."}
</p>
</div>
) : (
<>
<div className="space-y-3">
{(displayItems as KnowledgeItem[]).map((item) => (
<KnowledgeItemCard key={item.ID} item={item} />
))}
</div>
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
</>
)}
</div>
</div>
)}
</main>
</AuthGuard>
);
}
function KnowledgeItemCard({ item }: { item: KnowledgeItem }) {
return (
<Link
href={`/knowledge/${item.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
{typeLabels[item.TYPE] || item.TYPE}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
{item.STATUS}
</span>
</div>
<h3 className="font-medium truncate">
{item.TITLE || item.SOURCE_URL || "Untitled"}
</h3>
{item.CATEGORIES && (
<div className="flex flex-wrap gap-1 mt-1">
{item.CATEGORIES.split(", ").map((cat, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{cat}
</span>
))}
</div>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(item.CREATED_AT).toLocaleDateString()}
</span>
</div>
</Link>
);
}
function Pagination({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) {
const pages: number[] = [];
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, currentPage + 2);
for (let i = start; i <= end; i++) pages.push(i);
return (
<div className="flex items-center justify-center gap-1 mt-6">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
{start > 1 && (
<>
<button onClick={() => onPageChange(1)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">1</button>
{start > 2 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-1.5 text-sm rounded-lg ${
p === currentPage ? "bg-[var(--color-primary)] text-white" : "hover:bg-[var(--color-bg-hover)]"
}`}
>
{p}
</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
<button onClick={() => onPageChange(totalPages)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">{totalPages}</button>
</>
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
>
</button>
</div>
);
}

View File

@@ -22,21 +22,40 @@ const AuthContext = createContext<AuthContextType>({
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [accessToken, setAccessTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logoutRef = useRef<() => void>(() => {});
// localStorage와 동기화하는 setter
const setAccessToken = useCallback((token: string | null) => {
setAccessTokenState(token);
if (token) {
localStorage.setItem("accessToken", token);
} else {
localStorage.removeItem("accessToken");
}
}, []);
// interceptor 콜백 등록
useEffect(() => {
setAuthCallbacks(
(token: string) => setAccessToken(token),
() => logoutRef.current()
);
}, []);
}, [setAccessToken]);
useEffect(() => {
// Try to restore session from refresh token cookie
const tryRefresh = async () => {
const restoreSession = async () => {
// 1차: localStorage에서 복원
const stored = localStorage.getItem("accessToken");
if (stored) {
setAccessTokenState(stored);
api.defaults.headers.common["Authorization"] = `Bearer ${stored}`;
setIsLoading(false);
return;
}
// 2차: refresh token cookie로 복원
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
const token = res.data.accessToken;
@@ -48,8 +67,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoading(false);
}
};
tryRefresh();
}, []);
restoreSession();
}, [setAccessToken]);
useEffect(() => {
if (accessToken) {
@@ -61,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback((response: LoginResponse) => {
setAccessToken(response.accessToken);
}, []);
}, [setAccessToken]);
const logout = useCallback(async () => {
try {
@@ -71,7 +90,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
setAccessToken(null);
window.location.href = "/login";
}, []);
}, [setAccessToken]);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {