mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-01 05:30:31 +08:00
feat(ui): add path selector modal
This commit is contained in:
143
web/src/components/PathSelectorModal.tsx
Normal file
143
web/src/components/PathSelectorModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../api/client';
|
||||
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||
|
||||
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||
|
||||
interface PathSelectorModalProps {
|
||||
open: boolean;
|
||||
mode?: PathSelectorMode;
|
||||
initialPath?: string;
|
||||
onOk: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
const s = ('/' + p).replace(/\/+/, '/');
|
||||
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const base = normalizePath(dir);
|
||||
if (base === '/') return `/${name}`;
|
||||
return `${base}/${name}`.replace(/\/+/, '/');
|
||||
}
|
||||
|
||||
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (mode === 'file') return t('Select File');
|
||||
if (mode === 'any') return t('Select Path');
|
||||
return t('Select Folder');
|
||||
}, [mode, t]);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||
setEntries(listing.entries);
|
||||
setPath(listing.path || p);
|
||||
setSelected(null);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(normalizePath(initialPath));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialPath]);
|
||||
|
||||
const canOk = useMemo(() => {
|
||||
if (mode === 'file') return !!selected;
|
||||
return true;
|
||||
}, [mode, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (mode === 'directory') {
|
||||
onOk(normalizePath(path));
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!selected) {
|
||||
message.warning(t('Please select a file'));
|
||||
return;
|
||||
}
|
||||
onOk(joinPath(path, selected));
|
||||
return;
|
||||
}
|
||||
// any
|
||||
if (selected) onOk(joinPath(path, selected));
|
||||
else onOk(normalizePath(path));
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const cur = normalizePath(path);
|
||||
if (cur === '/') return;
|
||||
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
load(parent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !canOk }}
|
||||
width={720}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||
<Input value={path} readOnly />
|
||||
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||
{mode !== 'file' && (
|
||||
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
bordered
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = selected === item.name && !item.is_dir;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
if (item.is_dir) {
|
||||
load(joinPath(path, item.name));
|
||||
} else {
|
||||
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||
>
|
||||
<Space>
|
||||
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default PathSelectorModal;
|
||||
|
||||
@@ -312,6 +312,16 @@ export const en = {
|
||||
'Processing finished': 'Processing finished',
|
||||
'Processing failed': 'Processing failed',
|
||||
|
||||
// Path selector
|
||||
'Select File': 'Select File',
|
||||
'Select Path': 'Select Path',
|
||||
'Select Folder': 'Select Folder',
|
||||
'Select': 'Select',
|
||||
'Current': 'Current',
|
||||
'Up': 'Up',
|
||||
'Select Current Folder': 'Select Current Folder',
|
||||
'Please select a file': 'Please select a file',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': 'Installed successfully',
|
||||
'Plugin': 'Plugin',
|
||||
|
||||
@@ -314,6 +314,16 @@ export const zh = {
|
||||
'Processing finished': '处理完成',
|
||||
'Processing failed': '处理失败',
|
||||
|
||||
// Path selector
|
||||
'Select File': '选择文件',
|
||||
'Select Path': '选择路径',
|
||||
'Select Folder': '选择目录',
|
||||
'Select': '选择',
|
||||
'Current': '当前',
|
||||
'Up': '上一级',
|
||||
'Select Current Folder': '选择当前目录',
|
||||
'Please select a file': '请选择一个文件',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': '安装成功',
|
||||
'Plugin': '插件',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
import { useI18n } from '../i18n';
|
||||
import PathSelectorModal from '../components/PathSelectorModal';
|
||||
|
||||
const TasksPage = memo(function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -17,6 +18,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
||||
const [queueLoading, setQueueLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const [pathPickerOpen, setPathPickerOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -151,6 +153,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
|
||||
const selectedProcessor = Form.useWatch('processor_type', form);
|
||||
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
|
||||
const watchedPathPattern = Form.useWatch('path_pattern', form);
|
||||
|
||||
|
||||
return (
|
||||
@@ -197,7 +200,10 @@ const TasksPage = memo(function TasksPage() {
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
|
||||
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
|
||||
<Input placeholder="/images/screenshots" />
|
||||
<Input
|
||||
placeholder="/images/screenshots"
|
||||
addonAfter={<Button size="small" onClick={() => setPathPickerOpen(true)}>{t('Select')}</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
|
||||
<Input placeholder=".*\.png$" />
|
||||
@@ -219,6 +225,13 @@ const TasksPage = memo(function TasksPage() {
|
||||
/>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<PathSelectorModal
|
||||
open={pathPickerOpen}
|
||||
mode="directory"
|
||||
initialPath={watchedPathPattern || '/'}
|
||||
onCancel={() => setPathPickerOpen(false)}
|
||||
onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }}
|
||||
/>
|
||||
<Modal
|
||||
title={t('Current Task Queue')}
|
||||
open={queueModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user