mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-10 17:43:35 +08:00
feat: enhance app descriptors with additional metadata and support for various file types
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web",
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { ImageViewerApp } from './ImageViewer.tsx';
|
||||
|
||||
const supportedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'image-viewer',
|
||||
name: '图片查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:image.svg',
|
||||
description: '内置图片查看器,支持常见图片与部分 RAW 格式预览。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: ImageViewerApp,
|
||||
default: true,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { OfficeViewerApp } from './OfficeViewer.tsx';
|
||||
|
||||
const supportedExts = ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'office-viewer',
|
||||
name: 'Office 文档查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
|
||||
description: '内置 Office 文档查看器,支持 Word/Excel/PowerPoint 文件预览。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'].includes(ext);
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: OfficeViewerApp,
|
||||
default: true,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { PdfViewerApp } from './PdfViewer';
|
||||
|
||||
const supportedExts = ['pdf'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'pdf-viewer',
|
||||
name: 'PDF 查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
|
||||
description: '内置 PDF 查看器。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ext === 'pdf';
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: PdfViewerApp,
|
||||
default: true,
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { TextEditorApp } from './TextEditor.tsx';
|
||||
|
||||
const supportedExts = [
|
||||
// Text formats
|
||||
'txt', 'md', 'markdown', 'log',
|
||||
// Data formats
|
||||
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
|
||||
// Web technologies
|
||||
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
|
||||
// Programming languages
|
||||
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
|
||||
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
|
||||
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
|
||||
// Database
|
||||
'sql',
|
||||
// Shell and scripts
|
||||
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
// Build and config files
|
||||
'dockerfile', 'makefile', 'gradle', 'cmake',
|
||||
// Other common text files
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
|
||||
];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'text-editor',
|
||||
name: '文本编辑器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
|
||||
description: '内置文本/代码编辑器,支持常见文本与代码格式。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
// Supports common text and code formats
|
||||
return [
|
||||
// Text formats
|
||||
'txt', 'md', 'markdown', 'log',
|
||||
// Data formats
|
||||
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
|
||||
// Web technologies
|
||||
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
|
||||
// Programming languages
|
||||
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
|
||||
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
|
||||
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
|
||||
// Database
|
||||
'sql',
|
||||
// Shell and scripts
|
||||
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
// Build and config files
|
||||
'dockerfile', 'makefile', 'gradle', 'cmake',
|
||||
// Other common text files
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
|
||||
].includes(ext);
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: TextEditorApp,
|
||||
default: true,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
|
||||
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'video-player',
|
||||
name: '视频播放器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||
description: '内置视频播放器,支持常见视频格式播放。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'].includes(ext);
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
|
||||
@@ -46,7 +46,15 @@ function registerPluginAsApp(p: PluginItem) {
|
||||
});
|
||||
}
|
||||
|
||||
loadApps();
|
||||
const appsLoadedPromise = loadApps();
|
||||
|
||||
export async function ensureAppsLoaded() {
|
||||
await appsLoadedPromise;
|
||||
}
|
||||
|
||||
export function listSystemApps(): AppDescriptor[] {
|
||||
return apps.filter(a => !a.key.startsWith('plugin:'));
|
||||
}
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter(a => a.supported(entry));
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface AppDescriptor {
|
||||
iconUrl?: string;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
description?: string;
|
||||
author?: string;
|
||||
supportedExts?: string[];
|
||||
website?: string;
|
||||
github?: string;
|
||||
/**
|
||||
* 应用窗口的默认位置与尺寸(非最大化时生效)
|
||||
* 任意字段缺省则按系统默认/级联偏移。
|
||||
|
||||
@@ -601,6 +601,7 @@
|
||||
"Please select a file": "Please select a file",
|
||||
"Installed successfully": "Installed successfully",
|
||||
"Plugin": "Plugin",
|
||||
"System App": "System App",
|
||||
"Open Link": "Open Link",
|
||||
"Link copied": "Link copied",
|
||||
"Copy Link": "Copy Link",
|
||||
@@ -659,4 +660,4 @@
|
||||
"Open with {app}": "Open with {app}",
|
||||
"Set as default for .{ext}": "Set as default for .{ext}",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,7 @@
|
||||
"Please select a file": "请选择一个文件",
|
||||
"Installed successfully": "安装成功",
|
||||
"Plugin": "插件",
|
||||
"System App": "系统应用",
|
||||
"Open Link": "打开链接",
|
||||
"Link copied": "已复制链接",
|
||||
"Copy Link": "复制链接",
|
||||
@@ -652,4 +653,4 @@
|
||||
"Open with {app}": "使用 {app} 打开",
|
||||
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm,
|
||||
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
|
||||
import { reloadPluginApps } from '../apps/registry';
|
||||
import { reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
const [data, setData] = useState<PluginItem[]>([]);
|
||||
const [systemApps, setSystemApps] = useState<AppDescriptor[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
@@ -30,6 +31,14 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => { reload(); }, []);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await ensureAppsLoaded();
|
||||
setSystemApps(listSystemApps());
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const installedKeySet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -83,6 +92,20 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
));
|
||||
}, [data, q]);
|
||||
|
||||
const filteredSystemApps = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return systemApps;
|
||||
return systemApps.filter(a => (
|
||||
(a.name || '').toLowerCase().includes(s)
|
||||
|| (a.author || '').toLowerCase().includes(s)
|
||||
|| (a.website || '').toLowerCase().includes(s)
|
||||
|| (a.github || '').toLowerCase().includes(s)
|
||||
|| (a.description || '').toLowerCase().includes(s)
|
||||
|| (a.supportedExts || []).some(e => e.toLowerCase().includes(s))
|
||||
|| (a.key || '').toLowerCase().includes(s)
|
||||
));
|
||||
}, [systemApps, q]);
|
||||
|
||||
const renderCard = (p: PluginItem) => {
|
||||
const icon = p.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = p.name || `${t('Plugin')} ${p.id}`;
|
||||
@@ -149,6 +172,73 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderSystemCard = (a: AppDescriptor) => {
|
||||
const icon = a.iconUrl || '/plugins/demo-text-viewer.svg';
|
||||
const name = a.name || a.key;
|
||||
const exts = (a.supportedExts || []).slice(0, 6);
|
||||
const more = (a.supportedExts || []).length - exts.length;
|
||||
const link = a.website || a.github || '';
|
||||
const title = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
|
||||
<span>{name}</span>
|
||||
<Tag style={{ marginLeft: 'auto' }}>{t('System App')}</Tag>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={`system:${a.key}`}
|
||||
title={title}
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 12 } } as any}
|
||||
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
|
||||
actions={[
|
||||
<Button
|
||||
key="open"
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!link}
|
||||
onClick={() => { if (link) window.open(link, '_blank', 'noreferrer'); }}
|
||||
>
|
||||
{t('Open Link')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!link}
|
||||
onClick={async () => {
|
||||
if (!link) return;
|
||||
try { await navigator.clipboard.writeText(link); message.success(t('Link copied')); } catch {}
|
||||
}}
|
||||
>
|
||||
{t('Copy Link')}
|
||||
</Button>,
|
||||
<Button key="del" type="link" danger size="small" disabled>{t('Delete')}</Button>
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph
|
||||
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{a.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
|
||||
</div>
|
||||
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
<span>{t('Author')}: {a.author || 'Foxel'}</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>{t('System App')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRepoCard = (item: RepoItem) => {
|
||||
const icon = item.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = item.name || item.key;
|
||||
@@ -277,10 +367,11 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : (filteredSystemApps.length + filtered.length) === 0 ? (
|
||||
<Empty description={t('No plugins')} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{filteredSystemApps.map(renderSystemCard)}
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user