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:
@@ -1,6 +1,7 @@
|
||||
package com.sundol.controller;
|
||||
|
||||
import com.sundol.dto.IngestRequest;
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.service.KnowledgeService;
|
||||
import com.sundol.service.YouTubeTranscriptService;
|
||||
import org.slf4j.Logger;
|
||||
@@ -22,11 +23,14 @@ public class KnowledgeController {
|
||||
|
||||
private final KnowledgeService knowledgeService;
|
||||
private final YouTubeTranscriptService youTubeTranscriptService;
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
public KnowledgeController(KnowledgeService knowledgeService,
|
||||
YouTubeTranscriptService youTubeTranscriptService) {
|
||||
YouTubeTranscriptService youTubeTranscriptService,
|
||||
CategoryRepository categoryRepository) {
|
||||
this.knowledgeService = knowledgeService;
|
||||
this.youTubeTranscriptService = youTubeTranscriptService;
|
||||
this.categoryRepository = categoryRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -97,7 +101,28 @@ public class KnowledgeController {
|
||||
.map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/chunks")
|
||||
@GetMapping("/categories")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> categories(@AuthenticationPrincipal String userId) {
|
||||
return Mono.fromCallable(() -> {
|
||||
var categories = categoryRepository.findAllByUserWithCount(userId);
|
||||
var uncategorized = categoryRepository.findUncategorizedItems(userId);
|
||||
return ResponseEntity.ok(Map.<String, Object>of(
|
||||
"categories", categories,
|
||||
"uncategorized", uncategorized
|
||||
));
|
||||
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
@GetMapping("/categories/{categoryId}/items")
|
||||
public Mono<ResponseEntity<List<Map<String, Object>>>> itemsByCategory(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String categoryId) {
|
||||
return Mono.fromCallable(() -> categoryRepository.findItemsByCategoryId(userId, categoryId))
|
||||
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
|
||||
.map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/chunks")
|
||||
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@PathVariable String id) {
|
||||
|
||||
@@ -110,6 +110,62 @@ public class CategoryRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 + 해당 카테고리 및 하위 카테고리에 속한 고유 항목 수 조회
|
||||
*/
|
||||
public List<Map<String, Object>> findAllByUserWithCount(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(c.id) AS id, c.name, RAWTOHEX(c.parent_id) AS parent_id, c.depth, c.full_path, " +
|
||||
" (SELECT COUNT(DISTINCT kic.knowledge_item_id) " +
|
||||
" FROM knowledge_item_categories kic " +
|
||||
" JOIN categories c2 ON c2.id = kic.category_id " +
|
||||
" WHERE c2.user_id = HEXTORAW(?) AND (c2.full_path = c.full_path OR c2.full_path LIKE c.full_path || '/%')) AS item_count " +
|
||||
"FROM categories c WHERE c.user_id = HEXTORAW(?) ORDER BY c.full_path",
|
||||
userId, userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 카테고리 및 하위 카테고리에 속한 knowledge_items 조회.
|
||||
* full_path LIKE 'parent_path%' 로 하위 카테고리를 포함한다.
|
||||
*/
|
||||
public List<Map<String, Object>> findItemsByCategoryId(String userId, String categoryId) {
|
||||
// 먼저 선택된 카테고리의 full_path를 가져옴
|
||||
var catResult = jdbcTemplate.queryForList(
|
||||
"SELECT full_path FROM categories WHERE RAWTOHEX(id) = ?", categoryId
|
||||
);
|
||||
if (catResult.isEmpty()) return List.of();
|
||||
String fullPath = (String) catResult.get(0).get("FULL_PATH");
|
||||
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
|
||||
" (SELECT LISTAGG(c2.full_path, ', ') WITHIN GROUP (ORDER BY c2.full_path) " +
|
||||
" FROM knowledge_item_categories kic2 JOIN categories c2 ON c2.id = kic2.category_id " +
|
||||
" WHERE kic2.knowledge_item_id = ki.id) AS categories " +
|
||||
"FROM knowledge_items ki " +
|
||||
"JOIN knowledge_item_categories kic ON kic.knowledge_item_id = ki.id " +
|
||||
"JOIN categories c ON c.id = kic.category_id " +
|
||||
"WHERE ki.user_id = HEXTORAW(?) AND (c.full_path = ? OR c.full_path LIKE ?) " +
|
||||
"ORDER BY ki.created_at DESC",
|
||||
userId, fullPath, fullPath + "/%"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리가 없는 knowledge_items 조회
|
||||
*/
|
||||
public List<Map<String, Object>> findUncategorizedItems(String userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
|
||||
" NULL AS categories " +
|
||||
"FROM knowledge_items ki " +
|
||||
"WHERE ki.user_id = HEXTORAW(?) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM knowledge_item_categories kic WHERE kic.knowledge_item_id = ki.id) " +
|
||||
"ORDER BY ki.created_at DESC",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* knowledge_item의 카테고리 매핑 전체 삭제
|
||||
*/
|
||||
|
||||
@@ -32,24 +32,28 @@ public class KnowledgeRepository {
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)"
|
||||
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, ki.updated_at, " +
|
||||
" (SELECT LISTAGG(c.full_path, ', ') WITHIN GROUP (ORDER BY c.full_path) " +
|
||||
" FROM knowledge_item_categories kic JOIN categories c ON c.id = kic.category_id " +
|
||||
" WHERE kic.knowledge_item_id = ki.id) AS categories " +
|
||||
"FROM knowledge_items ki WHERE ki.user_id = HEXTORAW(?)"
|
||||
);
|
||||
java.util.List<Object> params = new java.util.ArrayList<>();
|
||||
params.add(userId);
|
||||
|
||||
if (type != null && !type.isEmpty()) {
|
||||
sql.append(" AND type = ?");
|
||||
sql.append(" AND ki.type = ?");
|
||||
params.add(type);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
sql.append(" AND status = ?");
|
||||
sql.append(" AND ki.status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (search != null && !search.isEmpty()) {
|
||||
sql.append(" AND UPPER(title) LIKE UPPER(?)");
|
||||
sql.append(" AND UPPER(ki.title) LIKE UPPER(?)");
|
||||
params.add("%" + search + "%");
|
||||
}
|
||||
sql.append(" ORDER BY created_at DESC");
|
||||
sql.append(" ORDER BY ki.created_at DESC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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,15 +284,113 @@ export default function KnowledgePage() {
|
||||
|
||||
{loading ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : items.length === 0 ? (
|
||||
) : 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">
|
||||
{items.map((item) => (
|
||||
{displayItems.map((item) => (
|
||||
<KnowledgeItemCard key={item.ID} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
/* 카테고리 뷰 — 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}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
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"
|
||||
>
|
||||
@@ -95,8 +407,14 @@ export default function KnowledgePage() {
|
||||
<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>
|
||||
{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">
|
||||
@@ -104,10 +422,54 @@ export default function KnowledgePage() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user