Files
ragflow/web/src/pages/skills/index.tsx

1200 lines
42 KiB
TypeScript

import { BulkOperateBar } from '@/components/bulk-operate-bar';
import { CardContainer } from '@/components/card-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import ListFilterBar from '@/components/list-filter-bar';
import SvgIcon from '@/components/svg-icon';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchInput } from '@/components/ui/input';
import { Segmented } from '@/components/ui/segmented';
import { Spin } from '@/components/ui/spin';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Routes } from '@/routes';
import fileManagerService from '@/services/file-manager-service';
import { formatFileSize } from '@/utils/common-util';
import { formatDate } from '@/utils/date';
import {
Eye,
FolderOpen,
LayoutGrid,
List,
Pencil,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { CreateSpaceDialog } from './components/create-space-dialog';
import DeleteSelectedSpacesDialog from './components/delete-selected-spaces-dialog';
import DeleteSpaceDialog from './components/delete-space-dialog';
import RenameSpaceDialog from './components/rename-space-dialog';
import SearchConfigModal from './components/search-config-modal';
import SkillCard from './components/skill-card';
import SkillDetail from './components/skill-detail';
import UploadModal from './components/upload-modal';
import { useSkills, useSkillSearchConfig } from './hooks';
import type { Skill } from './types';
// Format relative time
const formatRelative = (timestamp: number): string => {
let normalized = timestamp;
if (normalized > 1e17) normalized = normalized / 1e6;
else if (normalized > 1e14) normalized = normalized / 1e3;
else if (normalized > 0 && normalized < 1e11) normalized = normalized * 1e3;
const diff = Date.now() - normalized;
if (diff < 0) return 'just now';
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years}y ago`;
};
const SkillsPage: React.FC = () => {
const { t } = useTranslation();
const { pathname } = useLocation();
const navigate = useNavigate();
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
const [spaceInput, setSpaceInput] = useState('');
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('');
const [selectedSpaceName, setSelectedSpaceName] = useState<string>('');
const [spaceLoading, setSpaceLoading] = useState(false);
const [spaceSearchString, setSpaceSearchString] = useState('');
const {
skills,
filteredSkills,
loading,
searchQuery,
setSearchQuery,
fetchSpaces,
createSpace,
deleteSpace,
updateSpace,
fetchSkills,
uploadSkill,
deleteSkill,
getSkillFileContent,
getSkillVersionFiles,
getSkillDetails,
} = useSkills();
const {
config,
configLoading,
saveConfig,
fetchConfig,
reindex,
searchSkills,
} = useSkillSearchConfig(selectedSpaceId);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [spaceViewMode, setSpaceViewMode] = useState<'grid' | 'list'>('grid');
const [detailOpen, setDetailOpen] = useState(false);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [createSpaceModalOpen, setCreateSpaceModalOpen] = useState(false);
const [deleteSpaceModalOpen, setDeleteSpaceModalOpen] = useState(false);
const [spaceToDelete, setSpaceToDelete] = useState<{
id: string;
name: string;
} | null>(null);
const [renameSpaceModalOpen, setRenameSpaceModalOpen] = useState(false);
const [spaceToRename, setSpaceToRename] = useState<{
id: string;
name: string;
} | null>(null);
const [renameSpaceInput, setRenameSpaceInput] = useState('');
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [spaceDetails, setSpaceDetails] = useState<
Record<string, { size: number; createTime: number }>
>({});
const [deleteSpacesModalOpen, setDeleteSpacesModalOpen] = useState(false);
const [searchResults, setSearchResults] = useState<Skill[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [skillDetailLoading, setSkillDetailLoading] = useState(false);
// Pagination and sorting state
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [totalSkills, setTotalSkills] = useState(0);
const [sortBy] = useState<'name' | 'update_time' | 'create_time'>(
'update_time',
);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Selection state derived values (must be declared before any functions that use them)
const selectedSpaceCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection],
);
const selectedSpaceIds = useMemo(
() => Object.keys(rowSelection),
[rowSelection],
);
const hasSelectedSpaces = selectedSpaceCount > 0;
const clearModalLocks = useCallback(() => {
setDetailOpen(false);
setUploadModalOpen(false);
setConfigModalOpen(false);
setSelectedSkill(null);
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
}, []);
useEffect(() => {
clearModalLocks();
}, [pathname, clearModalLocks]);
useEffect(() => {
return () => {
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
};
}, []);
const loadSpaces = useCallback(async () => {
setSpaceLoading(true);
setRowSelection({}); // Clear selection when loading new data
try {
const nextSpaces = await fetchSpaces();
setSpaces(nextSpaces);
// Fetch folder details for each space
const details: Record<string, { size: number; createTime: number }> = {};
for (const space of nextSpaces) {
if (space.folder_id) {
try {
const { data } = await fileManagerService.listFile({
parent_id: space.folder_id,
});
if (data.code === 0) {
const files = data.data?.files || [];
const totalSize = files.reduce(
(sum: number, f: any) => sum + (f.size || 0),
0,
);
details[space.id] = {
size: totalSize,
createTime: space.create_time || Date.now(),
};
}
} catch (e) {
console.warn('Failed to fetch space folder details:', e);
}
}
}
setSpaceDetails(details);
} finally {
setSpaceLoading(false);
}
}, [fetchSpaces]);
useEffect(() => {
loadSpaces();
// oxlint-disable-next-line react/exhaustive-deps
}, []);
// Function to load skills with pagination and sorting
const loadSkills = useCallback(async () => {
const result = await fetchSkills(
selectedSpaceName,
selectedSpaceId,
currentPage,
pageSize,
sortBy,
sortOrder,
);
setTotalSkills(result.total);
}, [
fetchSkills,
selectedSpaceName,
selectedSpaceId,
currentPage,
pageSize,
sortBy,
sortOrder,
]);
// Load skills when space changes or pagination/sorting changes
useEffect(() => {
if (!selectedSpaceId || !selectedSpaceName) return;
// Clear search results when switching spaces
setSearchResults([]);
setHasSearched(false);
setSearchQuery('');
setCurrentPage(1);
fetchConfig(undefined, selectedSpaceId);
// Use search API with pagination and sorting
loadSkills();
// oxlint-disable-next-line react/exhaustive-deps
}, [selectedSpaceId, selectedSpaceName]);
// Load skills when pagination or sorting changes
useEffect(() => {
if (!selectedSpaceId || !selectedSpaceName || hasSearched) return;
loadSkills();
// oxlint-disable-next-line react/exhaustive-deps
}, [currentPage, sortBy, sortOrder]);
const handleViewSkill = useCallback(
async (skill: Skill) => {
// If skill already has versions, use it directly
if (skill.versions && skill.versions.length > 0) {
setSelectedSkill(skill);
setDetailOpen(true);
return;
}
// Try to enrich skill data with versions from existing skills list
if (!(skill as any)._folderId || !skill.versions) {
const existingSkill = filteredSkills.find((s) => s.id === skill.id);
if (existingSkill) {
if ((existingSkill as any)._folderId) {
skill = {
...skill,
_folderId: (existingSkill as any)._folderId,
};
}
if (existingSkill.versions && existingSkill.versions.length > 0) {
skill = {
...skill,
versions: existingSkill.versions,
files: existingSkill.files,
};
}
}
}
// If still no versions but has folderId, fetch from file system
if (
(!skill.versions || skill.versions.length === 0) &&
(skill as any)._folderId
) {
setSkillDetailLoading(true);
try {
const detailedSkill = await getSkillDetails(
(skill as any)._folderId,
skill.name,
);
if (detailedSkill) {
skill = {
...skill,
versions: detailedSkill.versions,
files: detailedSkill.files,
metadata: {
...skill.metadata,
...detailedSkill.metadata,
},
};
}
} catch (error) {
console.warn('Failed to fetch skill details:', error);
} finally {
setSkillDetailLoading(false);
}
}
if (!(skill as any)._folderId) {
console.warn(
`[Skill Search] Skill "${skill.name}" has no folder_id. ` +
'Please reindex skills to fix this issue.',
);
}
setSelectedSkill(skill);
setDetailOpen(true);
},
[filteredSkills, getSkillDetails],
);
const handleCloseDetail = useCallback(() => {
setDetailOpen(false);
setSelectedSkill(null);
}, []);
const handleUpload = useCallback(
async (name: string, version: string, files: File[]) => {
// Pass space name (for file system), space ID (for indexing), and embd_id (for indexing)
return await uploadSkill(
name,
version,
files,
selectedSpaceName,
selectedSpaceId,
config?.embd_id,
);
},
[uploadSkill, selectedSpaceName, selectedSpaceId, config?.embd_id],
);
const handleDelete = useCallback(
async (skillId: string, skillName: string, folderId?: string) => {
// Pass both space ID (for index), space name (for file system), and folderId (for search results)
const success = await deleteSkill(
skillId,
skillName,
selectedSpaceId,
selectedSpaceName,
folderId,
);
// If delete succeeded and we have search results, remove the skill from searchResults
if (success) {
setSearchResults((prev) => prev.filter((s) => s.id !== skillId));
}
},
[deleteSkill, selectedSpaceId, selectedSpaceName],
);
const handleCreateHub = useCallback(async () => {
const nextHubName = spaceInput.trim();
if (!nextHubName) return;
const newHub = await createSpace(nextHubName);
if (!newHub) return;
setSpaceInput('');
setCreateSpaceModalOpen(false);
await loadSpaces();
// Select the newly created space
setSelectedSpaceId(newHub.id);
setSelectedSpaceName(newHub.name);
}, [spaceInput, createSpace, loadSpaces]);
const handleDeleteHub = useCallback(async () => {
if (!spaceToDelete) return;
const success = await deleteSpace(spaceToDelete.id);
if (success) {
setDeleteSpaceModalOpen(false);
setSpaceToDelete(null);
await loadSpaces();
}
}, [spaceToDelete, deleteSpace, loadSpaces]);
const openDeleteSpaceModal = useCallback(
(space: { id: string; name: string }, e: React.MouseEvent) => {
e.stopPropagation();
setSpaceToDelete(space);
setDeleteSpaceModalOpen(true);
},
[],
);
const openRenameSpaceModal = useCallback(
(space: { id: string; name: string }, e: React.MouseEvent) => {
e.stopPropagation();
setSpaceToRename(space);
setRenameSpaceInput(space.name);
setRenameSpaceModalOpen(true);
},
[],
);
const handleRenameHub = useCallback(async () => {
if (!spaceToRename || !renameSpaceInput.trim()) return;
const success = await updateSpace(
spaceToRename.id,
renameSpaceInput.trim(),
);
if (success) {
setRenameSpaceModalOpen(false);
setSpaceToRename(null);
setRenameSpaceInput('');
await loadSpaces();
// Update selected space name if it's the current space
if (selectedSpaceId === spaceToRename.id) {
setSelectedSpaceName(renameSpaceInput.trim());
}
}
}, [
spaceToRename,
renameSpaceInput,
updateSpace,
loadSpaces,
selectedSpaceId,
]);
const handleDeleteSelectedHubs = useCallback(async () => {
for (const hubId of selectedSpaceIds) {
await deleteSpace(hubId);
}
setDeleteSpacesModalOpen(false);
setRowSelection({});
await loadSpaces();
}, [selectedSpaceIds, deleteSpace, loadSpaces]);
const handleOpenDeleteSelectedModal = useCallback(() => {
setDeleteSpacesModalOpen(true);
}, []);
const handleSearch = useCallback(
async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults([]);
setHasSearched(false);
return;
}
setIsSearching(true);
setHasSearched(true);
try {
const results = await searchSkills(query, 1, 20);
if (results?.skills) {
const localSkillMap = new Map(skills.map((s) => [s.id, s]));
const localSkillNameMap = new Map(
skills.map((s) => [s.name.toLowerCase(), s]),
);
const mergedResults = results.skills.map((skill) => {
const localSkill =
localSkillMap.get(skill.id) ||
localSkillNameMap.get(skill.name.toLowerCase());
if (!localSkill) return skill;
return {
...skill,
created_at: localSkill.created_at,
updated_at: localSkill.updated_at,
_folderId:
(skill as any)._folderId || (localSkill as any)._folderId,
versions: localSkill.versions,
files: localSkill.files,
};
});
setSearchResults(mergedResults);
} else {
setSearchResults([]);
}
} catch (error) {
console.error('Search error:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
},
[searchSkills, setSearchQuery, skills],
);
const handleSearchInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
if (!value.trim()) {
setSearchResults([]);
setHasSearched(false);
}
},
[setSearchQuery],
);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch(searchQuery);
}
},
[handleSearch, searchQuery],
);
const handleHubSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSpaceSearchString(e.target.value);
},
[],
);
const filteredSpaces = useMemo(() => {
if (!spaceSearchString.trim()) return spaces;
return spaces.filter((space) =>
space.name.toLowerCase().includes(spaceSearchString.toLowerCase()),
);
}, [spaces, spaceSearchString]);
const displayedSkills = useMemo(() => {
// Server-side sorting is already applied via API, no need to sort here
return hasSearched ? searchResults : filteredSkills;
}, [hasSearched, searchResults, filteredSkills]);
const isLoading = loading || isSearching || configLoading;
// Space list breadcrumb: root / skills
const hubListBreadcrumb = (
<div className="flex items-center gap-2">
<span
className="text-text-secondary cursor-pointer hover:text-text-primary"
onClick={() => navigate(Routes.Files)}
>
root
</span>
<span className="text-text-secondary">/</span>
<span>{t('skills.title')}</span>
</div>
);
// Skills list breadcrumb: root / skills / {hubName}
const skillsListBreadcrumb = (
<div className="flex items-center gap-2">
<span
className="text-text-secondary cursor-pointer hover:text-text-primary"
onClick={() => navigate(Routes.Files)}
>
root
</span>
<span className="text-text-secondary">/</span>
<span
className="text-text-secondary cursor-pointer hover:text-text-primary"
onClick={() => {
setSelectedSpaceId('');
setSelectedSpaceName('');
setSearchResults([]);
setHasSearched(false);
setSearchQuery('');
fetchSkills(''); // Clear skills data
}}
>
{t('skills.title')}
</span>
<span className="text-text-secondary">/</span>
<span>{selectedSpaceName}</span>
</div>
);
// Space list page (no space selected)
if (!selectedSpaceId) {
return (
<>
<article
className="size-full flex flex-col"
data-testid="skill-space-list"
>
<header className="px-5 pt-8 mb-4">
<ListFilterBar
leftPanel={hubListBreadcrumb}
searchString={spaceSearchString}
onSearchChange={handleHubSearchChange}
showFilter={false}
icon="file"
>
<div className="flex items-center gap-2">
<Segmented
value={spaceViewMode}
onChange={(v) => setSpaceViewMode(v as 'grid' | 'list')}
options={[
{ value: 'grid', label: <LayoutGrid className="size-4" /> },
{ value: 'list', label: <List className="size-4" /> },
]}
/>
<Button onClick={() => setCreateSpaceModalOpen(true)}>
<Plus className="size-[1em]" />
{t('skills.createSpace') || 'Create Skill Space'}
</Button>
</div>
</ListFilterBar>
{hasSelectedSpaces && spaceViewMode === 'list' && (
<BulkOperateBar
className="mt-4"
count={selectedSpaceCount}
unit={t('skills.space') || 'spaces'}
list={[
{
id: 'delete',
label: t('common.delete'),
icon: <Trash2 className="size-4" />,
onClick: handleOpenDeleteSelectedModal,
},
]}
/>
)}
</header>
<div className="flex-1 px-5 flex flex-col overflow-hidden">
{spaceLoading ? (
<div className="flex-1 flex items-center justify-center">
<Spin size="large" />
</div>
) : filteredSpaces.length ? (
spaceViewMode === 'grid' ? (
<CardContainer className="flex-1 overflow-auto">
{filteredSpaces.map((space) => (
<Card
key={space.id}
className="group flex flex-col cursor-pointer bg-bg-card hover:border-accent-primary hover:shadow-md transition-all"
onClick={() => {
setSelectedSpaceId(space.id);
setSelectedSpaceName(space.name);
}}
>
<CardContent className="flex-1 flex flex-col p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 flex items-center gap-2">
<SvgIcon
name="home-icon/skill-space"
width={20}
height={20}
/>
<h3 className="font-semibold text-lg truncate">
{space.name}
</h3>
</div>
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
onClick={(e: React.MouseEvent) =>
openRenameSpaceModal(space, e)
}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-red-500"
onClick={(e: React.MouseEvent) =>
openDeleteSpaceModal(space, e)
}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</CardContent>
<CardFooter className="pt-0 pb-4 px-4">
<span className="text-accent-primary text-sm">
{t('skills.enterSpace') || 'Enter'}
</span>
</CardFooter>
</Card>
))}
</CardContainer>
) : (
<div className="flex-1 overflow-auto border border-border rounded-lg">
<Table>
<TableHeader className="bg-bg-title sticky top-0">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={
filteredSpaces.length > 0 &&
filteredSpaces.every(
(space) => rowSelection[space.id],
)
}
onCheckedChange={(checked) => {
const newSelection = { ...rowSelection };
filteredSpaces.forEach((space) => {
if (checked) {
newSelection[space.id] = true;
} else {
delete newSelection[space.id];
}
});
setRowSelection(newSelection);
}}
/>
</TableHead>
<TableHead className="w-[20vw]">
{t('skills.spaceName') || 'Name'}
</TableHead>
<TableHead className="w-[160px]">
{t('fileManager.uploadDate') || 'Upload Date'}
</TableHead>
<TableHead className="w-[96px]">
{t('fileManager.size') || 'Size'}
</TableHead>
<TableHead className="w-[96px] text-right">
{t('common.action') || 'Action'}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSpaces.map((space) => (
<TableRow
key={space.id}
className="hover:bg-bg-secondary/50 cursor-pointer transition-colors"
onClick={() => {
setSelectedSpaceId(space.id);
setSelectedSpaceName(space.name);
}}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={!!rowSelection[space.id]}
onCheckedChange={(checked) => {
setRowSelection((prev) => {
const newSelection = { ...prev };
if (checked) {
newSelection[space.id] = true;
} else {
delete newSelection[space.id];
}
return newSelection;
});
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 overflow-hidden">
<SvgIcon
name="home-icon/skill-space"
width={16}
height={16}
/>
<span className="font-medium truncate">
{space.name}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-text-secondary">
{spaceDetails[space.id]?.createTime
? formatDate(spaceDetails[space.id].createTime)
: '-'}
</TableCell>
<TableCell className="text-sm text-text-secondary">
{spaceDetails[space.id]?.size !== undefined
? formatFileSize(spaceDetails[space.id].size)
: '-'}
</TableCell>
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
onClick={(e: React.MouseEvent) =>
openRenameSpaceModal(space, e)
}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-red-500"
onClick={(e: React.MouseEvent) =>
openDeleteSpaceModal(space, e)
}
>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
) : (
<div className="flex-1 flex items-center justify-center">
{spaceSearchString ? (
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
isSearch
type={EmptyCardType.Skills}
/>
) : (
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Skills}
onClick={() => setCreateSpaceModalOpen(true)}
/>
)}
</div>
)}
</div>
</article>
{/* Create Space Modal */}
<CreateSpaceDialog
open={createSpaceModalOpen}
onOpenChange={setCreateSpaceModalOpen}
spaceInput={spaceInput}
onSpaceInputChange={setSpaceInput}
onCreate={handleCreateHub}
/>
{/* Delete Space Modal */}
<DeleteSpaceDialog
open={deleteSpaceModalOpen}
onOpenChange={(open) => {
setDeleteSpaceModalOpen(open);
if (!open) setSpaceToDelete(null);
}}
spaceToDelete={spaceToDelete}
onDelete={handleDeleteHub}
/>
{/* Rename Space Modal */}
<RenameSpaceDialog
open={renameSpaceModalOpen}
onOpenChange={(open) => {
setRenameSpaceModalOpen(open);
if (!open) {
setSpaceToRename(null);
setRenameSpaceInput('');
}
}}
spaceToRename={spaceToRename}
renameSpaceInput={renameSpaceInput}
onRenameInputChange={setRenameSpaceInput}
onRename={handleRenameHub}
/>
{/* Delete Selected Spaces Modal */}
<DeleteSelectedSpacesDialog
open={deleteSpacesModalOpen}
onOpenChange={setDeleteSpacesModalOpen}
selectedCount={selectedSpaceCount}
onDelete={handleDeleteSelectedHubs}
/>
</>
);
}
// Inside a space (skills list page)
return (
<article className="size-full flex flex-col" data-testid="skills-list">
<header className="px-5 pt-8 mb-4">
<ListFilterBar
leftPanel={skillsListBreadcrumb}
showFilter={false}
showSearch={false}
icon="file"
>
<div className="flex items-center gap-2">
{/* Search skills */}
<SearchInput
placeholder={t('skills.searchPlaceholder') || 'Search skills...'}
value={searchQuery}
onChange={handleSearchInputChange}
onKeyDown={handleSearchKeyDown}
className="w-[200px]"
rootClassName="relative"
/>
{/* Sort order toggle */}
<Button
variant="outline"
size="icon"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
title={
sortOrder === 'asc'
? t('skills.sortDesc') || 'Sort Descending'
: t('skills.sortAsc') || 'Sort Ascending'
}
>
{sortOrder === 'asc' ? (
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 5v14M5 12l7-7 7 7" />
</svg>
) : (
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 19V5M5 12l7 7 7-7" />
</svg>
)}
</Button>
{/* Grid/List toggle */}
<Segmented
value={viewMode}
onChange={(v) => setViewMode(v as 'grid' | 'list')}
options={[
{ value: 'grid', label: <LayoutGrid className="size-4" /> },
{ value: 'list', label: <List className="size-4" /> },
]}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setConfigModalOpen(true)}
disabled={loading}
>
<Settings className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('skills.configureSearch')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => loadSkills()}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.refresh')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button onClick={() => setUploadModalOpen(true)}>
<Plus className="mr-2" />
{t('skills.addSkill') || 'Add Skill'}
</Button>
</div>
</ListFilterBar>
</header>
<div className="flex-1 px-5 flex flex-col overflow-hidden">
{/* Skills List */}
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Spin size="large" />
</div>
) : displayedSkills.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-text-secondary">
<FolderOpen className="size-16 mb-4 opacity-50" />
{hasSearched ? (
<p>
{t('skills.noSearchResults') || 'No search results'}
:&nbsp;&quot;{searchQuery}&quot;
</p>
) : searchQuery ? (
<p>
{t('skills.noSearchResults') || 'No search results'}
:&nbsp;&quot;{searchQuery}&quot;
</p>
) : (
<div className="text-center">
<p className="mb-2">{t('skills.noSkills')}</p>
<button
className="text-accent-primary hover:underline"
onClick={() => setUploadModalOpen(true)}
>
{t('skills.addSkill') || 'Add Skill'}
</button>
</div>
)}
</div>
) : viewMode === 'grid' ? (
<CardContainer className="flex-1 overflow-auto">
{displayedSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
onView={handleViewSkill}
onDelete={handleDelete}
formatRelative={formatRelative}
/>
))}
</CardContainer>
) : (
<div className="flex-1 overflow-auto border border-border rounded-lg">
<table className="w-full" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: 'auto' }} />
<col style={{ width: '120px' }} />
<col style={{ width: '96px' }} />
</colgroup>
<thead className="bg-bg-title sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-text-title">
{t('skills.skillName') || 'Name'}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-text-title">
{t('skills.version') || 'Version'}
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-text-title">
{t('common.action') || 'Action'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{displayedSkills.map((skill) => (
<tr
key={skill.id}
className="hover:bg-bg-secondary/50 cursor-pointer transition-colors"
onClick={() => handleViewSkill(skill)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2 overflow-hidden">
<SvgIcon
name="home-icon/skill-folder"
width={16}
height={16}
/>
<span className="font-medium truncate">
{skill.name}
</span>
</div>
{skill.description && (
<p className="text-text-secondary text-xs mt-1 truncate">
{skill.description}
</p>
)}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{skill.metadata?.version || '-'}
</td>
<td
className="px-4 py-3 text-right"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleViewSkill(skill);
}}
>
<Eye className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-text-secondary hover:text-red-500"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleDelete(
skill.id,
skill.name,
(skill as any)._folderId,
);
}}
>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!hasSearched && totalSkills > 0 && (
<div className="flex items-center justify-between py-4 border-t border-border mt-4">
<div className="text-sm text-text-secondary">
{t('skills.totalSkills', { total: totalSkills })}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1 || loading}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
{t('common.previous')}
</Button>
<span className="text-sm text-text-secondary px-2">
{t('skills.pageInfo', {
current: currentPage,
total: Math.ceil(totalSkills / pageSize),
})}
</span>
<Button
variant="outline"
size="sm"
disabled={
currentPage >= Math.ceil(totalSkills / pageSize) || loading
}
onClick={() => setCurrentPage((p) => p + 1)}
>
{t('common.next')}
</Button>
</div>
</div>
)}
</div>
{/* Skill Detail Drawer */}
{detailOpen && selectedSkill && (
<SkillDetail
skill={selectedSkill}
open={detailOpen}
onClose={handleCloseDetail}
getFileContent={getSkillFileContent}
getVersionFiles={getSkillVersionFiles}
/>
)}
{/* Skill Detail Loading Overlay */}
{skillDetailLoading && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20">
<Spin size="large" />
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<UploadModal
open={uploadModalOpen}
onCancel={() => setUploadModalOpen(false)}
onUpload={handleUpload}
/>
)}
{/* Search Config Modal */}
{configModalOpen && (
<SearchConfigModal
open={configModalOpen}
onOpenChange={setConfigModalOpen}
config={config || undefined}
onSave={saveConfig}
onReindex={reindex}
loading={configLoading}
/>
)}
</article>
);
};
export default SkillsPage;