154 lines
5.5 KiB
TypeScript
154 lines
5.5 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Modal, Input, List, theme, Typography, Tag } from 'antd';
|
|
import { FileOutlined, PictureOutlined } from '@ant-design/icons';
|
|
import type { FileNode } from '@/types/api';
|
|
|
|
interface FlatFile {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
|
|
|
|
function isImage(name: string): boolean {
|
|
const dot = name.lastIndexOf('.');
|
|
return dot >= 0 && IMAGE_EXTENSIONS.has(name.substring(dot).toLowerCase());
|
|
}
|
|
|
|
function flattenFiles(nodes: FileNode[]): FlatFile[] {
|
|
const out: FlatFile[] = [];
|
|
for (const n of nodes) {
|
|
if (n.isDirectory) {
|
|
if (n.children) out.push(...flattenFiles(n.children));
|
|
} else {
|
|
out.push({ path: n.path, name: n.name });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface WikiLinkPickerModalProps {
|
|
open: boolean;
|
|
fileTree: FileNode[];
|
|
onSelect: (wikiLink: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiLinkPickerModalProps) {
|
|
const { token } = theme.useToken();
|
|
const [search, setSearch] = useState('');
|
|
|
|
const allFiles = useMemo(() => flattenFiles(fileTree), [fileTree]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search.trim()) return allFiles;
|
|
const q = search.toLowerCase();
|
|
return allFiles.filter(
|
|
f => f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
|
|
);
|
|
}, [allFiles, search]);
|
|
|
|
// Group: images first if searching for images, docs first otherwise
|
|
const docs = useMemo(() => filtered.filter(f => !isImage(f.name)), [filtered]);
|
|
const images = useMemo(() => filtered.filter(f => isImage(f.name)), [filtered]);
|
|
|
|
const handleSelect = (file: FlatFile) => {
|
|
const img = isImage(file.name);
|
|
const linkName = file.name.endsWith('.md') ? file.name.slice(0, -3) : file.name;
|
|
// For images, use ![[name]] syntax; for docs, use [[name]]
|
|
const wikiLink = img ? `![[${linkName}]]` : `[[${linkName}]]`;
|
|
onSelect(wikiLink);
|
|
setSearch('');
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
title="Insert Wiki Link"
|
|
open={open}
|
|
onCancel={() => { onClose(); setSearch(''); }}
|
|
footer={null}
|
|
destroyOnHidden
|
|
width={420}
|
|
>
|
|
<Input.Search
|
|
placeholder="Search files..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
allowClear
|
|
autoFocus
|
|
style={{ marginBottom: 12 }}
|
|
/>
|
|
|
|
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
|
{docs.length > 0 && (
|
|
<>
|
|
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, padding: '4px 0', display: 'block' }}>
|
|
Documents
|
|
</Typography.Text>
|
|
<List
|
|
size="small"
|
|
dataSource={docs.slice(0, 30)}
|
|
renderItem={item => (
|
|
<List.Item
|
|
style={{ padding: '8px 8px', cursor: 'pointer' }}
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, width: '100%' }}>
|
|
<FileOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.name.replace(/\.md$/, '')}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: token.colorTextTertiary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.path}
|
|
</div>
|
|
</div>
|
|
<Tag color="blue" style={{ marginInlineEnd: 0 }}>doc</Tag>
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{images.length > 0 && (
|
|
<>
|
|
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, padding: '4px 0', display: 'block' }}>
|
|
Images
|
|
</Typography.Text>
|
|
<List
|
|
size="small"
|
|
dataSource={images.slice(0, 20)}
|
|
renderItem={item => (
|
|
<List.Item
|
|
style={{ padding: '8px 8px', cursor: 'pointer' }}
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, width: '100%' }}>
|
|
<PictureOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.name}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: token.colorTextTertiary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.path}
|
|
</div>
|
|
</div>
|
|
<Tag color="green" style={{ marginInlineEnd: 0 }}>img</Tag>
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{docs.length === 0 && images.length === 0 && (
|
|
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextTertiary }}>
|
|
No files found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|