feat: improvements for installed plugin card

* feat:Add README display to installed plugins

* chore: Increase the timeout of call_tool

* perf: smaller animation

* fix: add endpiont for readme

* feat: supports for multilingual READMEs

* feat: supports for getting readme img

* chore: bump langbot-plugin to 0.1.13b1

* perf: plugin card layout

* fix: import useTranslation linter error

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Xiaoyu Su
2025-11-25 00:12:03 +08:00
committed by GitHub
parent 50c33c5213
commit 2e1f16d7b4
19 changed files with 2652 additions and 79 deletions

1865
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
@@ -59,6 +60,11 @@
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
@@ -72,6 +78,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",
@@ -81,5 +88,6 @@
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"typescript-eslint": "^8.31.1"
}
},
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e"
}

View File

@@ -4,6 +4,7 @@ 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 PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
@@ -39,6 +40,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
@@ -106,6 +109,11 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
setModalOpen(true);
}
function handleViewReadme(plugin: PluginCardVO) {
setReadmePlugin(plugin);
setReadmeModalOpen(true);
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
@@ -316,6 +324,25 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</DialogContent>
</Dialog>
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2 border-b">
<DialogTitle>
{readmePlugin &&
`${readmePlugin.author}/${readmePlugin.name} - ${t('plugins.readme')}`}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{readmePlugin && (
<PluginReadme
pluginAuthor={readmePlugin.author}
pluginName={readmePlugin.name}
/>
)}
</div>
</DialogContent>
</Dialog>
{pluginList.map((vo, index) => {
return (
<div key={index}>
@@ -324,6 +351,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
onViewReadme={() => handleViewReadme(vo)}
/>
</div>
);

View File

@@ -2,7 +2,15 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
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 {
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Settings,
FileText,
} from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
@@ -19,53 +27,59 @@ export default function PluginCardComponent({
onCardClick,
onDeleteClick,
onUpgradeClick,
onViewReadme,
}: {
cardVO: PluginCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void;
onViewReadme: (cardVO: PluginCardVO) => void;
}) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = 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}
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] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
if (!dropdownOpen) {
setIsHovered(false);
}
}}
>
<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> */}
{/* Icon - fixed width */}
<img
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
alt="plugin icon"
className="w-16 h-16 rounded-[8%]"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
/>
<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]">
{/* Content area - flexible width with min-width to prevent overflow */}
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
{/* Top content area - allows overflow with max height */}
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{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]">
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge variant="outline" className="text-[0.7rem]">
<Badge
variant="outline"
className="text-[0.7rem] flex-shrink-0"
>
v{cardVO.version}
</Badge>
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
@@ -76,7 +90,7 @@ export default function PluginCardComponent({
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
window.open(
@@ -92,7 +106,7 @@ export default function PluginCardComponent({
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
>
{t('plugins.fromLocal')}
</Badge>
@@ -100,7 +114,7 @@ export default function PluginCardComponent({
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
window.open(
@@ -121,12 +135,13 @@ export default function PluginCardComponent({
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999]">
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
{cardVO.description}
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{/* Components list - fixed at bottom */}
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem] flex-shrink-0 min-h-[1.5rem]">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> = {};
@@ -148,13 +163,25 @@ export default function PluginCardComponent({
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
{/* Menu button - fixed width and position */}
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (!open) {
setIsHovered(false);
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Ellipsis className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@@ -174,7 +201,7 @@ export default function PluginCardComponent({
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
@@ -189,6 +216,45 @@ export default function PluginCardComponent({
</div>
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={(e) => {
e.stopPropagation();
onViewReadme(cardVO);
}}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<FileText className="w-4 h-4" />
{t('plugins.readme')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onCardClick();
}}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<Settings className="w-4 h-4" />
{t('plugins.config')}
</Button>
</div>
</div>
</>
);

View File

@@ -184,7 +184,7 @@ export default function PluginForm({
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用不触发状态更新
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {

View File

@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider';
import './github-markdown.css';
export default function PluginReadme({
pluginAuthor,
pluginName,
}: {
pluginAuthor: string;
pluginName: string;
}) {
const { t } = useTranslation();
const [readme, setReadme] = useState<string>('');
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
const language = getAPILanguageCode();
useEffect(() => {
// Fetch plugin README
setIsLoadingReadme(true);
httpClient
.getPluginReadme(pluginAuthor, pluginName, language)
.then((res) => {
setReadme(res.readme);
})
.catch(() => {
setReadme('');
})
.finally(() => {
setIsLoadingReadme(false);
});
}, [pluginAuthor, pluginName]);
return (
<div className="w-full h-full overflow-auto">
{isLoadingReadme ? (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.loadingReadme')}
</div>
) : readme ? (
<div className="markdown-body p-6 max-w-none pt-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => (
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => {
let imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
if (
imageSrc &&
!imageSrc.startsWith('http://') &&
!imageSrc.startsWith('https://') &&
!imageSrc.startsWith('data:')
) {
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
if (!imageSrc.startsWith('assets/')) {
imageSrc = `assets/${imageSrc}`;
}
const assetPath = imageSrc.replace(/^assets\//, '');
imageSrc = httpClient.getPluginAssetURL(
pluginAuthor,
pluginName,
assetPath,
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{readme}
</ReactMarkdown>
</div>
) : (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.noReadme')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,401 @@
/* GitHub-style Markdown CSS */
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
color: var(--color-fg-default);
background-color: transparent;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',
Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
/* Hide light theme highlight.js styles in dark mode */
.dark .markdown-body .hljs {
background: transparent !important;
}
/* Ensure code blocks have proper styling */
.markdown-body pre code.hljs {
background: transparent;
}
.markdown-body .octicon {
display: inline-block;
fill: currentColor;
vertical-align: text-bottom;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-muted);
}
.markdown-body h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-muted);
}
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
font-size: 0.85em;
color: var(--color-fg-muted);
}
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body blockquote {
margin: 0 0 16px 0;
padding: 0 1em;
color: var(--color-fg-muted);
border-left: 0.25em solid var(--color-border-default);
}
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin-top: 0.25em;
}
.markdown-body li + li {
margin-top: 0.25em;
}
.markdown-body li > p {
margin-top: 16px;
margin-bottom: 16px;
}
.markdown-body li > p:first-child {
margin-top: 0;
}
.markdown-body li > p:last-child {
margin-bottom: 0;
}
/* Nested lists */
.markdown-body ul ul,
.markdown-body ul ol,
.markdown-body ol ol,
.markdown-body ol ul {
margin-top: 0.25em;
margin-bottom: 0;
}
.markdown-body ul ul {
list-style-type: circle;
}
.markdown-body ul ul ul {
list-style-type: square;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: var(--color-neutral-muted);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;
}
.markdown-body pre {
margin-top: 0;
margin-bottom: 16px;
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
}
.markdown-body pre code {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
.markdown-body a {
color: var(--color-accent-fg);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body table tr {
background-color: transparent;
border-top: 1px solid var(--color-border-muted);
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--color-canvas-subtle);
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid var(--color-border-default);
}
.markdown-body table th {
font-weight: 600;
background-color: var(--color-canvas-subtle);
}
.markdown-body img {
max-width: 50%;
box-sizing: content-box;
background-color: transparent;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--color-border-default);
border: 0;
}
/* Light theme colors */
.markdown-body {
--color-fg-default: #1f2328;
--color-fg-muted: #656d76;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: #d8dee4;
--color-neutral-muted: rgba(175, 184, 193, 0.2);
--color-accent-fg: #0969da;
}
/* Dark theme colors */
.dark .markdown-body {
--color-fg-default: #e6edf3;
--color-fg-muted: #8d96a0;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-neutral-muted: rgba(110, 118, 129, 0.4);
--color-accent-fg: #4493f8;
}
/* Code highlighting styles */
.markdown-body .hljs {
display: block;
overflow-x: auto;
padding: 0;
background: transparent;
color: var(--color-fg-default);
}
/* Light theme syntax highlighting */
.markdown-body .hljs-comment,
.markdown-body .hljs-quote {
color: #6a737d;
font-style: italic;
}
.markdown-body .hljs-keyword,
.markdown-body .hljs-selector-tag,
.markdown-body .hljs-subst {
color: #d73a49;
}
.markdown-body .hljs-number,
.markdown-body .hljs-literal,
.markdown-body .hljs-variable,
.markdown-body .hljs-template-variable,
.markdown-body .hljs-tag .hljs-attr {
color: #005cc5;
}
.markdown-body .hljs-string,
.markdown-body .hljs-doctag {
color: #032f62;
}
.markdown-body .hljs-title,
.markdown-body .hljs-section,
.markdown-body .hljs-selector-id {
color: #6f42c1;
font-weight: bold;
}
.markdown-body .hljs-type,
.markdown-body .hljs-class .hljs-title {
color: #6f42c1;
}
.markdown-body .hljs-tag,
.markdown-body .hljs-name,
.markdown-body .hljs-attribute {
color: #22863a;
font-weight: normal;
}
.markdown-body .hljs-regexp,
.markdown-body .hljs-link {
color: #032f62;
}
.markdown-body .hljs-symbol,
.markdown-body .hljs-bullet {
color: #e36209;
}
.markdown-body .hljs-built_in,
.markdown-body .hljs-builtin-name {
color: #005cc5;
}
.markdown-body .hljs-meta {
color: #6a737d;
}
.markdown-body .hljs-deletion {
background-color: #ffeef0;
}
.markdown-body .hljs-addition {
background-color: #e6ffed;
}
.markdown-body .hljs-emphasis {
font-style: italic;
}
.markdown-body .hljs-strong {
font-weight: bold;
}
/* Dark theme syntax highlighting */
.dark .markdown-body .hljs-comment,
.dark .markdown-body .hljs-quote {
color: #8b949e;
}
.dark .markdown-body .hljs-keyword,
.dark .markdown-body .hljs-selector-tag,
.dark .markdown-body .hljs-subst {
color: #ff7b72;
}
.dark .markdown-body .hljs-number,
.dark .markdown-body .hljs-literal,
.dark .markdown-body .hljs-variable,
.dark .markdown-body .hljs-template-variable,
.dark .markdown-body .hljs-tag .hljs-attr {
color: #79c0ff;
}
.dark .markdown-body .hljs-string,
.dark .markdown-body .hljs-doctag {
color: #a5d6ff;
}
.dark .markdown-body .hljs-title,
.dark .markdown-body .hljs-section,
.dark .markdown-body .hljs-selector-id {
color: #d2a8ff;
font-weight: bold;
}
.dark .markdown-body .hljs-type,
.dark .markdown-body .hljs-class .hljs-title {
color: #d2a8ff;
}
.dark .markdown-body .hljs-tag,
.dark .markdown-body .hljs-name,
.dark .markdown-body .hljs-attribute {
color: #7ee787;
}
.dark .markdown-body .hljs-regexp,
.dark .markdown-body .hljs-link {
color: #a5d6ff;
}
.dark .markdown-body .hljs-symbol,
.dark .markdown-body .hljs-bullet {
color: #ffa657;
}
.dark .markdown-body .hljs-built_in,
.dark .markdown-body .hljs-builtin-name {
color: #79c0ff;
}
.dark .markdown-body .hljs-meta {
color: #8b949e;
}
.dark .markdown-body .hljs-deletion {
background-color: rgba(248, 81, 73, 0.15);
}
.dark .markdown-body .hljs-addition {
background-color: rgba(46, 160, 67, 0.15);
}

View File

@@ -48,7 +48,7 @@ export default function PluginMarketCardComponent({
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] relative"
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -137,25 +137,33 @@ export default function PluginMarketCardComponent({
</div>
{/* Hover overlay with action buttons */}
{isHovered && (
<div className="absolute inset-0 bg-gray-100/65 dark:bg-black/40 rounded-[10px] flex items-center justify-center gap-3 transition-opacity duration-200">
<Button
onClick={handleInstallClick}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className="bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-lg flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
)}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
);
}

View File

@@ -12,7 +12,6 @@
padding-left: 0.8rem;
padding-right: 0.8rem;
padding-top: 2rem;
padding-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2rem;

View File

@@ -483,6 +483,27 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/plugins/config-files/${fileKey}`);
}
public getPluginReadme(
author: string,
name: string,
language: string = 'en',
): Promise<{ readme: string }> {
return this.get(
`/api/v1/plugins/${author}/${name}/readme?language=${language}`,
);
}
public getPluginAssetURL(
author: string,
name: string,
filepath: string,
): string {
return (
this.instance.defaults.baseURL +
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
);
}
public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') {
const url = window.location.href;

View File

@@ -280,6 +280,10 @@ const enUS = {
saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ',
config: 'Configuration',
readme: 'Documentation',
loadingReadme: 'Loading documentation...',
noReadme: 'This plugin does not provide README documentation',
fileUpload: {
tooLarge: 'File size exceeds 10MB limit',
success: 'File uploaded successfully',

View File

@@ -281,6 +281,10 @@ const jaJP = {
saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:',
config: '設定',
readme: 'ドキュメント',
loadingReadme: 'ドキュメントを読み込み中...',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
fileUpload: {
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
success: 'ファイルのアップロードに成功しました',

View File

@@ -266,6 +266,10 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:',
config: '配置',
readme: '文档',
loadingReadme: '正在加载文档...',
noReadme: '该插件没有提供 README 文档',
fileUpload: {
tooLarge: '文件大小超过 10MB 限制',
success: '文件上传成功',

View File

@@ -265,6 +265,10 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:',
config: '配置',
readme: '文件',
loadingReadme: '正在載入文件...',
noReadme: '該插件沒有提供 README 文件',
fileUpload: {
tooLarge: '檔案大小超過 10MB 限制',
success: '檔案上傳成功',

10
web/src/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import 'react-i18next';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: typeof import('./i18n/locales/zh-Hans').default;
};
}
}