changemaker.lite/admin/src/components/docs/WikiLinkPickerModal.tsx

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>
);
}