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;
|
package com.sundol.controller;
|
||||||
|
|
||||||
import com.sundol.dto.IngestRequest;
|
import com.sundol.dto.IngestRequest;
|
||||||
|
import com.sundol.repository.CategoryRepository;
|
||||||
import com.sundol.service.KnowledgeService;
|
import com.sundol.service.KnowledgeService;
|
||||||
import com.sundol.service.YouTubeTranscriptService;
|
import com.sundol.service.YouTubeTranscriptService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -22,11 +23,14 @@ public class KnowledgeController {
|
|||||||
|
|
||||||
private final KnowledgeService knowledgeService;
|
private final KnowledgeService knowledgeService;
|
||||||
private final YouTubeTranscriptService youTubeTranscriptService;
|
private final YouTubeTranscriptService youTubeTranscriptService;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
|
||||||
public KnowledgeController(KnowledgeService knowledgeService,
|
public KnowledgeController(KnowledgeService knowledgeService,
|
||||||
YouTubeTranscriptService youTubeTranscriptService) {
|
YouTubeTranscriptService youTubeTranscriptService,
|
||||||
|
CategoryRepository categoryRepository) {
|
||||||
this.knowledgeService = knowledgeService;
|
this.knowledgeService = knowledgeService;
|
||||||
this.youTubeTranscriptService = youTubeTranscriptService;
|
this.youTubeTranscriptService = youTubeTranscriptService;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -97,6 +101,27 @@ public class KnowledgeController {
|
|||||||
.map(ResponseEntity::ok);
|
.map(ResponseEntity::ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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")
|
@GetMapping("/{id}/chunks")
|
||||||
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
|
|||||||
@@ -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의 카테고리 매핑 전체 삭제
|
* knowledge_item의 카테고리 매핑 전체 삭제
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,24 +32,28 @@ public class KnowledgeRepository {
|
|||||||
|
|
||||||
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
|
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
|
||||||
StringBuilder sql = new StringBuilder(
|
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<>();
|
java.util.List<Object> params = new java.util.ArrayList<>();
|
||||||
params.add(userId);
|
params.add(userId);
|
||||||
|
|
||||||
if (type != null && !type.isEmpty()) {
|
if (type != null && !type.isEmpty()) {
|
||||||
sql.append(" AND type = ?");
|
sql.append(" AND ki.type = ?");
|
||||||
params.add(type);
|
params.add(type);
|
||||||
}
|
}
|
||||||
if (status != null && !status.isEmpty()) {
|
if (status != null && !status.isEmpty()) {
|
||||||
sql.append(" AND status = ?");
|
sql.append(" AND ki.status = ?");
|
||||||
params.add(status);
|
params.add(status);
|
||||||
}
|
}
|
||||||
if (search != null && !search.isEmpty()) {
|
if (search != null && !search.isEmpty()) {
|
||||||
sql.append(" AND UPPER(title) LIKE UPPER(?)");
|
sql.append(" AND UPPER(ki.title) LIKE UPPER(?)");
|
||||||
params.add("%" + search + "%");
|
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());
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,26 @@ interface KnowledgeItem {
|
|||||||
SOURCE_URL: string;
|
SOURCE_URL: string;
|
||||||
STATUS: string;
|
STATUS: string;
|
||||||
CREATED_AT: 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> = {
|
const statusColors: Record<string, string> = {
|
||||||
PENDING: "bg-yellow-500/20 text-yellow-400",
|
PENDING: "bg-yellow-500/20 text-yellow-400",
|
||||||
EXTRACTING: "bg-blue-500/20 text-blue-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",
|
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||||
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||||
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||||
@@ -31,10 +46,111 @@ const typeLabels: Record<string, string> = {
|
|||||||
TEXT: "Text",
|
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() {
|
export default function KnowledgePage() {
|
||||||
const { request } = useApi();
|
const { request } = useApi();
|
||||||
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const fetchItems = async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
// Poll for status updates every 5 seconds
|
fetchCategories();
|
||||||
const interval = setInterval(fetchItems, 5000);
|
const interval = setInterval(fetchItems, 5000);
|
||||||
return () => clearInterval(interval);
|
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 (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">Knowledge</h1>
|
<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
|
<Link
|
||||||
href="/knowledge/add"
|
href="/knowledge/add"
|
||||||
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
|
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 ? (
|
{loading ? (
|
||||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
<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)]">
|
<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>
|
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="space-y-3">
|
<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
|
<Link
|
||||||
key={item.ID}
|
|
||||||
href={`/knowledge/${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"
|
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">
|
<h3 className="font-medium truncate">
|
||||||
{item.TITLE || item.SOURCE_URL || "Untitled"}
|
{item.TITLE || item.SOURCE_URL || "Untitled"}
|
||||||
</h3>
|
</h3>
|
||||||
{item.SOURCE_URL && (
|
{item.CATEGORIES && (
|
||||||
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
|
<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>
|
</div>
|
||||||
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
|
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
|
||||||
@@ -104,10 +422,54 @@ export default function KnowledgePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
</main>
|
function Pagination({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) {
|
||||||
</AuthGuard>
|
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 }) {
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const logoutRef = useRef<() => void>(() => {});
|
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 콜백 등록
|
// interceptor 콜백 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAuthCallbacks(
|
setAuthCallbacks(
|
||||||
(token: string) => setAccessToken(token),
|
(token: string) => setAccessToken(token),
|
||||||
() => logoutRef.current()
|
() => logoutRef.current()
|
||||||
);
|
);
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to restore session from refresh token cookie
|
const restoreSession = async () => {
|
||||||
const tryRefresh = 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 {
|
try {
|
||||||
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
||||||
const token = res.data.accessToken;
|
const token = res.data.accessToken;
|
||||||
@@ -48,8 +67,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tryRefresh();
|
restoreSession();
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
@@ -61,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback((response: LoginResponse) => {
|
const login = useCallback((response: LoginResponse) => {
|
||||||
setAccessToken(response.accessToken);
|
setAccessToken(response.accessToken);
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +90,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
// ref로 최신 logout 유지 (interceptor에서 사용)
|
// ref로 최신 logout 유지 (interceptor에서 사용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user