mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 00:36:03 +00:00
perf: add component list in plugin detail dialog
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { PluginComponent } from '@/app/infra/entities/plugin';
|
||||
|
||||
export interface IPluginCardVO {
|
||||
author: string;
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export class PluginCardVO implements IPluginCardVO {
|
||||
author: string;
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
debug: boolean;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
|
||||
constructor(prop: IPluginCardVO) {
|
||||
this.author = prop.author;
|
||||
this.label = prop.label;
|
||||
this.description = prop.description;
|
||||
this.enabled = prop.enabled;
|
||||
this.components = prop.components;
|
||||
this.name = prop.name;
|
||||
this.priority = prop.priority;
|
||||
this.status = prop.status;
|
||||
this.version = prop.version;
|
||||
this.debug = prop.debug;
|
||||
this.install_source = prop.install_source;
|
||||
this.install_info = prop.install_info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PluginComponent } from '@/app/infra/entities/plugin';
|
||||
import { TFunction } from 'i18next';
|
||||
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function PluginComponentList({
|
||||
components,
|
||||
showComponentName,
|
||||
showTitle,
|
||||
useBadge,
|
||||
t,
|
||||
}: {
|
||||
components: PluginComponent[];
|
||||
showComponentName: boolean;
|
||||
showTitle: boolean;
|
||||
useBadge: boolean;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const componentKindCount: Record<string, number> = {};
|
||||
|
||||
for (const component of components) {
|
||||
const kind = component.manifest.manifest.kind;
|
||||
if (componentKindCount[kind]) {
|
||||
componentKindCount[kind]++;
|
||||
} else {
|
||||
componentKindCount[kind] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const kindIconMap: Record<string, React.ReactNode> = {
|
||||
Tool: <Wrench className="w-5 h-5" />,
|
||||
EventListener: <AudioWaveform className="w-5 h-5" />,
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(componentKindCount);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTitle && <div>{t('plugins.componentsList')}</div>}
|
||||
{componentKindList.length > 0 && (
|
||||
<>
|
||||
{componentKindList.map((kind) => {
|
||||
return (
|
||||
<>
|
||||
{useBadge && (
|
||||
<Badge variant="outline">
|
||||
{kindIconMap[kind]}
|
||||
{showComponentName &&
|
||||
t('plugins.componentName.' + kind) + ' '}
|
||||
{componentKindCount[kind]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{!useBadge && (
|
||||
<div
|
||||
key={kind}
|
||||
className="flex flex-row items-center justify-start gap-[0.2rem]"
|
||||
>
|
||||
{kindIconMap[kind]}
|
||||
{showComponentName &&
|
||||
t('plugins.componentName.' + kind) + ' '}
|
||||
{componentKindCount[kind]}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
||||
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
|
||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
|
||||
export interface PluginInstalledComponentRef {
|
||||
refreshPluginList: () => void;
|
||||
}
|
||||
|
||||
enum PluginOperationType {
|
||||
DELETE = 'DELETE',
|
||||
UPDATE = 'UPDATE',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
);
|
||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||
PluginOperationType.DELETE,
|
||||
);
|
||||
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
|
||||
|
||||
const asyncTask = useAsyncTask({
|
||||
onSuccess: () => {
|
||||
const successMessage =
|
||||
operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteSuccess')
|
||||
: t('plugins.updateSuccess');
|
||||
toast.success(successMessage);
|
||||
setShowOperationModal(false);
|
||||
getPluginList();
|
||||
},
|
||||
onError: () => {
|
||||
// Error is already handled in the hook state
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initData();
|
||||
}, []);
|
||||
|
||||
function initData() {
|
||||
getPluginList();
|
||||
}
|
||||
|
||||
function getPluginList() {
|
||||
httpClient.getPlugins().then((value) => {
|
||||
setPluginList(
|
||||
value.plugins.map((plugin) => {
|
||||
return new PluginCardVO({
|
||||
author: plugin.manifest.manifest.metadata.author ?? '',
|
||||
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
|
||||
description: extractI18nObject(
|
||||
plugin.manifest.manifest.metadata.description ?? {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
),
|
||||
debug: plugin.debug,
|
||||
enabled: plugin.enabled,
|
||||
name: plugin.manifest.manifest.metadata.name,
|
||||
version: plugin.manifest.manifest.metadata.version ?? '',
|
||||
status: plugin.status,
|
||||
components: plugin.components,
|
||||
priority: plugin.priority,
|
||||
install_source: plugin.install_source,
|
||||
install_info: plugin.install_info,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshPluginList: getPluginList,
|
||||
}));
|
||||
|
||||
function handlePluginClick(plugin: PluginCardVO) {
|
||||
setSelectedPlugin(plugin);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handlePluginDelete(plugin: PluginCardVO) {
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.DELETE);
|
||||
setShowOperationModal(true);
|
||||
asyncTask.reset();
|
||||
}
|
||||
|
||||
function handlePluginUpdate(plugin: PluginCardVO) {
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.UPDATE);
|
||||
setShowOperationModal(true);
|
||||
asyncTask.reset();
|
||||
}
|
||||
|
||||
function executeOperation() {
|
||||
if (!targetPlugin) return;
|
||||
|
||||
const apiCall =
|
||||
operationType === PluginOperationType.DELETE
|
||||
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
|
||||
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
|
||||
|
||||
apiCall
|
||||
.then((res) => {
|
||||
asyncTask.startTask(res.task_id);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteError') + error.message
|
||||
: t('plugins.updateError') + error.message;
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={showOperationModal}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowOperationModal(false);
|
||||
setTargetPlugin(null);
|
||||
asyncTask.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteConfirm')
|
||||
: t('plugins.updateConfirm')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDeletePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})
|
||||
: t('plugins.confirmUpdatePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||
<div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleting')
|
||||
: t('plugins.updating')}
|
||||
</div>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||
<div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteError')
|
||||
: t('plugins.updateError')}
|
||||
<div className="text-red-500">{asyncTask.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowOperationModal(false);
|
||||
setTargetPlugin(null);
|
||||
asyncTask.reset();
|
||||
}}
|
||||
>
|
||||
{t('plugins.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<Button
|
||||
variant={
|
||||
operationType === PluginOperationType.DELETE
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
onClick={() => {
|
||||
executeOperation();
|
||||
}}
|
||||
>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDelete')
|
||||
: t('plugins.confirmUpdate')}
|
||||
</Button>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||
<Button
|
||||
variant={
|
||||
operationType === PluginOperationType.DELETE
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
disabled
|
||||
>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleting')
|
||||
: t('plugins.updating')}
|
||||
</Button>
|
||||
)}
|
||||
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setShowOperationModal(false);
|
||||
asyncTask.reset();
|
||||
}}
|
||||
>
|
||||
{t('plugins.close')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pluginList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
|
||||
<svg
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H20C20.5523 5 21 5.44772 21 6V10.1707C21 10.4953 20.8424 10.7997 20.5774 10.9872C20.3123 11.1746 19.9728 11.2217 19.6668 11.1135C19.4595 11.0403 19.2355 11 19 11C17.8954 11 17 11.8954 17 13C17 14.1046 17.8954 15 19 15C19.2355 15 19.4595 14.9597 19.6668 14.8865C19.9728 14.7783 20.3123 14.8254 20.5774 15.0128C20.8424 15.2003 21 15.5047 21 15.8293V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H19V17C16.7909 17 15 15.2091 15 13C15 10.7909 16.7909 9 19 9V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
|
||||
</svg>
|
||||
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
{selectedPlugin && (
|
||||
<PluginForm
|
||||
pluginAuthor={selectedPlugin.author}
|
||||
pluginName={selectedPlugin.name}
|
||||
onFormSubmit={(timeout?: number) => {
|
||||
setModalOpen(false);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
getPluginList();
|
||||
}, timeout);
|
||||
} else {
|
||||
getPluginList();
|
||||
}
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pluginList.map((vo, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<PluginCardComponent
|
||||
cardVO={vo}
|
||||
onCardClick={() => handlePluginClick(vo)}
|
||||
onDeleteClick={() => handlePluginDelete(vo)}
|
||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default PluginInstalledComponent;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
|
||||
export default function PluginCardComponent({
|
||||
cardVO,
|
||||
onCardClick,
|
||||
onDeleteClick,
|
||||
onUpgradeClick,
|
||||
}: {
|
||||
cardVO: PluginCardVO;
|
||||
onCardClick: () => void;
|
||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22]"
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* <svg
|
||||
className="w-16 h-16 text-[#2288ee]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 4C8 2.34315 9.34315 1 11 1C12.6569 1 14 2.34315 14 4C14 4.35064 13.9398 4.68722 13.8293 5H18C18.5523 5 19 5.44772 19 6V10.1707C19.3128 10.0602 19.6494 10 20 10C21.6569 10 23 11.3431 23 13C23 14.6569 21.6569 16 20 16C19.6494 16 19.3128 15.9398 19 15.8293V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H8.17071C8.06015 4.68722 8 4.35064 8 4Z"></path>
|
||||
</svg> */}
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
|
||||
{cardVO.author} / {cardVO.name}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
v{cardVO.version}
|
||||
</Badge>
|
||||
{cardVO.debug && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-orange-400 text-orange-400"
|
||||
>
|
||||
<BugIcon className="w-4 h-4" />
|
||||
{t('plugins.debugging')}
|
||||
</Badge>
|
||||
)}
|
||||
{!cardVO.debug && (
|
||||
<>
|
||||
{cardVO.install_source === 'github' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-blue-400 text-blue-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
cardVO.install_info.github_url,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('plugins.fromGithub')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'local' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-green-400 text-green-400"
|
||||
>
|
||||
{t('plugins.fromLocal')}
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'marketplace' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-purple-400 text-purple-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
getCloudServiceClientSync().getPluginMarketplaceURL(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('plugins.fromMarketplace')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999]">
|
||||
{cardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||
<PluginComponentList
|
||||
components={cardVO.components}
|
||||
showComponentName={false}
|
||||
showTitle={true}
|
||||
useBadge={false}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between h-full">
|
||||
<div className="flex items-center justify-center"></div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Ellipsis className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{/**upgrade */}
|
||||
{cardVO.install_source === 'marketplace' && (
|
||||
<DropdownMenuItem
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpgradeClick(cardVO);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
<span>{t('plugins.update')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteClick(cardVO);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
<span>{t('plugins.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
|
||||
export default function PluginForm({
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
}: {
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
onFormSubmit: (timeout?: number) => void;
|
||||
onFormCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
||||
const [isSaving, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 获取插件信息
|
||||
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
|
||||
setPluginInfo(res.plugin);
|
||||
});
|
||||
// 获取插件配置
|
||||
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
||||
setPluginConfig(res);
|
||||
});
|
||||
}, [pluginAuthor, pluginName]);
|
||||
|
||||
const handleSubmit = async (values: object) => {
|
||||
setIsLoading(true);
|
||||
const isDebugPlugin = pluginInfo?.debug;
|
||||
httpClient
|
||||
.updatePluginConfig(pluginAuthor, pluginName, values)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
isDebugPlugin
|
||||
? t('plugins.saveConfigSuccessDebugPlugin')
|
||||
: t('plugins.saveConfigSuccessNormal'),
|
||||
);
|
||||
onFormSubmit(1000);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t('plugins.saveConfigError') + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!pluginInfo || !pluginConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full mb-[2rem]">
|
||||
{t('plugins.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 pb-2">
|
||||
{extractI18nObject(
|
||||
pluginInfo.manifest.manifest.metadata.description ?? {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<PluginComponentList
|
||||
components={pluginInfo.components}
|
||||
showComponentName={true}
|
||||
showTitle={false}
|
||||
useBadge={true}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||
initialValues={pluginConfig.config as Record<string, object>}
|
||||
onSubmit={(values) => {
|
||||
let config = pluginConfig.config;
|
||||
config = {
|
||||
...config,
|
||||
...values,
|
||||
};
|
||||
setPluginConfig({
|
||||
config: config,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('plugins.pluginNoConfig')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => handleSubmit(pluginConfig.config)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('plugins.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
||||
|
||||
interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
// 内部组件,用于处理搜索参数
|
||||
function MarketPageContent({
|
||||
installPlugin,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sortOption, setSortOption] = useState('install_count_desc');
|
||||
|
||||
// Plugin detail dialog state
|
||||
const [selectedPluginAuthor, setSelectedPluginAuthor] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedPluginName, setSelectedPluginName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const pageSize = 16; // 每页16个,4行x4列
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
{
|
||||
value: 'created_at_desc',
|
||||
label: t('market.sort.recentlyAdded'),
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'updated_at_desc',
|
||||
label: t('market.sort.recentlyUpdated'),
|
||||
sortBy: 'updated_at',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'install_count_desc',
|
||||
label: t('market.sort.mostDownloads'),
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'install_count_asc',
|
||||
label: t('market.sort.leastDownloads'),
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'ASC',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取当前排序参数
|
||||
const getCurrentSort = useCallback(() => {
|
||||
const option = sortOptions.find((opt) => opt.value === sortOption);
|
||||
return option
|
||||
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
|
||||
: { sortBy: 'install_count', sortOrder: 'DESC' };
|
||||
}, [sortOption]);
|
||||
|
||||
// 将API响应转换为VO对象
|
||||
const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => {
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: plugin.author + ' / ' + plugin.name,
|
||||
author: plugin.author,
|
||||
pluginName: plugin.name,
|
||||
label: extractI18nObject(plugin.label),
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count,
|
||||
iconURL: getCloudServiceClientSync().getPluginIconURL(
|
||||
plugin.author,
|
||||
plugin.name,
|
||||
),
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 获取插件列表
|
||||
const fetchPlugins = useCallback(
|
||||
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsLoadingMore(true);
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
const { sortBy, sortOrder } = getCurrentSort();
|
||||
|
||||
if (isSearch && searchQuery.trim()) {
|
||||
response = await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||
searchQuery.trim(),
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
);
|
||||
} else {
|
||||
response = await getCloudServiceClientSync().getMarketplacePlugins(
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
);
|
||||
}
|
||||
|
||||
const data: ApiRespMarketplacePlugins = response;
|
||||
const newPlugins = data.plugins.map(transformToVO);
|
||||
const total = data.total;
|
||||
|
||||
if (reset || page === 1) {
|
||||
setPlugins(newPlugins);
|
||||
} else {
|
||||
setPlugins((prev) => [...prev, ...newPlugins]);
|
||||
}
|
||||
|
||||
setTotal(total);
|
||||
setHasMore(
|
||||
data.plugins.length === pageSize &&
|
||||
plugins.length + newPlugins.length < total,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins:', error);
|
||||
toast.error(t('market.loadFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, false, true);
|
||||
}, []);
|
||||
|
||||
// 搜索功能
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
fetchPlugins(1, !!query.trim(), true);
|
||||
},
|
||||
[fetchPlugins],
|
||||
);
|
||||
|
||||
// 防抖搜索
|
||||
const handleSearchInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
handleSearch(value);
|
||||
}, 300);
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
// 排序选项变化处理
|
||||
const handleSortChange = useCallback((value: string) => {
|
||||
setSortOption(value);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
// fetchPlugins will be called by useEffect when sortOption changes
|
||||
}, []);
|
||||
|
||||
// 当排序选项变化时重新加载数据
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, !!searchQuery.trim(), true);
|
||||
}, [sortOption]);
|
||||
|
||||
// 处理URL参数,检查是否需要打开插件详情对话框
|
||||
useEffect(() => {
|
||||
const author = searchParams.get('author');
|
||||
const pluginName = searchParams.get('plugin');
|
||||
|
||||
if (author && pluginName) {
|
||||
setSelectedPluginAuthor(author);
|
||||
setSelectedPluginName(pluginName);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 插件详情对话框处理函数
|
||||
const handlePluginClick = useCallback(
|
||||
(author: string, pluginName: string) => {
|
||||
setSelectedPluginAuthor(author);
|
||||
setSelectedPluginName(pluginName);
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDialogClose = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setSelectedPluginAuthor(null);
|
||||
setSelectedPluginName(null);
|
||||
}, []);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (!isLoadingMore && hasMore) {
|
||||
const nextPage = currentPage + 1;
|
||||
setCurrentPage(nextPage);
|
||||
fetchPlugins(nextPage, !!searchQuery.trim());
|
||||
}
|
||||
}, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]);
|
||||
|
||||
// 监听滚动事件
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + document.documentElement.scrollTop >=
|
||||
document.documentElement.offsetHeight - 100
|
||||
) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [loadMore]);
|
||||
|
||||
// 安装插件
|
||||
// const handleInstallPlugin = (plugin: PluginV4) => {
|
||||
// console.log('install plugin', plugin);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 立即搜索,清除防抖定时器
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序下拉框 */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.sortBy')}:
|
||||
</span>
|
||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果统计 */}
|
||||
{total > 0 && (
|
||||
<div className="text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? t('market.searchResults', { count: total })
|
||||
: t('market.totalPlugins', { count: total })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('market.loading')}</span>
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.pluginId}
|
||||
cardVO={plugin}
|
||||
onPluginClick={handlePluginClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">{t('market.loadingMore')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && plugins.length > 0 && (
|
||||
<div className="text-center text-muted-foreground py-6">
|
||||
{t('market.allLoaded')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件详情对话框 */}
|
||||
<PluginDetailDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
author={selectedPluginAuthor}
|
||||
pluginName={selectedPluginName}
|
||||
installPlugin={installPlugin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主组件,包装在 Suspense 中
|
||||
export default function MarketPage({
|
||||
installPlugin,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MarketPageContent installPlugin={installPlugin} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Download, Users } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
interface PluginDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
author: string | null;
|
||||
pluginName: string | null;
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
}
|
||||
|
||||
export default function PluginDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
author,
|
||||
pluginName,
|
||||
installPlugin,
|
||||
}: PluginDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [plugin, setPlugin] = useState<PluginV4 | null>(null);
|
||||
const [readme, setReadme] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
|
||||
|
||||
// 获取插件详情和README
|
||||
useEffect(() => {
|
||||
if (open && author && pluginName) {
|
||||
fetchPluginData();
|
||||
}
|
||||
}, [open, author, pluginName]);
|
||||
|
||||
const fetchPluginData = async () => {
|
||||
if (!author || !pluginName) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 获取插件详情
|
||||
const detailResponse = await getCloudServiceClientSync().getPluginDetail(
|
||||
author,
|
||||
pluginName,
|
||||
);
|
||||
setPlugin(detailResponse.plugin);
|
||||
|
||||
// 获取README
|
||||
setIsLoadingReadme(true);
|
||||
try {
|
||||
const readmeResponse =
|
||||
await getCloudServiceClientSync().getPluginREADME(author, pluginName);
|
||||
setReadme(readmeResponse.readme);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load README:', error);
|
||||
setReadme(t('market.noReadme'));
|
||||
} finally {
|
||||
setIsLoadingReadme(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin details:', error);
|
||||
toast.error(t('market.loadFailed'));
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const PluginHeader = () => (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={getCloudServiceClientSync().getPluginIconURL(author!, pluginName!)}
|
||||
alt={plugin!.name}
|
||||
className="w-16 h-16 rounded-xl border bg-gray-50 object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2 dark:text-white">
|
||||
{extractI18nObject(plugin!.label) || plugin!.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3 dark:text-gray-400">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>
|
||||
{plugin!.author} / {plugin!.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="dark:bg-gray-800 dark:text-white">
|
||||
v{plugin!.latest_version}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 dark:bg-gray-800 dark:text-white"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
|
||||
</Badge>
|
||||
{plugin!.repository && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(plugin!.repository, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded-md transition-colors dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PluginDescription = () => (
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 leading-relaxed text-base dark:text-gray-400">
|
||||
{extractI18nObject(plugin!.description) || t('market.noDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PluginOptions = () => (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={() => installPlugin(plugin!)}
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
<Download className="w-5 h-5 mr-2" />
|
||||
{t('market.install')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReadmeContent = () => (
|
||||
<div className="prose prose-sm max-w-none text-gray-800 dark:text-gray-400">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 表格组件
|
||||
table: ({ ...props }) => (
|
||||
<div className="my-6 w-full overflow-x-auto rounded-lg">
|
||||
<table
|
||||
className="w-full border-collapse bg-white dark:bg-gray-800"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: ({ ...props }) => (
|
||||
<thead
|
||||
className="bg-gray-50 dark:bg-gray-900 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tbody: ({ ...props }) => (
|
||||
<tbody
|
||||
className="divide-y divide-gray-200 dark:divide-gray-700 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ ...props }) => (
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-semibold text-gray-900 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ ...props }) => (
|
||||
<td
|
||||
className="px-4 py-3 text-sm text-gray-700 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tr: ({ ...props }) => (
|
||||
<tr
|
||||
className="hover:bg-gray-50 transition-colors dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// 删除线支持
|
||||
del: ({ ...props }) => (
|
||||
<del
|
||||
className="text-gray-500 line-through dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
// Todo 列表支持
|
||||
input: ({ type, checked, ...props }) => {
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled
|
||||
className="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-default dark:border-gray-700"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <input type={type} {...props} />;
|
||||
},
|
||||
ul: ({ ...props }) => (
|
||||
<ul className="list-disc ml-5 dark:text-gray-400" {...props} />
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol className="list-decimal ml-5 dark:text-gray-400" {...props} />
|
||||
),
|
||||
li: ({ ...props }) => <li className="mb-1" {...props} />,
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-3xl font-bold my-2 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2
|
||||
className="text-2xl font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="leading-relaxed dark:text-gray-400" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isCodeBlock = match ? true : false;
|
||||
|
||||
// 如果是代码块(有语言标识),由 pre 标签处理样式,淡灰色底,黑色字
|
||||
if (isCodeBlock) {
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono dark:bg-gray-800 dark:text-gray-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// 内联代码样式 - 淡灰色底
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono inline-block dark:bg-gray-800 dark:text-gray-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ ...props }) => (
|
||||
<pre
|
||||
className="bg-gray-100 text-gray-800 rounded-lg my-4 shadow-sm max-h-[500px] relative dark:bg-gray-800 dark:text-gray-400"
|
||||
style={{
|
||||
// 内边距确保内容不被滚动条覆盖
|
||||
padding: '16px',
|
||||
// 保持代码不换行以启用横向滚动
|
||||
whiteSpace: 'pre',
|
||||
// 滚动设置
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
// 确保滚动条在内部
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!min-w-[50vw] max-w-none max-h-[90vh] h-[90vh] p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('market.loading')}</span>
|
||||
</div>
|
||||
) : plugin ? (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 插件信息区域 */}
|
||||
<div className="flex-shrink-0 bg-white border-b m-4 pt-2 dark:bg-black">
|
||||
<div className="flex gap-6 p-2 px-4">
|
||||
<div className="flex-1">
|
||||
<PluginHeader />
|
||||
<PluginDescription />
|
||||
</div>
|
||||
<div className="w-40 pr-4 flex-shrink-0">
|
||||
<PluginOptions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* README 区域 */}
|
||||
<div className="flex-1 overflow-hidden px-8">
|
||||
<div className="h-full bg-white overflow-y-auto pb-2 dark:bg-black">
|
||||
{isLoadingReadme ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-3 text-gray-600">
|
||||
{t('market.loading')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ReadmeContent />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
onPluginClick,
|
||||
}: {
|
||||
cardVO: PluginMarketCardVO;
|
||||
onPluginClick?: (author: string, pluginName: string) => void;
|
||||
}) {
|
||||
function handleCardClick() {
|
||||
if (onPluginClick) {
|
||||
onPluginClick(cardVO.author, cardVO.pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
{/* 上部分:插件信息 */}
|
||||
<div className="flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
<img src={cardVO.iconURL} alt="plugin icon" className="w-16 h-16" />
|
||||
|
||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
|
||||
{cardVO.pluginId}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2">
|
||||
{cardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
|
||||
{cardVO.githubURL && (
|
||||
<svg
|
||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(cardVO.githubURL, '_blank');
|
||||
}}
|
||||
>
|
||||
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下部分:下载量 */}
|
||||
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
|
||||
<svg
|
||||
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<div className="text-sm text-[#2563eb] font-medium">
|
||||
{cardVO.installCount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface IPluginMarketCardVO {
|
||||
pluginId: string;
|
||||
author: string;
|
||||
pluginName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
installCount: number;
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
pluginId: string;
|
||||
description: string;
|
||||
label: string;
|
||||
author: string;
|
||||
pluginName: string;
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
installCount: number;
|
||||
version: string;
|
||||
|
||||
constructor(prop: IPluginMarketCardVO) {
|
||||
this.description = prop.description;
|
||||
this.label = prop.label;
|
||||
this.author = prop.author;
|
||||
this.pluginName = prop.pluginName;
|
||||
this.iconURL = prop.iconURL;
|
||||
this.githubURL = prop.githubURL;
|
||||
this.installCount = prop.installCount;
|
||||
this.pluginId = prop.pluginId;
|
||||
this.version = prop.version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// 'use client';
|
||||
|
||||
// import * as React from 'react';
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO';
|
||||
// import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
// import { PluginReorderElement } from '@/app/infra/entities/api';
|
||||
// import { toast } from 'sonner';
|
||||
// import {
|
||||
// Dialog,
|
||||
// DialogContent,
|
||||
// DialogHeader,
|
||||
// DialogTitle,
|
||||
// DialogFooter,
|
||||
// } from '@/components/ui/dialog';
|
||||
// import { Button } from '@/components/ui/button';
|
||||
// import {
|
||||
// DndContext,
|
||||
// closestCenter,
|
||||
// KeyboardSensor,
|
||||
// PointerSensor,
|
||||
// useSensor,
|
||||
// useSensors,
|
||||
// DragEndEvent,
|
||||
// } from '@dnd-kit/core';
|
||||
// import {
|
||||
// arrayMove,
|
||||
// SortableContext,
|
||||
// sortableKeyboardCoordinates,
|
||||
// useSortable,
|
||||
// verticalListSortingStrategy,
|
||||
// } from '@dnd-kit/sortable';
|
||||
// import { CSS } from '@dnd-kit/utilities';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
// import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
// interface PluginSortDialogProps {
|
||||
// open: boolean;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// onSortComplete: () => void;
|
||||
// }
|
||||
|
||||
// function SortablePluginItem({ plugin }: { plugin: PluginCardVO }) {
|
||||
// const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
// useSortable({
|
||||
// id: `${plugin.author}-${plugin.name}`,
|
||||
// });
|
||||
|
||||
// const style = {
|
||||
// transform: CSS.Transform.toString(transform),
|
||||
// transition,
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// ref={setNodeRef}
|
||||
// style={style}
|
||||
// {...attributes}
|
||||
// {...listeners}
|
||||
// className="bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm border mb-2 cursor-move"
|
||||
// >
|
||||
// <div className="flex flex-col">
|
||||
// <div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
// {plugin.author}
|
||||
// </div>
|
||||
// <div className="text-lg font-medium">{plugin.name}</div>
|
||||
// <div className="text-sm line-clamp-2 text-gray-500 dark:text-gray-400 mt-1">
|
||||
// {plugin.description}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default function PluginSortDialog({
|
||||
// open,
|
||||
// onOpenChange,
|
||||
// onSortComplete,
|
||||
// }: PluginSortDialogProps) {
|
||||
// const { t } = useTranslation();
|
||||
// const [sortedPlugins, setSortedPlugins] = useState<PluginCardVO[]>([]);
|
||||
// const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// function getPluginList() {
|
||||
// httpClient.getPlugins().then((value) => {
|
||||
// setSortedPlugins(
|
||||
// value.plugins.map((plugin) => {
|
||||
// return new PluginCardVO({
|
||||
// author: plugin.manifest.manifest.metadata.author ?? '',
|
||||
// description: extractI18nObject(
|
||||
// plugin.manifest.manifest.metadata.description ?? {
|
||||
// en_US: '',
|
||||
// zh_Hans: '',
|
||||
// },
|
||||
// ),
|
||||
// enabled: plugin.enabled,
|
||||
// name: plugin.manifest.manifest.metadata.name,
|
||||
// version: plugin.manifest.manifest.metadata.version ?? '',
|
||||
// status: plugin.status,
|
||||
// components: plugin.components,
|
||||
// install_source: plugin.install_source,
|
||||
// install_info: plugin.install_info,
|
||||
// priority: plugin.priority,
|
||||
// debug: plugin.debug,
|
||||
// });
|
||||
// }),
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
|
||||
// useEffect(() => {
|
||||
// if (open) {
|
||||
// getPluginList();
|
||||
// }
|
||||
// }, [open]);
|
||||
|
||||
// const sensors = useSensors(
|
||||
// useSensor(PointerSensor),
|
||||
// useSensor(KeyboardSensor, {
|
||||
// coordinateGetter: sortableKeyboardCoordinates,
|
||||
// }),
|
||||
// );
|
||||
|
||||
// function handleDragEnd(event: DragEndEvent) {
|
||||
// const { active, over } = event;
|
||||
// console.log('Drag end event:', { active, over });
|
||||
|
||||
// if (over && active.id !== over.id) {
|
||||
// setSortedPlugins((items) => {
|
||||
// const oldIndex = items.findIndex(
|
||||
// (item) => `${item.author}-${item.name}` === active.id,
|
||||
// );
|
||||
// const newIndex = items.findIndex(
|
||||
// (item) => `${item.author}-${item.name}` === over.id,
|
||||
// );
|
||||
|
||||
// const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// return newItems;
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// function handleSave() {
|
||||
// setIsLoading(true);
|
||||
|
||||
// const reorderElements: PluginReorderElement[] = sortedPlugins.map(
|
||||
// (plugin, index) => ({
|
||||
// author: plugin.author,
|
||||
// name: plugin.name,
|
||||
// priority: index,
|
||||
// }),
|
||||
// );
|
||||
|
||||
// httpClient
|
||||
// .reorderPlugins(reorderElements)
|
||||
// .then(() => {
|
||||
// toast.success(t('plugins.pluginSortSuccess'));
|
||||
// onSortComplete();
|
||||
// onOpenChange(false);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// toast.error(t('plugins.pluginSortError') + err.message);
|
||||
// })
|
||||
// .finally(() => {
|
||||
// setIsLoading(false);
|
||||
// });
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
// <DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
// <DialogHeader className="px-6 pt-6 pb-0">
|
||||
// <DialogTitle>{t('plugins.pluginSort')}</DialogTitle>
|
||||
// </DialogHeader>
|
||||
// <div className="flex-1 overflow-y-auto px-6 py-0">
|
||||
// <p className="text-sm text-gray-500 mb-4">
|
||||
// {t('plugins.pluginSortDescription')}
|
||||
// </p>
|
||||
// <DndContext
|
||||
// sensors={sensors}
|
||||
// collisionDetection={closestCenter}
|
||||
// onDragEnd={handleDragEnd}
|
||||
// >
|
||||
// <SortableContext
|
||||
// items={sortedPlugins.map(
|
||||
// (plugin) => `${plugin.author}-${plugin.name}`,
|
||||
// )}
|
||||
// strategy={verticalListSortingStrategy}
|
||||
// >
|
||||
// {sortedPlugins.map((plugin) => (
|
||||
// <SortablePluginItem
|
||||
// key={`${plugin.author}-${plugin.name}`}
|
||||
// plugin={plugin}
|
||||
// />
|
||||
// ))}
|
||||
// </SortableContext>
|
||||
// </DndContext>
|
||||
// </div>
|
||||
// <DialogFooter className="px-6 py-4">
|
||||
// <Button
|
||||
// variant="outline"
|
||||
// onClick={() => onOpenChange(false)}
|
||||
// disabled={isLoading}
|
||||
// >
|
||||
// {t('common.cancel')}
|
||||
// </Button>
|
||||
// <Button onClick={handleSave} disabled={isLoading}>
|
||||
// {isLoading ? t('common.saving') : t('common.save')}
|
||||
// </Button>
|
||||
// </DialogFooter>
|
||||
// </DialogContent>
|
||||
// </Dialog>
|
||||
// );
|
||||
// }
|
||||
Reference in New Issue
Block a user