feat: enhance plugin functionality

This commit is contained in:
时雨
2026-01-06 16:54:49 +08:00
committed by GitHub
parent 31d97b2968
commit 24255744df
48 changed files with 3089 additions and 3811 deletions

View File

@@ -7,11 +7,9 @@
"dependencies": {
"@ant-design/icons": "6",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-md-editor": "^4.0.11",
"antd": "6",
"artplayer": "^5.3.0",
"date-fns": "^4.1.0",
"monaco-editor": "^0.55.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
@@ -337,8 +335,6 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -367,12 +363,6 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
"@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.19", "", {}, "sha512-AYxzFUBkZrhtExb2QC0C4lFH2+BSx6JVId9iqeGHakBuosqiQHUQaNZCvIBeM97Ucp+nJ22flOh8FBT2pKRRAA=="],
"@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.1.5", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~9.0.1", "rehype-attr": "~3.0.1", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg=="],
"@uiw/react-md-editor": ["@uiw/react-md-editor@4.0.11", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.0.6", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-F0OR5O1v54EkZYvJj3ew0I7UqLiPeU34hMAY4MdXS3hI86rruYi5DHVkG/VuvLkUZW7wIETM2QFtZ459gKIjQA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
@@ -397,10 +387,6 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -439,8 +425,6 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
@@ -457,14 +441,10 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -515,50 +495,22 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@3.1.1", "", { "dependencies": { "@types/hast": "^2.0.0" } }, "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@7.2.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^3.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -615,26 +567,10 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
@@ -653,20 +589,6 @@
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
@@ -717,8 +639,6 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -731,10 +651,6 @@
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -763,38 +679,10 @@
"react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],
"refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="],
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
"rehype-attr": ["rehype-attr@3.0.3", "", { "dependencies": { "unified": "~11.0.0", "unist-util-visit": "~5.0.0" } }, "sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw=="],
"rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="],
"rehype-ignore": ["rehype-ignore@2.0.3", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg=="],
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
"rehype-prism-plus": ["rehype-prism-plus@2.0.1", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-rewrite": ["rehype-rewrite@4.0.4", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0" } }, "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw=="],
"rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="],
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-github-blockquote-alert": ["remark-github-blockquote-alert@1.3.1", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
@@ -849,8 +737,6 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-filter": ["unist-util-filter@5.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
@@ -867,14 +753,10 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -899,32 +781,8 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@uiw/react-markdown-preview/react-markdown": ["react-markdown@9.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="],
"@uiw/react-markdown-preview/rehype-prism-plus": ["rehype-prism-plus@2.0.0", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hast-util-parse-selector/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-parse-selector/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"refractor/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
}
}

View File

@@ -12,11 +12,15 @@ export default tseslint.config([
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'error',
{

View File

@@ -12,11 +12,9 @@
"dependencies": {
"@ant-design/icons": "6",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-md-editor": "^4.0.11",
"antd": "6",
"artplayer": "^5.3.0",
"date-fns": "^4.1.0",
"monaco-editor": "^0.55.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",

28
web/plugin-frame.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Foxel Plugin Frame</title>
<link rel='stylesheet' href='https://foxel.cc/fonts/result.css' />
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
font-family: 'Maple Mono Normal NL NF CN';
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/plugin-frame.ts"></script>
</body>
</html>

View File

@@ -28,7 +28,67 @@ export interface RepoQueryParams {
pageSize?: number;
}
// foxel-core 应用中心的数据结构
export interface FoxelCoreApp {
key: string;
version: string;
name: {
zh: string;
en: string;
};
description: {
zh: string;
en: string;
};
author: string;
website: string;
tags: {
zh: string[];
en: string[];
};
approvedAt: number;
detailUrl: string;
downloadUrl: string;
}
export interface FoxelCoreAppsResponse {
apps: FoxelCoreApp[];
}
export interface FoxelCoreAppVersion {
version: string;
name: {
zh: string;
en: string;
};
description: {
zh: string;
en: string;
};
author: string;
website: string;
tags: {
zh: string[];
en: string[];
};
approvedAt: number;
releaseNotesMd: string | null;
}
export interface FoxelCoreAppDetail {
key: string;
latest: FoxelCoreAppVersion & {
downloadUrl: string;
};
versions: FoxelCoreAppVersion[];
}
export interface FoxelCoreAppDetailResponse {
app: FoxelCoreAppDetail;
}
const CENTER_BASE = 'https://center.foxel.cc';
const FOXEL_CORE_BASE = 'https://foxel.cc';
export function buildCenterUrl(path: string) {
return new URL(path, CENTER_BASE).href;
@@ -50,3 +110,42 @@ export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoL
return await resp.json();
}
/**
* 从 foxel-core 应用中心获取应用列表
*/
export async function fetchFoxelCoreApps(): Promise<FoxelCoreApp[]> {
const url = `${FOXEL_CORE_BASE}/api/apps`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch apps: ${resp.status}`);
}
const data: FoxelCoreAppsResponse = await resp.json();
return data.apps;
}
/**
* 从 foxel-core 应用中心获取应用详情(含历史版本)
*/
export async function fetchFoxelCoreAppDetail(appKey: string): Promise<FoxelCoreAppDetail> {
const url = `${FOXEL_CORE_BASE}/api/apps/${encodeURIComponent(appKey)}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch app detail: ${resp.status}`);
}
const data: FoxelCoreAppDetailResponse = await resp.json();
return data.app;
}
/**
* 从 foxel-core 下载应用包文件
*/
export async function downloadFoxelCoreApp(app: Pick<FoxelCoreApp, 'key' | 'version' | 'downloadUrl'>): Promise<File> {
const url = `${FOXEL_CORE_BASE}${app.downloadUrl}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to download app: ${resp.status}`);
}
const blob = await resp.blob();
const filename = `${app.key}-${app.version}.foxpkg`;
return new File([blob], filename, { type: 'application/octet-stream' });
}

View File

@@ -2,46 +2,67 @@ import request from './client';
export interface PluginItem {
id: number;
url: string;
enabled: boolean;
key: string;
open_app?: boolean | null;
key?: string | null;
name?: string | null;
version?: string | null;
supported_exts?: string[] | null;
default_bounds?: Record<string, any> | null;
default_bounds?: Record<string, number> | null;
default_maximized?: boolean | null;
icon?: string | null;
description?: string | null;
author?: string | null;
website?: string | null;
github?: string | null;
license?: string | null;
manifest?: Record<string, unknown> | null;
loaded_routes?: string[] | null;
loaded_processors?: string[] | null;
}
export interface PluginCreate {
url: string;
enabled?: boolean;
}
export interface PluginManifestUpdate {
key?: string;
name?: string;
version?: string;
open_app?: boolean;
supported_exts?: string[];
default_bounds?: Record<string, any>;
default_maximized?: boolean;
icon?: string;
description?: string;
author?: string;
website?: string;
github?: string;
export interface PluginInstallResult {
success: boolean;
plugin?: PluginItem;
message?: string;
errors?: string[];
}
export const pluginsApi = {
/**
* 获取已安装插件列表
*/
list: () => request<PluginItem[]>(`/plugins`),
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
/**
* 获取单个插件详情
*/
get: (key: string) => request<PluginItem>(`/plugins/${key}`),
/**
* 安装插件(上传 .foxpkg
*/
install: async (file: File): Promise<PluginInstallResult> => {
const formData = new FormData();
formData.append('file', file);
return request<PluginInstallResult>(`/plugins/install`, {
method: 'POST',
formData,
});
},
/**
* 删除/卸载插件
*/
remove: (key: string) => request(`/plugins/${key}`, { method: 'DELETE' }),
/**
* 获取插件 bundle URL
*/
getBundleUrl: (key: string) => `/api/plugins/${key}/bundle.js`,
/**
* 获取插件资源 URL
*/
getAssetUrl: (key: string, assetPath: string) =>
`/api/plugins/${key}/assets/${assetPath}`,
};

View File

@@ -1,35 +0,0 @@
import request from './client';
export type VideoLibraryMediaType = 'tv' | 'movie';
export interface VideoLibraryItem {
id: string;
type: VideoLibraryMediaType;
title: string;
year?: string | null;
overview?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
genres?: string[];
tmdb_id?: number | null;
source_path?: string | null;
scraped_at?: string | null;
updated_at?: string | null;
episodes_count?: number;
seasons_count?: number;
vote_average?: number | null;
vote_count?: number | null;
}
export const videoLibraryApi = {
list: (params?: { q?: string; type?: VideoLibraryMediaType }) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.type) search.set('type', params.type);
const suffix = search.toString();
return request<VideoLibraryItem[]>(`/plugins/video-player/library${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
},
get: (id: string) =>
request<any>(`/plugins/video-player/library/${encodeURIComponent(id)}`, { method: 'GET' }),
};

View File

@@ -212,6 +212,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
<div
key={w.id}
ref={el => { windowEls.current[w.id] = el; }}
onMouseDown={() => onBringToFront(w.id)}
style={{
position: 'fixed',
top: w.maximized ? 0 : w.y,

View File

@@ -1,654 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FileOutlined,
DatabaseOutlined,
ExpandOutlined,
BgColorsOutlined,
ClockCircleOutlined,
FolderOutlined,
AimOutlined,
BulbOutlined,
ThunderboltOutlined,
AlertOutlined,
CameraOutlined,
ApiOutlined,
FieldTimeOutlined,
} from '@ant-design/icons';
import { API_BASE_URL, vfsApi, type VfsEntry } from '../../api/client';
import type { AppComponentProps } from '../types';
import { ImageCanvas } from './components/ImageCanvas';
import { ViewerControls } from './components/ViewerControls';
import { Filmstrip } from './components/Filmstrip';
import { InfoPanel } from './components/InfoPanel';
import type { HistogramData, RgbColor, InfoItem } from './components/types';
import { viewerStyles } from './styles';
interface ExplorerSnapshot {
path: string;
entries: VfsEntry[];
pagination?: { page: number; page_size: number; total: number };
sortBy?: string;
sortOrder?: string;
timestamp: number;
}
interface FileStat {
name?: string;
is_dir?: boolean;
size?: number;
mtime?: number;
mode?: number;
path?: string;
type?: string;
exif?: Record<string, unknown>;
}
declare global {
interface WindowEventMap {
'foxel:file-explorer-page': CustomEvent<ExplorerSnapshot>;
}
}
type ExplorerAwareWindow = Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
const isImageEntry = (ent: VfsEntry) => {
if (ent.is_dir) return false;
const maybe = ent as VfsEntry & { has_thumbnail?: boolean };
if (typeof maybe.has_thumbnail === 'boolean' && maybe.has_thumbnail) return true;
const ext = ent.name.split('.').pop()?.toLowerCase();
if (!ext) return false;
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
};
const buildThumbUrl = (fullPath: string, w = 180, h = 120) => {
const base = API_BASE_URL.replace(/\/+$/, '');
const clean = fullPath.replace(/^\/+/, '');
return `${base}/fs/thumb/${encodeURI(clean)}?w=${w}&h=${h}&fit=cover`;
};
const getDirectory = (fullPath: string) => {
const path = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
const idx = path.lastIndexOf('/');
if (idx <= 0) return '/';
return path.slice(0, idx) || '/';
};
const joinPath = (dir: string, name: string) => {
if (dir === '/' || dir === '') return `/${name}`;
return `${dir.replace(/\/$/, '')}/${name}`;
};
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const parseNumberish = (raw: unknown): number | null => {
if (typeof raw === 'number') return raw;
if (typeof raw !== 'string') return null;
if (raw.includes('/')) {
const [a, b] = raw.split('/').map(v => Number(v));
if (!Number.isNaN(a) && !Number.isNaN(b) && b !== 0) return a / b;
}
const val = Number(raw);
return Number.isNaN(val) ? null : val;
};
const humanFileSize = (size: number | undefined) => {
if (typeof size !== 'number') return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
};
const readExplorerSnapshot = (dir: string): ExplorerSnapshot | null => {
if (typeof window === 'undefined') return null;
const snap = (window as ExplorerAwareWindow).__FOXEL_LAST_EXPLORER_PAGE__;
if (!snap) return null;
const snapshotPath = snap.path === '' ? '/' : snap.path;
const normalizedSnap = snapshotPath.endsWith('/') && snapshotPath !== '/' ? snapshotPath.slice(0, -1) : snapshotPath;
const normalizedTarget = dir.endsWith('/') && dir !== '/' ? dir.slice(0, -1) : dir;
if (normalizedSnap !== normalizedTarget) return null;
return snap;
};
const formatDateTime = (ts?: number) => {
if (!ts) return '-';
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return '-';
}
};
const clampChannel = (value: number) => Math.max(0, Math.min(255, value));
const mixColor = (base: RgbColor, target: RgbColor, ratio: number): RgbColor => ({
r: clampChannel(base.r * (1 - ratio) + target.r * ratio),
g: clampChannel(base.g * (1 - ratio) + target.g * ratio),
b: clampChannel(base.b * (1 - ratio) + target.b * ratio),
});
const rgbToRgba = (color: RgbColor, alpha: number) => `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${alpha})`;
const computeImageStats = (img: HTMLImageElement): { histogram: HistogramData | null; dominantColor: RgbColor | null } => {
try {
const maxSide = 720;
const naturalWidth = img.naturalWidth || 1;
const naturalHeight = img.naturalHeight || 1;
const ratio = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
const width = Math.max(1, Math.floor(naturalWidth * ratio));
const height = Math.max(1, Math.floor(naturalHeight * ratio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return { histogram: null, dominantColor: null };
ctx.drawImage(img, 0, 0, width, height);
const { data } = ctx.getImageData(0, 0, width, height);
const r = new Array(256).fill(0);
const g = new Array(256).fill(0);
const b = new Array(256).fill(0);
let rTotal = 0;
let gTotal = 0;
let bTotal = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
r[data[i]] += 1;
g[data[i + 1]] += 1;
b[data[i + 2]] += 1;
rTotal += data[i];
gTotal += data[i + 1];
bTotal += data[i + 2];
count += 1;
}
const histogram: HistogramData = { r, g, b };
if (count === 0) return { histogram, dominantColor: null };
const dominantColor: RgbColor = {
r: rTotal / count,
g: gTotal / count,
b: bTotal / count,
};
return { histogram, dominantColor };
} catch {
return { histogram: null, dominantColor: null };
}
};
export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
const normalizedInitialPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const [activeEntry, setActiveEntry] = useState<VfsEntry>(entry);
const [activePath, setActivePath] = useState<string>(normalizedInitialPath);
const [imageUrl, setImageUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [stat, setStat] = useState<FileStat | null>(null);
const [histogram, setHistogram] = useState<HistogramData | null>(null);
const [dominantColor, setDominantColor] = useState<RgbColor | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [rotate, setRotate] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [filmstrip, setFilmstrip] = useState<VfsEntry[]>([]);
const [pageInfo, setPageInfo] = useState<{ page: number; total: number; pageSize: number } | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
const pinchDistanceRef = useRef<number | null>(null);
const transitionRef = useRef(false);
const filmstripRefs = useRef<Record<string, HTMLDivElement | null>>({});
const directory = useMemo(() => getDirectory(activePath), [activePath]);
const baseTone = useMemo<RgbColor>(() => dominantColor ?? DEFAULT_TONE, [dominantColor]);
const containerStyle = useMemo(() => {
const light = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.18);
const shadow = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.62);
return {
...viewerStyles.container,
background: `linear-gradient(135deg, ${rgbToRgba(light, 0.78)} 0%, ${rgbToRgba(baseTone, 0.86)} 48%, ${rgbToRgba(shadow, 0.96)} 100%)`,
};
}, [baseTone]);
const mainBackdropStyle = useMemo(() => {
const glow = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.32);
const shade = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.7);
return {
...viewerStyles.mainBackdrop,
background: `radial-gradient(circle at 18% 22%, ${rgbToRgba(glow, 0.38)}, ${rgbToRgba(shade, 0.94)} 68%)`,
};
}, [baseTone]);
const viewerStyle = useMemo(() => {
const surface = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.45);
const edge = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.65);
return {
...viewerStyles.viewer,
background: `linear-gradient(145deg, ${rgbToRgba(surface, 0.7)} 0%, ${rgbToRgba(edge, 0.92)} 100%)`,
backdropFilter: 'blur(28px)',
};
}, [baseTone]);
const controlsStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.52);
return {
...viewerStyles.controls,
background: rgbToRgba(tone, 0.74),
backdropFilter: 'blur(18px)',
};
}, [baseTone]);
const filmstripShellStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.56);
return {
...viewerStyles.filmstripShell,
background: rgbToRgba(tone, 0.7),
backdropFilter: 'blur(22px)',
};
}, [baseTone]);
const getThumbUrl = useCallback((item: VfsEntry) => {
const full = joinPath(directory, item.name);
return buildThumbUrl(full, 160, 120);
}, [directory]);
const sidePanelStyle = useMemo(() => {
const panel = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.6);
const border = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.1), 0.28);
return {
...viewerStyles.sidePanel,
background: rgbToRgba(panel, 0.8),
backdropFilter: 'blur(28px)',
borderLeft: `1px solid ${border}`,
};
}, [baseTone]);
const histogramCardStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.55);
const stroke = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.12), 0.2);
return {
...viewerStyles.histogramCard,
background: rgbToRgba(tone, 0.58),
border: `1px solid ${stroke}`,
};
}, [baseTone]);
useEffect(() => {
const normalized = filePath.startsWith('/') ? filePath : `/${filePath}`;
setActiveEntry(entry);
setActivePath(normalized);
}, [entry, filePath]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(undefined);
setHistogram(null);
setDominantColor(null);
const cleaned = activePath.replace(/^\/+/, '');
Promise.all([
vfsApi.getTempLinkToken(cleaned),
vfsApi.stat(activePath) as Promise<FileStat>,
])
.then(([token, metadata]) => {
if (cancelled) return;
setImageUrl(vfsApi.getTempPublicUrl(token.token));
setStat(metadata);
setScale(1);
setRotate(0);
setOffset({ x: 0, y: 0 });
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activePath]);
const refreshFilmstrip = useCallback((dir: string) => {
const snap = readExplorerSnapshot(dir);
if (snap) {
const images = snap.entries.filter(isImageEntry);
const ensured = images.some(item => item.name === activeEntry.name) ? images : [...images, activeEntry];
setFilmstrip(ensured);
if (snap.pagination) {
setPageInfo({
page: snap.pagination.page,
pageSize: snap.pagination.page_size,
total: snap.pagination.total,
});
} else {
setPageInfo(null);
}
return;
}
setFilmstrip([activeEntry]);
setPageInfo(null);
}, [activeEntry]);
useEffect(() => {
refreshFilmstrip(directory);
}, [directory, refreshFilmstrip]);
useEffect(() => {
const handler = () => refreshFilmstrip(directory);
window.addEventListener('foxel:file-explorer-page', handler);
return () => window.removeEventListener('foxel:file-explorer-page', handler);
}, [directory, refreshFilmstrip]);
useEffect(() => {
const el = filmstripRefs.current[activeEntry.name];
if (el) {
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}, [activeEntry, filmstrip]);
useEffect(() => {
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
switchRelative(1);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
switchRelative(-1);
} else if ((e.key === '+' || e.key === '=') && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
zoom(1.15);
} else if ((e.key === '-' || e.key === '_') && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
zoom(0.85);
}
};
window.addEventListener('keydown', keyHandler);
return () => window.removeEventListener('keydown', keyHandler);
});
const zoom = useCallback((factor: number) => {
setScale(prev => {
const next = clamp(prev * factor, 0.08, 10);
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 120);
return next;
});
}, []);
const rotateImage = () => {
setRotate(prev => {
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 180);
return (prev + 90) % 360;
});
};
const resetView = () => {
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 160);
setScale(1);
setOffset({ x: 0, y: 0 });
setRotate(0);
};
const fitToScreen = () => {
resetView();
};
const onWheel = (e: React.WheelEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
setScale(prev => {
const factor = e.deltaY < 0 ? 1.12 : 0.88;
const next = clamp(prev * factor, 0.08, 10);
const ratio = next / prev;
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 120);
return next;
});
};
const onMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
setIsDragging(true);
dragPointRef.current = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !dragPointRef.current) return;
e.preventDefault();
const dx = e.clientX - dragPointRef.current.x;
const dy = e.clientY - dragPointRef.current.y;
dragPointRef.current = { x: e.clientX, y: e.clientY };
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
};
const stopDragging = () => {
setIsDragging(false);
dragPointRef.current = null;
};
const dist = (t1: React.Touch, t2: React.Touch) => Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
const onTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 1) {
const t = e.touches[0];
dragPointRef.current = { x: t.clientX, y: t.clientY };
} else if (e.touches.length === 2) {
pinchDistanceRef.current = dist(e.touches[0], e.touches[1]);
}
};
const onTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 1 && dragPointRef.current) {
const t = e.touches[0];
const dx = t.clientX - dragPointRef.current.x;
const dy = t.clientY - dragPointRef.current.y;
dragPointRef.current = { x: t.clientX, y: t.clientY };
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
} else if (e.touches.length === 2 && pinchDistanceRef.current) {
const dNow = dist(e.touches[0], e.touches[1]);
const ratio = dNow / pinchDistanceRef.current;
pinchDistanceRef.current = dNow;
setScale(prev => clamp(prev * ratio, 0.08, 10));
}
};
const onTouchEnd = () => {
pinchDistanceRef.current = null;
dragPointRef.current = null;
};
const onDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
const next = scale > 1.4 ? 1 : 2.2;
const container = containerRef.current;
if (!container) {
setScale(next);
return;
}
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
const ratio = next / scale;
setScale(next);
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
};
const handleImageLoaded = () => {
const img = imageRef.current;
if (!img) return;
const stats = computeImageStats(img);
setHistogram(stats.histogram);
setDominantColor(stats.dominantColor);
};
const switchEntry = (target: VfsEntry) => {
const nextPath = joinPath(directory, target.name);
setActiveEntry(target);
setActivePath(nextPath);
};
const switchRelative = (step: number) => {
if (filmstrip.length <= 1) return;
const currentIndex = filmstrip.findIndex(item => item.name === activeEntry.name);
if (currentIndex === -1) return;
const target = filmstrip[(currentIndex + step + filmstrip.length) % filmstrip.length];
if (target) switchEntry(target);
};
const scaleLabel = `${(scale * 100).toFixed(scale >= 1 ? 0 : 1)}%`;
const imageStyle: React.CSSProperties = {
maxWidth: '100%',
maxHeight: '100%',
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
cursor: isDragging ? 'grabbing' : scale > 1 ? 'grab' : 'zoom-in',
willChange: 'transform',
};
const controlsNode = (
<ViewerControls
style={controlsStyle}
onPrev={() => switchRelative(-1)}
onNext={() => switchRelative(1)}
onZoomIn={() => zoom(1.18)}
onZoomOut={() => zoom(0.82)}
onRotate={rotateImage}
onReset={resetView}
onFit={fitToScreen}
disableSwitch={filmstrip.length <= 1}
/>
);
const exif = (stat?.exif ?? {}) as Record<string, unknown>;
const infoIconStyle: React.CSSProperties = { fontSize: 15, color: 'rgba(255,255,255,0.62)' };
const exifValue = (key: string): string | number | null => {
const value = exif[key];
if (typeof value === 'string' || typeof value === 'number') return value;
return null;
};
const focalLength = (() => {
const v = parseNumberish(exifValue('37386') ?? exifValue('37377'));
return v ? `${v.toFixed(1)} mm` : null;
})();
const aperture = (() => {
const v = parseNumberish(exifValue('33437') ?? exifValue('37378'));
return v ? `f/${v.toFixed(1)}` : null;
})();
const exposure = (() => {
const v = parseNumberish(exifValue('33434'));
if (!v) return null;
if (v >= 1) return `${v.toFixed(1)} s`;
const denom = Math.max(1, Math.round(1 / v));
return `1/${denom}`;
})();
const isoValue = exifValue('34855') ?? exifValue('34864');
const width = parseNumberish(exifValue('40962'));
const height = parseNumberish(exifValue('40963'));
const colorSpace = exifValue('40961');
const cameraMake = exifValue('271');
const cameraModel = exifValue('272');
const lensModel = exifValue('42036');
const captureTime = exifValue('36867') ?? exifValue('36868') ?? exifValue('306');
const basicList: InfoItem[] = [
{ label: '文件名', value: activeEntry.name, icon: <FileOutlined style={infoIconStyle} /> },
{ label: '文件大小', value: humanFileSize(stat?.size), icon: <DatabaseOutlined style={infoIconStyle} /> },
{ label: '分辨率', value: width && height ? `${width} × ${height}` : null, icon: <ExpandOutlined style={infoIconStyle} /> },
{ label: '颜色空间', value: colorSpace ?? null, icon: <BgColorsOutlined style={infoIconStyle} /> },
{ label: '修改时间', value: stat?.mtime ? formatDateTime(stat.mtime) : null, icon: <ClockCircleOutlined style={infoIconStyle} /> },
{ label: '路径', value: typeof stat?.path === 'string' ? stat.path : activePath, icon: <FolderOutlined style={infoIconStyle} /> },
];
const shootingList: InfoItem[] = [
{ label: '焦距', value: focalLength, icon: <AimOutlined style={infoIconStyle} /> },
{ label: '光圈', value: aperture, icon: <BulbOutlined style={infoIconStyle} /> },
{ label: '快门', value: exposure, icon: <ThunderboltOutlined style={infoIconStyle} /> },
{ label: 'ISO', value: isoValue != null ? isoValue.toString() : null, icon: <AlertOutlined style={infoIconStyle} /> },
];
const deviceList: InfoItem[] = [
{
label: '相机',
value: cameraModel ? `${cameraMake ? `${cameraMake} ` : ''}${cameraModel}` : (cameraMake ?? null),
icon: <CameraOutlined style={infoIconStyle} />,
},
{ label: '镜头', value: lensModel ?? null, icon: <ApiOutlined style={infoIconStyle} /> },
];
const miscList: InfoItem[] = [
{ label: '拍摄时间', value: captureTime, icon: <FieldTimeOutlined style={infoIconStyle} /> },
];
return (
<div style={containerStyle}>
<section style={viewerStyles.main}>
<div style={mainBackdropStyle} />
<div style={viewerStyles.mainContent}>
<ImageCanvas
containerRef={containerRef}
imageRef={imageRef}
viewerStyle={viewerStyle}
controls={controlsNode}
scaleLabel={scaleLabel}
imageStyle={imageStyle}
loading={loading}
error={error}
imageUrl={imageUrl}
activeEntry={activeEntry}
onRequestClose={onRequestClose}
onImageLoad={handleImageLoaded}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseLeave={stopDragging}
onMouseUp={stopDragging}
onDoubleClick={onDoubleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
<Filmstrip
shellStyle={filmstripShellStyle}
listStyle={viewerStyles.filmstrip}
entries={filmstrip}
activeEntry={activeEntry}
onSelect={switchEntry}
filmstripRefs={filmstripRefs}
pageInfo={pageInfo}
getThumbUrl={getThumbUrl}
/>
</div>
</section>
<InfoPanel
style={sidePanelStyle}
histogramCardStyle={histogramCardStyle}
title={activeEntry.name}
captureTime={captureTime ?? null}
basicList={basicList}
shootingList={shootingList}
deviceList={deviceList}
miscList={miscList}
histogram={histogram}
/>
</div>
);
};

View File

@@ -1,94 +0,0 @@
import React from 'react';
import { Typography } from 'antd';
import type { VfsEntry } from '../../../api/client';
interface PageInfo {
page: number;
total: number;
pageSize: number;
}
interface FilmstripProps {
shellStyle: React.CSSProperties;
listStyle: React.CSSProperties;
entries: VfsEntry[];
activeEntry: VfsEntry;
onSelect: (entry: VfsEntry) => void;
filmstripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
pageInfo: PageInfo | null;
getThumbUrl: (entry: VfsEntry) => string;
}
export const Filmstrip: React.FC<FilmstripProps> = ({
shellStyle,
listStyle,
entries,
activeEntry,
onSelect,
filmstripRefs,
pageInfo,
getThumbUrl,
}) => (
<div style={shellStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
· {entries.length}
</Typography.Text>
{pageInfo && (
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>
{pageInfo.page} / {Math.max(1, Math.ceil(pageInfo.total / pageInfo.pageSize))}
</Typography.Text>
)}
</div>
<div style={listStyle}>
{entries.map(item => {
const active = item.name === activeEntry.name;
return (
<div
key={`${item.name}-${item.mtime ?? ''}`}
ref={el => { filmstripRefs.current[item.name] = el; }}
onClick={() => onSelect(item)}
style={{
width: 84,
height: 64,
overflow: 'hidden',
border: active ? '2px solid #4e9bff' : '2px solid transparent',
boxShadow: active ? '0 0 0 4px rgba(78,155,255,0.28)' : '0 10px 28px rgba(0,0,0,0.45)',
cursor: 'pointer',
position: 'relative',
flex: '0 0 auto',
}}
>
<img
src={getThumbUrl(item)}
alt={item.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: active ? 'saturate(1)' : 'saturate(0.65)' }}
/>
{active && (
<div
style={{
position: 'absolute',
bottom: 4,
left: 6,
right: 6,
padding: '2px 4px',
background: 'rgba(0,0,0,0.55)',
color: '#fff',
fontSize: 10,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.name}
</div>
)}
</div>
);
})}
{entries.length === 0 && (
<div style={{ color: 'rgba(255,255,255,0.45)' }}></div>
)}
</div>
</div>
);

View File

@@ -1,99 +0,0 @@
import React from 'react';
import { Spin, Typography, Tooltip, Button } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { viewerStyles } from '../styles';
interface ImageCanvasProps {
containerRef: React.RefObject<HTMLDivElement | null>;
imageRef: React.RefObject<HTMLImageElement | null>;
viewerStyle: React.CSSProperties;
controls: React.ReactNode;
scaleLabel: string;
imageStyle: React.CSSProperties;
loading: boolean;
error?: string;
imageUrl?: string;
activeEntry: VfsEntry;
onRequestClose: () => void;
onImageLoad: () => void;
onWheel: React.WheelEventHandler<HTMLDivElement>;
onMouseDown: React.MouseEventHandler<HTMLDivElement>;
onMouseMove: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave: React.MouseEventHandler<HTMLDivElement>;
onMouseUp: React.MouseEventHandler<HTMLDivElement>;
onDoubleClick: React.MouseEventHandler<HTMLDivElement>;
onTouchStart: React.TouchEventHandler<HTMLDivElement>;
onTouchMove: React.TouchEventHandler<HTMLDivElement>;
onTouchEnd: React.TouchEventHandler<HTMLDivElement>;
}
export const ImageCanvas: React.FC<ImageCanvasProps> = ({
containerRef,
imageRef,
viewerStyle,
controls,
scaleLabel,
imageStyle,
loading,
error,
imageUrl,
activeEntry,
onRequestClose,
onImageLoad,
onWheel,
onMouseDown,
onMouseMove,
onMouseLeave,
onMouseUp,
onDoubleClick,
onTouchStart,
onTouchMove,
onTouchEnd,
}) => (
<div
ref={containerRef}
style={viewerStyle}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onDoubleClick={onDoubleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<div style={viewerStyles.viewerCloseWrap}>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onRequestClose}
style={viewerStyles.viewerClose}
/>
</Tooltip>
</div>
{loading ? (
<Spin tip="加载中" />
) : error ? (
<Typography.Text type="danger">{error}</Typography.Text>
) : imageUrl ? (
<img
ref={imageRef}
src={imageUrl}
alt={activeEntry.name}
onLoad={onImageLoad}
draggable={false}
crossOrigin="anonymous"
style={imageStyle}
/>
) : (
<Typography.Text></Typography.Text>
)}
<div style={viewerStyles.scaleBadge}>{scaleLabel}</div>
{controls}
</div>
);

View File

@@ -1,116 +0,0 @@
import React from 'react';
import { Typography, Empty } from 'antd';
import type { HistogramData, InfoItem } from './types';
interface InfoPanelProps {
style: React.CSSProperties;
histogramCardStyle: React.CSSProperties;
title: string;
captureTime: string | number | null;
basicList: InfoItem[];
shootingList: InfoItem[];
deviceList: InfoItem[];
miscList: InfoItem[];
histogram: HistogramData | null;
}
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Typography.Title level={5} style={{ color: '#fff', fontSize: 15, marginTop: 24, marginBottom: 12 }}>
{children}
</Typography.Title>
);
const HistogramPlot: React.FC<{ data: HistogramData | null }> = ({ data }) => {
if (!data) {
return <Empty description="无法解析直方图" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
const width = 260;
const height = 140;
const max = Math.max(...data.r, ...data.g, ...data.b, 1);
const toPath = (arr: number[]) => arr
.map((value, index) => {
const x = (index / 255) * width;
const y = height - (value / max) * height;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ width: '100%' }}>
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.04)" />
<path d={toPath(data.r)} stroke="rgba(255,99,132,0.88)" fill="none" strokeWidth={1.3} />
<path d={toPath(data.g)} stroke="rgba(75,192,192,0.88)" fill="none" strokeWidth={1.3} />
<path d={toPath(data.b)} stroke="rgba(54,162,235,0.88)" fill="none" strokeWidth={1.3} />
</svg>
);
};
const InfoRows: React.FC<{ items: InfoItem[] }> = ({ items }) => (
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', rowGap: 10, columnGap: 12 }}>
{items
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
.map(item => (
<React.Fragment key={item.label}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.55)' }}>
{item.icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{item.icon}</span>}
<span>{item.label}</span>
</span>
<span style={{ color: '#fff', wordBreak: 'break-all' }}>{item.value}</span>
</React.Fragment>
))}
</div>
);
export const InfoPanel: React.FC<InfoPanelProps> = ({
style,
histogramCardStyle,
title,
captureTime,
basicList,
shootingList,
deviceList,
miscList,
histogram,
}) => (
<aside style={style}>
<Typography.Title level={3} style={{ color: '#fff', marginTop: 6, wordBreak: 'break-all' }}>
{title}
</Typography.Title>
{captureTime && (
<Typography.Text style={{ color: 'rgba(255,255,255,0.6)' }}> {captureTime}</Typography.Text>
)}
<SectionTitle></SectionTitle>
<InfoRows items={basicList} />
{shootingList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={shootingList} />
</>
)}
{deviceList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={deviceList} />
</>
)}
{miscList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={miscList} />
</>
)}
<SectionTitle></SectionTitle>
<div style={histogramCardStyle}>
<HistogramPlot data={histogram} />
<div style={{ marginTop: 12, display: 'flex', gap: 12, fontSize: 12 }}>
<span style={{ color: 'rgba(255,99,132,0.88)' }}>R</span>
<span style={{ color: 'rgba(75,192,192,0.88)' }}>G</span>
<span style={{ color: 'rgba(54,162,235,0.88)' }}>B</span>
</div>
</div>
</aside>
);

View File

@@ -1,73 +0,0 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import {
LeftOutlined,
RightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
RotateRightOutlined,
ReloadOutlined,
CompressOutlined,
} from '@ant-design/icons';
interface ViewerControlsProps {
style: React.CSSProperties;
onPrev: () => void;
onNext: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onRotate: () => void;
onReset: () => void;
onFit: () => void;
disableSwitch: boolean;
}
export const ViewerControls: React.FC<ViewerControlsProps> = ({
style,
onPrev,
onNext,
onZoomIn,
onZoomOut,
onRotate,
onReset,
onFit,
disableSwitch,
}) => (
<div style={style}>
<Tooltip title="上一张">
<Button
shape="circle"
type="text"
icon={<LeftOutlined />}
onClick={onPrev}
disabled={disableSwitch}
style={{ color: '#fff' }}
/>
</Tooltip>
<Tooltip title="缩小">
<Button shape="circle" type="text" icon={<ZoomOutOutlined />} onClick={onZoomOut} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="放大">
<Button shape="circle" type="text" icon={<ZoomInOutlined />} onClick={onZoomIn} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="旋转 90°">
<Button shape="circle" type="text" icon={<RotateRightOutlined />} onClick={onRotate} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="重置">
<Button shape="circle" type="text" icon={<ReloadOutlined />} onClick={onReset} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="适应窗口">
<Button shape="circle" type="text" icon={<CompressOutlined />} onClick={onFit} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="下一张">
<Button
shape="circle"
type="text"
icon={<RightOutlined />}
onClick={onNext}
disabled={disableSwitch}
style={{ color: '#fff' }}
/>
</Tooltip>
</div>
);

View File

@@ -1,19 +0,0 @@
import type { ReactNode } from 'react';
export interface HistogramData {
r: number[];
g: number[];
b: number[];
}
export interface RgbColor {
r: number;
g: number;
b: number;
}
export interface InfoItem {
label: string;
value: string | number | null;
icon?: ReactNode;
}

View File

@@ -1,23 +0,0 @@
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 supportedExts.includes(ext);
},
component: ImageViewerApp,
default: true,
defaultMaximized:true,
useSystemWindow:false,
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
};

View File

@@ -1,106 +0,0 @@
export const viewerStyles = {
container: {
width: '100%',
height: '100%',
boxSizing: 'border-box' as const,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
columnGap: 0,
color: '#fff',
overflow: 'hidden',
},
main: {
position: 'relative' as const,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column' as const,
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
minHeight: 0,
},
mainBackdrop: {
position: 'absolute' as const,
inset: 0,
},
mainContent: {
position: 'relative' as const,
zIndex: 1,
display: 'flex',
flexDirection: 'column' as const,
flex: 1,
padding: 0,
minHeight: 0,
minWidth: 0,
},
viewer: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative' as const,
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
touchAction: 'none' as const,
minHeight: 0,
},
controls: {
position: 'absolute' as const,
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 16,
padding: '8px 18px',
borderRadius: 24,
alignItems: 'center',
},
scaleBadge: {
position: 'absolute' as const,
bottom: 64,
left: 16,
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
letterSpacing: 0.2,
},
filmstripShell: {
marginTop: 0,
padding: '3px 12px',
boxShadow: '0 16px 42px rgba(0,0,0,0.52)',
},
filmstrip: {
display: 'flex',
overflowX: 'auto' as const,
gap: 12,
paddingBottom: 4,
},
sidePanel: {
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
padding: '20px 24px',
display: 'flex',
flexDirection: 'column' as const,
overflowY: 'auto' as const,
minHeight: 0,
},
histogramCard: {
padding: '12px 12px 18px',
background: 'rgba(0,0,0,0.34)',
borderRadius: 0,
},
viewerCloseWrap: {
position: 'absolute' as const,
top: 16,
right: 16,
zIndex: 2,
},
viewerClose: {
color: '#fff',
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 8px 18px rgba(0,0,0,0.45)',
borderRadius: '100%',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
};

View File

@@ -1,83 +0,0 @@
import React, { useEffect, useState } from 'react';
import { vfsApi } from '../../api/client';
import type { AppComponentProps } from '../types';
import { Spin, Result, Button } from 'antd';
import { useSystemStatus } from '../../contexts/SystemContext';
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
const systemStatus = useSystemStatus();
const fileDomain = systemStatus?.file_domain;
const [url, setUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
useEffect(() => {
let cancelled = false;
setLoading(true);
setErr(undefined);
setUrl(undefined);
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
.then(res => {
if (cancelled) return;
const baseUrl = fileDomain || window.location.origin;
const fullUrl = new URL(res.url, baseUrl).href;
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
setUrl(officeUrl);
})
.catch(e => {
if (!cancelled) {
setErr(e.message || '加载文档链接失败');
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [filePath, fileDomain]);
if (loading) {
return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="正在准备文档..." />
</div>
);
}
if (err) {
return (
<Result
status="error"
title="无法加载文档"
subTitle={err}
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
);
}
return (
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
{url ? (
<iframe
src={url}
width="100%"
height="100%"
frameBorder="0"
title="Office Document Viewer"
/>
) : (
<Result
status="warning"
title="文档链接无效"
subTitle="未能成功生成文档的在线查看链接。"
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
)}
</div>
);
};

View File

@@ -1,21 +0,0 @@
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 supportedExts.includes(ext);
},
component: OfficeViewerApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
};

View File

@@ -1,74 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Spin, Result, Button } from 'antd';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/client';
export const PdfViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
const [url, setUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
useEffect(() => {
let cancelled = false;
setLoading(true);
setErr(undefined);
setUrl(undefined);
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
.then(res => {
if (cancelled) return;
const publicUrl = vfsApi.getTempPublicUrl(res.token);
setUrl(publicUrl + '#toolbar=1&navpanes=1');
})
.catch(e => {
if (!cancelled) setErr(e.message || '获取临时链接失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [filePath]);
if (loading) {
return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="正在加载 PDF..." />
</div>
);
}
if (err) {
return (
<Result
status="error"
title="无法加载 PDF"
subTitle={err}
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
);
}
if (!url) {
return (
<Result
status="warning"
title="无可用链接"
subTitle="未能生成 PDF 的临时访问链接"
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
);
}
return (
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
<iframe
src={url}
width="100%"
height="100%"
title="PDF Viewer"
style={{ border: 'none' }}
/>
</div>
);
};

View File

@@ -1,21 +0,0 @@
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 supportedExts.includes(ext);
},
component: PdfViewerApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
};

View File

@@ -1,108 +1,109 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import type { AppComponentProps, AppOpenComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import { loadPlugin, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
import type { PluginItem } from '../../api/plugins';
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
import { useI18n } from '../../i18n';
export interface PluginAppHostProps extends AppComponentProps {
plugin: PluginItem;
}
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
function buildPluginFrameUrl(params: Record<string, string>): string {
const qs = new URLSearchParams(params);
return `/plugin-frame.html?${qs.toString()}`;
}
/**
* 插件宿主组件 - 文件打开模式
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
*/
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
plugin,
filePath,
onRequestClose,
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
const { t } = useI18n();
const pluginRef = useRef<RegisteredPlugin | null>(null);
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPlugin(plugin);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
if (isDisposed()) return;
const token = await vfsApi.getTempLinkToken(filePath);
if (isDisposed()) return;
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
if (isDisposed() || !containerRef.current) return;
await p.mount(containerRef.current, {
filePath,
entry,
urls: { downloadUrl },
host: { close: () => onCloseRef.current() },
});
} catch (e: any) {
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
}
},
[plugin.id, plugin.url, filePath],
() => {
try {
if (pluginRef.current?.unmount && containerRef.current) {
pluginRef.current.unmount(containerRef.current);
}
} catch { void 0; }
},
const src = useMemo(
() =>
buildPluginFrameUrl({
pluginKey: plugin.key,
mode: 'file',
filePath,
}),
[plugin.key, filePath]
);
if (error) {
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
}
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
if (ev.origin !== window.location.origin) return;
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as any;
if (!data || typeof data !== 'object') return;
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
onCloseRef.current();
}
};
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [plugin.key]);
return (
<iframe
ref={iframeRef}
src={src}
title={`plugin:${plugin.key}`}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
);
};
export interface PluginAppOpenHostProps extends AppOpenComponentProps {
plugin: PluginItem;
}
/**
* 插件宿主组件 - 独立应用模式
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
*/
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
const { t } = useI18n();
const pluginRef = useRef<RegisteredPlugin | null>(null);
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPlugin(plugin);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
if (isDisposed() || !containerRef.current) return;
if (typeof p.mountApp !== 'function') {
throw new Error('该插件不支持独立打开');
}
await p.mountApp(containerRef.current, {
host: { close: () => onCloseRef.current() },
});
} catch (e: any) {
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
}
},
[plugin.id, plugin.url],
() => {
try {
if (!containerRef.current) return;
const p = pluginRef.current;
if (p?.unmountApp) return p.unmountApp(containerRef.current);
if (p?.unmount) return p.unmount(containerRef.current);
} catch { void 0; }
},
const src = useMemo(
() =>
buildPluginFrameUrl({
pluginKey: plugin.key,
mode: 'app',
}),
[plugin.key]
);
if (error) {
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
}
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
if (ev.origin !== window.location.origin) return;
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as any;
if (!data || typeof data !== 'object') return;
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
onCloseRef.current();
}
};
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [plugin.key]);
return (
<iframe
ref={iframeRef}
src={src}
title={`plugin:${plugin.key}:app`}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
);
};

View File

@@ -1,274 +0,0 @@
import React, { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
import { Layout, Spin, Button, Space, message } from 'antd';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import request from '../../api/client';
const MonacoEditor = React.lazy(() => import('@monaco-editor/react'));
const MarkdownEditor = React.lazy(() => import('@uiw/react-md-editor'));
const { Header, Content } = Layout;
const MAX_PREVIEW_BYTES = 1024 * 1024; // 1MB
export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [content, setContent] = useState('');
const [initialContent, setInitialContent] = useState('');
const [truncated, setTruncated] = useState(false);
const isDirty = content !== initialContent;
const onRequestCloseRef = useRef(onRequestClose);
onRequestCloseRef.current = onRequestClose;
const ext = useMemo(() => entry.name.split('.').pop()?.toLowerCase() || '', [entry.name]);
const isMarkdown = ext === 'md' || ext === 'markdown';
const monacoLanguage = useMemo(() => {
switch (ext) {
// Web technologies
case 'js':
case 'jsx':
return 'javascript';
case 'ts':
case 'tsx':
return 'typescript';
case 'html':
case 'htm':
return 'html';
case 'css':
return 'css';
case 'scss':
case 'sass':
return 'scss';
case 'less':
return 'less';
case 'vue':
return 'html'; // Vue files are primarily HTML with some JS/TS
// Data formats
case 'json':
return 'json';
case 'yaml':
case 'yml':
return 'yaml';
case 'xml':
return 'xml';
case 'toml':
return 'ini'; // TOML is similar to INI
case 'ini':
case 'cfg':
case 'conf':
return 'ini';
// Programming languages
case 'py':
return 'python';
case 'java':
return 'java';
case 'c':
return 'c';
case 'cpp':
case 'cc':
case 'cxx':
return 'cpp';
case 'h':
case 'hpp':
case 'hxx':
return 'cpp'; // Header files use C++ highlighting
case 'php':
return 'php';
case 'rb':
return 'ruby';
case 'go':
return 'go';
case 'rs':
return 'rust';
case 'swift':
return 'swift';
case 'kt':
return 'kotlin';
case 'scala':
return 'scala';
case 'cs':
return 'csharp';
case 'fs':
return 'fsharp';
case 'vb':
return 'vb';
case 'pl':
case 'pm':
return 'perl';
case 'r':
return 'r';
case 'lua':
return 'lua';
case 'dart':
return 'dart';
// Database
case 'sql':
return 'sql';
// Shell and scripts
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return 'shell';
case 'ps1':
return 'powershell';
case 'bat':
case 'cmd':
return 'bat';
// Build and config files
case 'dockerfile':
return 'dockerfile';
case 'makefile':
return 'makefile';
case 'gradle':
return 'groovy';
case 'cmake':
return 'cmake';
// Markdown
case 'md':
case 'markdown':
return 'markdown';
// Plain text and logs
case 'txt':
case 'log':
case 'gitignore':
case 'gitattributes':
case 'editorconfig':
case 'prettierrc':
default:
return 'plaintext';
}
}, [ext]);
useEffect(() => {
const loadFile = async () => {
try {
setLoading(true);
setTruncated(false);
const shouldTruncate = (entry.size ?? 0) > MAX_PREVIEW_BYTES;
if (shouldTruncate) {
const enc = encodeURI(filePath.replace(/^\/+/, ''));
const resp = await request(`/fs/file/${enc}`, {
method: 'GET',
headers: { Range: `bytes=0-${MAX_PREVIEW_BYTES - 1}` },
rawResponse: true,
});
const buf = await (resp as Response).arrayBuffer();
const text = new TextDecoder().decode(buf);
setContent(text);
setInitialContent(text);
setTruncated(true);
} else {
const data = await vfsApi.readFile(filePath);
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
setContent(text);
setInitialContent(text);
}
} catch (error) {
message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
onRequestCloseRef.current();
} finally {
setLoading(false);
}
};
loadFile();
}, [filePath, entry.size]);
const handleSave = useCallback(async () => {
if (truncated) {
message.warning('大文件仅预览前 1MB已禁用保存');
return;
}
if (!isDirty) return;
try {
setSaving(true);
const blob = new Blob([content], { type: 'text/plain' });
await vfsApi.uploadFile(filePath, blob);
setInitialContent(content);
message.success('保存成功');
} catch (error) {
message.error(`保存文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setSaving(false);
}
}, [content, filePath, isDirty, truncated]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleSave]);
return (
<Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
<Header
style={{
background: 'var(--ant-color-bg-layout, #f0f2f5)',
padding: '0 16px',
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--ant-color-border-secondary, #d9d9d9)'
}}
>
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
{entry.name} {isDirty && '*'} {truncated && '(大文件仅预览前 1MB编辑与保存已禁用'}
</span>
<Space>
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty || truncated}>
</Button>
</Space>
</Header>
<Content style={{ position: 'relative', overflow: 'auto', height: 'calc(100% - 40px)' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin />
</div>
) : (
isMarkdown ? (
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
<MarkdownEditor
value={content}
onChange={(val) => setContent(val || '')}
height="100%"
preview={truncated ? 'preview' : 'live'}
/>
</Suspense>
) : (
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
<MonacoEditor
value={content}
onChange={(val) => setContent(val || '')}
height="100%"
language={monacoLanguage}
options={{
readOnly: truncated,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 13,
}}
/>
</Suspense>
)
)}
</Content>
</Layout>
);
};

View File

@@ -1,41 +0,0 @@
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 supportedExts.includes(ext);
},
component: TextEditorApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
};

View File

@@ -1,708 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Button,
Card,
Collapse,
Descriptions,
Divider,
Drawer,
Empty,
Flex,
Image,
Input,
List,
Segmented,
Skeleton,
Space,
Tabs,
Tag,
Typography,
message,
theme,
} from 'antd';
import { PlayCircleOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { AppOpenComponentProps } from '../types';
import { videoLibraryApi, type VideoLibraryItem } from '../../api/videoLibrary';
import { useI18n } from '../../i18n';
import { ensureAppsLoaded, getAppByKey } from '../registry';
import { useAppWindows } from '../../contexts/AppWindowsContext';
import type { VfsEntry } from '../../api/client';
type LibraryFilter = 'all' | 'tv' | 'movie';
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/';
function tmdbImage(path: string | null | undefined, size: string) {
if (!path) return undefined;
return `${TMDB_IMAGE_BASE}${size}${path}`;
}
function splitAbsolutePath(fullPath: string): { dir: string; name: string } | null {
const normalized = fullPath.replace(/\/+$/, '');
const idx = normalized.lastIndexOf('/');
if (idx < 0) return null;
const dir = idx === 0 ? '/' : normalized.slice(0, idx);
const name = normalized.slice(idx + 1);
if (!name) return null;
return { dir, name };
}
export const VideoLibraryApp: React.FC<AppOpenComponentProps> = () => {
const { token } = theme.useToken();
const { t } = useI18n();
const { openWithApp } = useAppWindows();
const [q, setQ] = useState('');
const [filter, setFilter] = useState<LibraryFilter>('all');
const [items, setItems] = useState<VideoLibraryItem[]>([]);
const [loading, setLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [selected, setSelected] = useState<VideoLibraryItem | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<any | null>(null);
const loadLibrary = useCallback(async () => {
setLoading(true);
try {
const list = await videoLibraryApi.list();
setItems(list);
} catch (err: any) {
setItems([]);
const msg = err instanceof Error ? err.message : t('Load failed');
message.error(msg);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadLibrary();
}, [loadLibrary]);
const stats = useMemo(() => {
let tv = 0;
let movie = 0;
items.forEach((it) => {
if (it.type === 'tv') tv += 1;
if (it.type === 'movie') movie += 1;
});
return { total: items.length, tv, movie };
}, [items]);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
return items.filter((v) => {
if (filter !== 'all' && v.type !== filter) return false;
if (!s) return true;
const haystack = `${v.title || ''} ${v.overview || ''} ${(v.genres || []).join(' ')}`.toLowerCase();
return haystack.includes(s);
});
}, [filter, items, q]);
const playByPath = useCallback(async (fullPath: string) => {
const splitted = splitAbsolutePath(fullPath);
if (!splitted) return;
await ensureAppsLoaded();
const app = getAppByKey('video-player');
if (!app) {
message.error(t('App "{key}" not found.', { key: 'video-player' }));
return;
}
const entry: VfsEntry = { name: splitted.name, is_dir: false, size: 0, mtime: 0 };
openWithApp(entry, app, splitted.dir);
}, [openWithApp, t]);
const fetchDetail = useCallback(async (item: VideoLibraryItem) => {
setDetailLoading(true);
setDetail(null);
try {
const payload = await videoLibraryApi.get(item.id);
setDetail(payload);
} catch (err: any) {
setDetail(null);
const msg = err instanceof Error ? err.message : t('Load failed');
message.error(msg);
} finally {
setDetailLoading(false);
}
}, [t]);
const openDetail = (item: VideoLibraryItem) => {
setSelected(item);
setDetailOpen(true);
fetchDetail(item);
};
const closeDetail = () => {
setDetailOpen(false);
setSelected(null);
setDetail(null);
};
const renderCover = (item: VideoLibraryItem) => {
const label = item.type === 'tv' ? 'TV' : 'Movie';
const coverUrl = tmdbImage(item.poster_path, 'w342') || tmdbImage(item.backdrop_path, 'w780');
return (
<div
style={{
position: 'relative',
width: '100%',
aspectRatio: '2 / 3',
background: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
overflow: 'hidden',
}}
>
{coverUrl ? (
<Image
src={coverUrl}
alt={item.title || label}
preview={false}
wrapperStyle={{ width: '100%', height: '100%', display: 'block' }}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
fallback="/logo.svg"
/>
) : (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.7)',
}}
>
<VideoCameraOutlined style={{ fontSize: 28 }} />
</div>
)}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(0,0,0,0.48) 0%, rgba(0,0,0,0.12) 40%, rgba(0,0,0,0.45) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 10,
left: 10,
right: 10,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
pointerEvents: 'none',
}}
>
<Tag color={item.type === 'tv' ? 'geekblue' : 'gold'} style={{ marginInlineEnd: 0 }}>
{label}
</Tag>
<VideoCameraOutlined style={{ fontSize: 18, opacity: 0.9, color: 'rgba(255,255,255,0.9)' }} />
</div>
<div
style={{
position: 'absolute',
left: 10,
bottom: 10,
width: 36,
height: 36,
borderRadius: 999,
background: 'rgba(2,6,23,0.55)',
border: '1px solid rgba(255,255,255,0.22)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.92)',
boxShadow: token.boxShadowTertiary,
pointerEvents: 'none',
}}
>
<PlayCircleOutlined style={{ fontSize: 20 }} />
</div>
</div>
);
};
const detailTitle = useMemo(() => {
const d = detail?.tmdb?.detail;
if (!d) return selected?.title || '';
if (detail?.type === 'tv') return d.name || d.original_name || selected?.title || '';
return d.title || d.original_title || selected?.title || '';
}, [detail, selected?.title]);
const detailPosterUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.poster_path, 'w342'), [detail]);
const detailBackdropUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.backdrop_path, 'w1280'), [detail]);
const detailGenres = useMemo(() => {
const genres = detail?.tmdb?.detail?.genres;
if (!Array.isArray(genres)) return [];
return genres.map((g: any) => g?.name).filter(Boolean);
}, [detail]);
const detailOverview = useMemo(() => {
const overview = detail?.tmdb?.detail?.overview;
if (!overview) return '';
return String(overview);
}, [detail]);
const castTop = useMemo(() => {
const cast = detail?.tmdb?.detail?.credits?.cast;
if (!Array.isArray(cast)) return [];
return cast.slice(0, 12);
}, [detail]);
const episodesBySeason = useMemo(() => {
if (detail?.type !== 'tv') return [];
const episodes = Array.isArray(detail?.episodes) ? detail.episodes : [];
const map = new Map<number, any[]>();
episodes.forEach((ep: any) => {
const season = typeof ep?.season === 'number' ? ep.season : 1;
if (!map.has(season)) map.set(season, []);
map.get(season)!.push(ep);
});
const seasons = Array.from(map.keys()).sort((a, b) => a - b);
return seasons.map((season) => {
const list = (map.get(season) || []).slice().sort((a, b) => {
const ae = typeof a?.episode === 'number' ? a.episode : 10_000;
const be = typeof b?.episode === 'number' ? b.episode : 10_000;
return ae - be;
});
return { season, episodes: list };
});
}, [detail]);
const renderHero = () => {
const year = detail?.type === 'tv'
? (detail?.tmdb?.detail?.first_air_date || '').slice(0, 4)
: (detail?.tmdb?.detail?.release_date || '').slice(0, 4);
const vote = detail?.tmdb?.detail?.vote_average;
const voteText = typeof vote === 'number' ? vote.toFixed(1) : '--';
return (
<div
style={{
position: 'relative',
padding: 16,
minHeight: 220,
background: detailBackdropUrl
? `url(${detailBackdropUrl}) center / cover no-repeat`
: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, rgba(2,6,23,0.92) 0%, rgba(2,6,23,0.55) 55%, rgba(2,6,23,0.15) 100%)',
}}
/>
<div style={{ position: 'relative', display: 'flex', gap: 16, alignItems: 'flex-end' }}>
<div style={{ width: 132, flex: 'none' }}>
<div style={{ borderRadius: 12, overflow: 'hidden', boxShadow: token.boxShadowTertiary, border: '1px solid rgba(255,255,255,0.18)' }}>
{detailPosterUrl ? (
<Image
src={detailPosterUrl}
alt={detailTitle}
preview={false}
width={132}
height={198}
style={{ objectFit: 'cover', display: 'block' }}
fallback="/logo.svg"
/>
) : (
<div
style={{
width: 132,
height: 198,
background: 'rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.75)',
}}
>
<VideoCameraOutlined style={{ fontSize: 28 }} />
</div>
)}
</div>
</div>
<div style={{ minWidth: 0, flex: 1, color: 'rgba(255,255,255,0.92)' }}>
<Typography.Title level={3} style={{ margin: 0, color: 'rgba(255,255,255,0.92)' }} ellipsis>
{detailTitle}
</Typography.Title>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', color: 'rgba(255,255,255,0.72)' }}>
{year && <span>{year}</span>}
<span>·</span>
<span>TMDB {voteText}</span>
{detail?.type === 'tv' && (
<>
<span>·</span>
<span>{episodesBySeason.reduce((acc, s) => acc + s.episodes.length, 0)} {t('Episodes')}</span>
</>
)}
</div>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{(detailGenres || []).slice(0, 6).map((g: string) => (
<Tag key={g} color="geekblue" style={{ marginInlineEnd: 0 }}>
{g}
</Tag>
))}
</div>
{detail?.type === 'movie' && (
<div style={{ marginTop: 14 }}>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playByPath(String(detail?.source_path || ''))}
disabled={!detail?.source_path}
>
{t('Play')}
</Button>
</div>
)}
</div>
</div>
</div>
);
};
const renderMeta = () => {
if (!detail?.tmdb?.detail) return null;
const d = detail.tmdb.detail;
const isTv = detail?.type === 'tv';
const release = isTv ? d.first_air_date : d.release_date;
const runtime = isTv ? (Array.isArray(d.episode_run_time) ? d.episode_run_time[0] : undefined) : d.runtime;
const status = d.status;
const language = d.original_language;
const origin = isTv ? d.original_name : d.original_title;
const seasons = isTv ? d.number_of_seasons : undefined;
const eps = isTv ? d.number_of_episodes : undefined;
return (
<Descriptions
size="small"
column={2}
styles={{ label: { width: 110, color: token.colorTextSecondary } }}
style={{ marginTop: 10 }}
>
<Descriptions.Item label={t('Type')}>{isTv ? t('TV') : t('Movies')}</Descriptions.Item>
<Descriptions.Item label="TMDB ID">{String(detail?.tmdb?.id || '')}</Descriptions.Item>
{origin && <Descriptions.Item label={t('Original Title')}>{String(origin)}</Descriptions.Item>}
{release && <Descriptions.Item label={t('Release Date')}>{String(release)}</Descriptions.Item>}
{status && <Descriptions.Item label={t('Status')}>{String(status)}</Descriptions.Item>}
{runtime && <Descriptions.Item label={t('Runtime')}>{String(runtime)} min</Descriptions.Item>}
{language && <Descriptions.Item label={t('Language')}>{String(language).toUpperCase()}</Descriptions.Item>}
{isTv && seasons !== undefined && <Descriptions.Item label={t('Seasons')}>{String(seasons)}</Descriptions.Item>}
{isTv && eps !== undefined && <Descriptions.Item label={t('Episodes')}>{String(eps)}</Descriptions.Item>}
{detail?.source_path && <Descriptions.Item label={t('Source Path')} span={2}>{String(detail.source_path)}</Descriptions.Item>}
</Descriptions>
);
};
const renderCast = () => {
if (!castTop.length) return null;
return (
<div style={{ marginTop: 14 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('Cast')}
</Typography.Title>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{castTop.map((c: any) => {
const avatar = tmdbImage(c?.profile_path, 'w185');
return (
<div
key={String(c?.id || `${c?.name}-${c?.character}`)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 12,
background: token.colorFillQuaternary,
border: `1px solid ${token.colorBorderSecondary}`,
minWidth: 200,
}}
>
<Avatar size={36} src={avatar} style={{ flex: 'none' }}>
{(c?.name || '?').slice(0, 1)}
</Avatar>
<div style={{ minWidth: 0 }}>
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 140 }}>
{c?.name || '--'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{c?.character || ''}
</Typography.Text>
</div>
</div>
);
})}
</div>
</div>
);
};
const renderEpisodes = () => {
if (detail?.type !== 'tv') return null;
if (!episodesBySeason.length) {
return (
<Empty description={t('No data')} style={{ marginTop: 24 }} />
);
}
return (
<Collapse
accordion={false}
items={episodesBySeason.map(({ season, episodes }) => ({
key: String(season),
label: `${t('Season')} ${season} · ${episodes.length} ${t('Episodes')}`,
children: (
<List
itemLayout="horizontal"
dataSource={episodes}
renderItem={(ep: any) => {
const seasonNo = typeof ep?.season === 'number' ? ep.season : season;
const epNo = typeof ep?.episode === 'number' ? ep.episode : undefined;
const tmdbEp = ep?.tmdb_episode || {};
const still = tmdbImage(tmdbEp?.still_path, 'w300');
const title = tmdbEp?.name || ep?.name || ep?.rel || '--';
const air = tmdbEp?.air_date ? String(tmdbEp.air_date) : '';
const runtime = tmdbEp?.runtime ? `${tmdbEp.runtime} min` : '';
const sub = [air, runtime].filter(Boolean).join(' · ');
const prefix = epNo !== undefined ? `S${String(seasonNo).padStart(2, '0')}E${String(epNo).padStart(2, '0')}` : `S${String(seasonNo).padStart(2, '0')}`;
return (
<List.Item
style={{ paddingInline: 0 }}
actions={[
<Button
key="play"
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playByPath(String(ep?.path || ''))}
disabled={!ep?.path}
>
{t('Play')}
</Button>,
]}
>
<List.Item.Meta
avatar={still ? (
<Image
src={still}
preview={false}
width={120}
height={68}
style={{ objectFit: 'cover', borderRadius: 10, overflow: 'hidden' }}
fallback="/logo.svg"
/>
) : (
<div
style={{
width: 120,
height: 68,
borderRadius: 10,
background: token.colorFillQuaternary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: token.colorTextTertiary,
}}
>
<VideoCameraOutlined />
</div>
)}
title={(
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Tag style={{ marginInlineEnd: 0 }}>{prefix}</Tag>
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 360 }}>
{title}
</Typography.Text>
</div>
)}
description={sub ? <Typography.Text type="secondary">{sub}</Typography.Text> : null}
/>
</List.Item>
);
}}
/>
),
}))}
/>
);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
<div
style={{
padding: 14,
borderRadius: 12,
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Flex align="center" justify="space-between" gap={12} wrap>
<div style={{ minWidth: 240 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
{t('Video Library')}
</Typography.Title>
<Typography.Text type="secondary">
{t('Total')}: {stats.total} · {t('Movies')}: {stats.movie} · {t('TV')}: {stats.tv}
</Typography.Text>
</div>
<Space size={10} wrap>
<Segmented
value={filter}
onChange={(v) => setFilter(v as LibraryFilter)}
options={[
{ value: 'all', label: t('All') },
{ value: 'movie', label: t('Movies') },
{ value: 'tv', label: t('TV') },
]}
/>
<Input
allowClear
value={q}
onChange={(e) => setQ(e.target.value)}
prefix={<SearchOutlined />}
placeholder={t('Search')}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={loadLibrary} loading={loading}>
{t('Refresh')}
</Button>
</Space>
</Flex>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px 2px' }}>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
{Array.from({ length: 10 }).map((_, idx) => (
<Card
key={idx}
size="small"
style={{ borderRadius: 12, overflow: 'hidden' }}
cover={(
<div style={{ width: '100%', aspectRatio: '2 / 3' }}>
<Skeleton.Image active style={{ width: '100%', height: '100%' }} />
</div>
)}
>
<Skeleton active paragraph={{ rows: 2 }} />
</Card>
))}
</div>
) : filtered.length === 0 ? (
<Empty description={t('No data')} style={{ marginTop: 48 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
{filtered.map((v) => (
<Card
key={v.id}
hoverable
size="small"
styles={{ body: { padding: 10 } } as any}
style={{
borderRadius: 12,
overflow: 'hidden',
boxShadow: token.boxShadowTertiary,
border: `1px solid ${token.colorBorderSecondary}`,
}}
cover={renderCover(v)}
onClick={() => openDetail(v)}
>
<Typography.Text strong ellipsis style={{ display: 'block' }}>
{v.title || '--'}
</Typography.Text>
<div style={{ marginTop: 6, display: 'flex', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
<span>{v.year || '--'}</span>
{v.type === 'tv' ? (
<>
<span>·</span>
<span>{v.episodes_count || 0} {t('Episodes')}</span>
</>
) : null}
</div>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{(v.genres || []).slice(0, 3).map((tag) => (
<Tag key={tag} style={{ marginInlineEnd: 0 }}>
{tag}
</Tag>
))}
{(v.genres || []).length > 3 && (
<Tag style={{ marginInlineEnd: 0 }}>+{(v.genres || []).length - 3}</Tag>
)}
</div>
</Card>
))}
</div>
)}
</div>
<Drawer
title={detailTitle || selected?.title || t('Details')}
open={detailOpen}
onClose={closeDetail}
width="100%"
destroyOnHidden
getContainer={false}
styles={{ body: { padding: 0 } }}
>
{detailLoading ? (
<div style={{ padding: 16 }}>
<Skeleton active paragraph={{ rows: 10 }} />
</div>
) : !detail ? (
<Empty description={t('No data')} style={{ marginTop: 48 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{renderHero()}
<div style={{ padding: 16 }}>
<Tabs
items={[
...(detail?.type === 'tv'
? [{
key: 'episodes',
label: t('Episodes'),
children: renderEpisodes(),
}]
: []),
{
key: 'detail',
label: t('Details'),
children: (
<>
{renderMeta()}
{detailOverview && (
<>
<Divider style={{ margin: '14px 0' }} />
<Typography.Title level={5} style={{ margin: 0 }}>
{t('Overview')}
</Typography.Title>
<Typography.Paragraph style={{ marginTop: 8, whiteSpace: 'pre-wrap' }}>
{detailOverview}
</Typography.Paragraph>
</>
)}
{renderCast()}
</>
),
},
]}
/>
</div>
</div>
)}
</Drawer>
</div>
);
};

View File

@@ -1,46 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { vfsApi } from '../../api/client';
import type { AppComponentProps } from '../types';
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
//
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
const videoUrl = vfsApi.streamUrl(safePath);
if (artRef.current) {
artInstance.current = new Artplayer({
container: artRef.current,
url: videoUrl,
autoplay: true,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
}
return () => {
if (artInstance.current) {
artInstance.current.destroy();
}
};
}, [filePath]);
return (
<div
ref={artRef}
style={{
width: '100%',
height: '100%',
backgroundColor: '#000'
}}
/>
);
};

View File

@@ -1,23 +0,0 @@
import type { AppDescriptor } from '../types';
import { VideoPlayerApp } from './VideoPlayer.tsx';
import { VideoLibraryApp } from './VideoLibrary.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',
openAppComponent: VideoLibraryApp,
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return supportedExts.includes(ext);
},
component: VideoPlayerApp,
default: true,
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
};

View File

@@ -3,76 +3,112 @@ import type { AppDescriptor } from './types';
import React from 'react';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
import { getPluginAssetUrl } from '../plugins/runtime';
const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用
const appModules = import.meta.glob('./*/index.ts');
/**
* 获取插件的唯一 key
*/
function getPluginAppKey(p: PluginItem): string {
return `plugin:${p.key}`;
}
async function loadApps() {
for (const path in appModules) {
const module = await appModules[path]();
if (module && typeof module === 'object' && 'descriptor' in module) {
const descriptor = (module as { descriptor: AppDescriptor }).descriptor;
if (!apps.find(a => a.key === descriptor.key)) {
apps.push(descriptor);
}
}
/**
* 解析插件图标 URL
* 支持绝对路径、相对路径(插件资源)、外部 URL
*/
function resolvePluginIcon(p: PluginItem): string | undefined {
if (!p.icon) return undefined;
// 外部 URL
if (p.icon.startsWith('http://') || p.icon.startsWith('https://')) {
return p.icon;
}
try {
const items = await pluginsApi.list();
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
} catch { void 0; }
// 绝对路径
if (p.icon.startsWith('/')) {
return p.icon;
}
// 插件资源路径
return getPluginAssetUrl(p.key, p.icon);
}
function resolvePluginUseSystemWindow(p: PluginItem): boolean | undefined {
const frontend = (p.manifest as any)?.frontend as any;
const value = frontend?.use_system_window ?? frontend?.useSystemWindow;
return typeof value === 'boolean' ? value : undefined;
}
function registerPluginAsApp(p: PluginItem) {
const key = 'plugin:' + p.id;
if (apps.find(a => a.key === key)) return;
const key = getPluginAppKey(p);
if (apps.find((a) => a.key === key)) return;
const supported = (entry: VfsEntry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
if (!p.supported_exts || p.supported_exts.length === 0) return true;
return p.supported_exts.includes(ext);
};
apps.push({
key,
name: p.name || `插件 ${p.id}`,
name: p.name || `插件 ${p.key}`,
supported,
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
openAppComponent: p.open_app ? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })) : undefined,
iconUrl: p.icon || undefined,
openAppComponent: p.open_app
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
: undefined,
iconUrl: resolvePluginIcon(p),
default: false,
defaultBounds: p.default_bounds || undefined,
defaultMaximized: p.default_maximized || undefined,
useSystemWindow: resolvePluginUseSystemWindow(p),
description: p.description || undefined,
author: p.author || undefined,
supportedExts: p.supported_exts || undefined,
website: p.website || undefined,
github: p.github || undefined,
});
}
async function loadApps() {
try {
const items = await pluginsApi.list();
items.forEach((p) => registerPluginAsApp(p));
} catch {
void 0;
}
}
const appsLoadedPromise = loadApps();
export async function ensureAppsLoaded() {
await appsLoadedPromise;
}
export function listSystemApps(): AppDescriptor[] {
return apps.filter(a => !a.key.startsWith('plugin:'));
export function listPluginApps(): AppDescriptor[] {
return apps;
}
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
return apps.filter(a => a.supported(entry));
return apps.filter((a) => a.supported(entry));
}
export function getAppByKey(key: string): AppDescriptor | undefined {
return apps.find(a => a.key === key);
return apps.find((a) => a.key === key);
}
export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefined {
if (entry.is_dir) return;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
if (!ext) return apps.find(a => a.supported(entry) && a.default);
if (!ext) return apps.find((a) => a.supported(entry) && a.default);
const saved = localStorage.getItem(`app.default.${ext}`);
if (saved) {
return apps.find(a => a.key === saved && a.supported(entry)) || undefined;
return apps.find((a) => a.key === saved && a.supported(entry)) || undefined;
}
return apps.find(a => a.supported(entry) && a.default);
return apps.find((a) => a.supported(entry) && a.default);
}
export type { AppDescriptor };
@@ -81,27 +117,40 @@ export type { AppComponentProps } from './types';
export async function reloadPluginApps() {
try {
const items = await pluginsApi.list();
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
// 生成要保留的 key 集合
const keepKeys = new Set(items.map((p) => getPluginAppKey(p)));
// 移除已卸载的插件应用
for (let i = apps.length - 1; i >= 0; i--) {
const a = apps[i];
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
if (!keepKeys.has(a.key)) {
apps.splice(i, 1);
}
}
items.filter(p => p.enabled !== false).forEach(p => {
const key = 'plugin:' + p.id;
const existing = apps.find(a => a.key === key);
// 更新或添加插件应用
items.forEach((p) => {
const key = getPluginAppKey(p);
const existing = apps.find((a) => a.key === key);
if (!existing) {
registerPluginAsApp(p);
} else {
existing.name = p.name || `插件 ${p.id}`;
// 更新现有应用信息
existing.name = p.name || `插件 ${p.key}`;
existing.defaultBounds = p.default_bounds || undefined;
existing.defaultMaximized = p.default_maximized || undefined;
existing.iconUrl = p.icon || existing.iconUrl;
existing.useSystemWindow = resolvePluginUseSystemWindow(p);
existing.iconUrl = resolvePluginIcon(p);
existing.description = p.description || undefined;
existing.author = p.author || undefined;
existing.supportedExts = p.supported_exts || undefined;
existing.openAppComponent = p.open_app
? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props }))
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
: undefined;
}
});
} catch { void 0; }
} catch {
void 0;
}
}

View File

@@ -10,6 +10,174 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
.fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
.fx-fade-text { color: var(--ant-color-text-secondary, #555); }
.fx-center-card {
background: var(--ant-color-bg-container, #fff);
border: 1px solid var(--ant-color-border, #eaeaea);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 24px -18px rgba(0,0,0,.22);
}
.fx-center-card-hero {
position: relative;
height: 112px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--ant-color-fill-secondary, #f3f3f3), var(--ant-color-fill-tertiary, #f5f5f5));
}
[data-theme="dark"] .fx-center-card-hero {
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
}
.fx-center-card-iconbox {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.72);
backdrop-filter: blur(10px);
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
}
[data-theme="dark"] .fx-center-card-iconbox {
border-color: rgba(255,255,255,0.14);
background: rgba(0,0,0,0.28);
}
.fx-center-card-icon {
width: 32px;
height: 32px;
object-fit: contain;
}
.fx-center-card-pills {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 6px;
align-items: center;
}
.fx-center-pill {
max-width: 120px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
border: 1px solid rgba(255,255,255,0.55);
background: rgba(255,255,255,0.72);
color: rgba(0,0,0,0.72);
backdrop-filter: blur(8px);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-theme="dark"] .fx-center-pill {
border-color: rgba(255,255,255,0.14);
background: rgba(0,0,0,0.28);
color: rgba(255,255,255,0.85);
}
.fx-center-pill-success {
border-color: rgba(82,196,26,0.35);
background: rgba(82,196,26,0.14);
color: var(--ant-color-success, #52c41a);
}
.fx-center-card-body {
padding: 14px 14px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.fx-center-card-titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.fx-center-card-title {
min-width: 0;
font-weight: 600;
font-size: 15px;
line-height: 1.2;
color: var(--ant-color-text, #111);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fx-center-card-version {
flex: none;
font-size: 12px;
color: var(--ant-color-text-tertiary, #666);
font-variant-numeric: tabular-nums;
}
.fx-center-card-metaRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: var(--ant-color-text-tertiary, #666);
}
.fx-center-card-metaLeft,
.fx-center-card-metaRight {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fx-center-card-desc {
font-size: 13px;
line-height: 1.6;
color: var(--ant-color-text-secondary, #555);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 42px;
}
.fx-center-card-footer {
padding: 10px 14px;
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
background: var(--ant-color-fill-tertiary, #fafafa);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
[data-theme="dark"] .fx-center-card-footer {
background: rgba(255,255,255,0.02);
}
.fx-center-card-footerLeft,
.fx-center-card-footerRight {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.fx-center-card-actions,
.fx-center-card-links {
display: flex;
align-items: center;
gap: 8px;
}
.fx-center-card-iconLink {
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--ant-color-text-tertiary, #666);
transition: background .18s ease, color .18s ease;
}
.fx-center-card-iconLink:hover {
background: var(--ant-color-fill-secondary, #f2f2f2);
color: var(--ant-color-text, #111);
}
[data-theme="dark"] .fx-center-card-iconLink:hover {
background: rgba(255,255,255,0.06);
}
.fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
/* 使用 antd 默认布局背景 */

View File

@@ -34,6 +34,7 @@ export function I18nProvider({ children }: PropsWithChildren) {
useEffect(() => {
document.documentElement.lang = lang;
window.dispatchEvent(new CustomEvent('foxel:langchange', { detail: { lang } }));
}, [lang]);
const t = useCallback((key: string, params?: Record<string, string | number>) => {

View File

@@ -605,18 +605,31 @@
"Up": "Up",
"Select Current Folder": "Select Current Folder",
"Please select a file": "Please select a file",
"Please select a .foxpkg file": "Please select a .foxpkg file",
"Invalid file": "Invalid file",
"Installed successfully": "Installed successfully",
"Installation failed": "Installation failed",
"Plugin": "Plugin",
"System App": "System App",
"No description": "No description",
"Any": "Any",
"Open Link": "Open Link",
"Link copied": "Link copied",
"Copy Link": "Copy Link",
"Open App": "Open App",
"Update App": "Update App",
"Confirm delete this plugin?": "Confirm delete this plugin?",
"Uninstall": "Uninstall",
"Author": "Author",
"Website": "Website",
"Install Plugin": "Install Plugin",
"Confirm Install": "Confirm Install",
"Selected {count} files": "Selected {count} files",
"Installation will stop on first failure": "Installation will stop on first failure",
"Installing": "Installing",
"Remove": "Remove",
"Cancel": "Cancel",
"Install App": "Install App",
"Search name/author/extension": "Search name/author/extension",
"Search name/author/url/extension": "Search name/author/url/extension",
"No plugins": "No plugins",
"Install": "Install",
@@ -630,6 +643,13 @@
"Created (newest)": "Created (newest)",
"Installed already": "Installed",
"No results": "No results",
"Download and Install": "Download & Install",
"Loading apps": "Loading apps",
"Failed to load apps": "Failed to load apps",
"Version": "Version",
"Tags": "Tags",
"Approved": "Approved",
"Coming soon v2": "Coming soon v2",
"Initialization succeeded! Logging you in...": "Initialization succeeded! Logging you in...",
"Initialization failed, please try later": "Initialization failed, please try later",
"Database Setup": "Database Setup",

View File

@@ -598,18 +598,30 @@
"Up": "上一级",
"Select Current Folder": "选择当前目录",
"Please select a file": "请选择一个文件",
"Please select a .foxpkg file": "请选择一个 .foxpkg 文件",
"Invalid file": "无效文件",
"Installed successfully": "安装成功",
"Installation failed": "安装失败",
"Plugin": "插件",
"System App": "系统应用",
"No description": "暂无描述",
"Any": "任意",
"Open Link": "打开链接",
"Link copied": "已复制链接",
"Copy Link": "复制链接",
"Open App": "打开应用",
"Update App": "更新应用",
"Confirm delete this plugin?": "确认删除该插件?",
"Uninstall": "卸载",
"Author": "作者",
"Website": "官网",
"Install Plugin": "安装应用",
"Confirm Install": "确认安装",
"Selected {count} files": "已选择 {count} 个文件",
"Installation will stop on first failure": "遇到失败将停止后续安装",
"Installing": "安装中",
"Remove": "移除",
"Install App": "安装应用",
"Search name/author/extension": "搜索 名称/作者/扩展名",
"Search name/author/url/extension": "搜索 名称/作者/链接/扩展名",
"No plugins": "暂无插件",
"Install": "安装",
@@ -623,6 +635,14 @@
"Created (newest)": "创建时间(最新)",
"Installed already": "已安装",
"No results": "暂无结果",
"Downloading": "下载中",
"Download and Install": "下载并安装",
"Loading apps": "加载应用中",
"Failed to load apps": "加载应用失败",
"Version": "版本",
"Tags": "标签",
"Approved": "已审核",
"Coming soon v2": "敬请期待 v2",
"Initialization succeeded! Logging you in...": "初始化成功!正在为您登录,请不要刷新。",
"Initialization failed, please try later": "初始化失败,请稍后重试",
"Database Setup": "数据库设置",

View File

@@ -4,6 +4,10 @@ import 'antd/dist/reset.css';
import './global.css';
import { BrowserRouter } from 'react-router';
// 初始化插件依赖注入
import { initExternals } from './plugins/externals';
initExternals();
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />

File diff suppressed because it is too large Load Diff

247
web/src/plugin-frame.ts Normal file
View File

@@ -0,0 +1,247 @@
import 'antd/dist/reset.css';
import './global.css';
import { initExternals } from './plugins/externals';
import type { FoxelHostApi, RegisteredPlugin } from './plugins/externals';
import type { PluginItem } from './api/plugins';
import { pluginsApi } from './api/plugins';
import request from './api/client';
import { vfsApi, type VfsEntry } from './api/vfs';
type FrameMode = 'file' | 'app';
function renderStatus(text: string, isError: boolean = false) {
const root = document.getElementById('root');
if (!root) return;
root.innerHTML = '';
const el = document.createElement('div');
el.style.cssText = [
'height:100%',
'display:flex',
'align-items:center',
'justify-content:center',
'padding:16px',
'box-sizing:border-box',
`color:${isError ? 'red' : 'rgba(0,0,0,0.65)'}`,
'font-size:14px',
'white-space:pre-wrap',
].join(';');
el.textContent = text;
root.appendChild(el);
}
function getQuery() {
const params = new URLSearchParams(window.location.search);
const pluginKey = (params.get('pluginKey') || '').trim();
const mode = (params.get('mode') || 'file') as FrameMode;
const filePath = (params.get('filePath') || '').trim();
return { pluginKey, mode, filePath };
}
function postToParent(data: any) {
if (window.parent && window.parent !== window) {
window.parent.postMessage(data, window.location.origin);
}
}
function createHostApi(pluginKey: string): FoxelHostApi {
const showMessage: FoxelHostApi['showMessage'] = (type, content) => {
const antd = window.__FOXEL_EXTERNALS__?.antd;
const msg = antd?.message;
if (!msg) return;
switch (type) {
case 'success':
msg.success(content);
break;
case 'error':
msg.error(content);
break;
case 'warning':
msg.warning(content);
break;
case 'info':
default:
msg.info(content);
break;
}
};
return {
close: () => postToParent({ type: 'foxel-plugin:close', pluginKey }),
openPath: (path) => postToParent({ type: 'foxel-plugin:openPath', pluginKey, path }),
openFile: (filePath, appKey) =>
postToParent({ type: 'foxel-plugin:openFile', pluginKey, filePath, appKey }),
showMessage,
callApi: async <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) =>
request<T>(path, options),
getTempLink: async (filePath: string) => {
const token = await vfsApi.getTempLinkToken(filePath);
return vfsApi.getTempPublicUrl(token.token);
},
getStreamUrl: (filePath: string) => vfsApi.streamUrl(filePath),
};
}
function getPluginStylePaths(plugin: PluginItem): string[] {
const styles = (plugin.manifest as any)?.frontend?.styles as unknown;
if (!Array.isArray(styles)) return [];
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
}
async function loadPluginStyles(pluginKey: string, plugin: PluginItem) {
const stylePaths = getPluginStylePaths(plugin);
if (stylePaths.length === 0) return;
const tasks = stylePaths.map(
(p) =>
new Promise<void>((resolve) => {
const href = `/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.onload = () => resolve();
link.onerror = () => resolve();
document.head.appendChild(link);
})
);
await Promise.all(tasks);
}
async function loadPluginBundle(pluginKey: string): Promise<RegisteredPlugin> {
const url = `/api/plugins/${pluginKey}/bundle.js`;
return new Promise<RegisteredPlugin>((resolve, reject) => {
let done = false;
const t = window.setTimeout(() => {
if (done) return;
done = true;
reject(new Error('Plugin did not call FoxelRegister'));
}, 15000);
window.FoxelRegister = (p: RegisteredPlugin) => {
if (done) return;
done = true;
window.clearTimeout(t);
resolve(p);
};
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onerror = () => {
if (done) return;
done = true;
window.clearTimeout(t);
reject(new Error('Failed to load plugin script: ' + url));
};
document.head.appendChild(script);
});
}
async function buildFileContext(filePath: string) {
const stat = (await vfsApi.stat(filePath)) as any;
const name =
typeof stat?.name === 'string' && stat.name.trim().length > 0
? stat.name
: filePath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || 'unknown';
const entry: VfsEntry = {
name,
is_dir: Boolean(stat?.is_dir),
size: Number(stat?.size || 0),
mtime: Number(stat?.mtime || 0),
type: typeof stat?.type === 'string' ? stat.type : undefined,
has_thumbnail: Boolean(stat?.has_thumbnail),
};
const token = await vfsApi.getTempLinkToken(filePath);
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
const streamUrl = vfsApi.streamUrl(filePath);
return { entry, urls: { downloadUrl, streamUrl } };
}
async function main() {
initExternals();
const { pluginKey, mode, filePath } = getQuery();
if (!pluginKey) {
renderStatus('Missing pluginKey in query string', true);
return;
}
const root = document.getElementById('root');
if (!root) {
renderStatus('Missing #root container', true);
return;
}
renderStatus('Loading plugin...');
let plugin: PluginItem;
try {
plugin = await pluginsApi.get(pluginKey);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
renderStatus(`Failed to load plugin info: ${msg}`, true);
return;
}
try {
await loadPluginStyles(pluginKey, plugin);
} catch {
// ignore
}
let registered: RegisteredPlugin;
try {
registered = await loadPluginBundle(pluginKey);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
renderStatus(`Failed to load plugin bundle: ${msg}`, true);
return;
}
const host = createHostApi(pluginKey);
let cleanup: (() => void) | null = null;
const mountError = async () => {
try {
root.innerHTML = '';
if (mode === 'app') {
if (typeof registered.mountApp !== 'function') {
throw new Error('This plugin does not support standalone mode');
}
const ret = await registered.mountApp(root, { host });
if (typeof ret === 'function') cleanup = ret;
return;
}
if (!filePath) {
throw new Error('Missing filePath in query string');
}
const { entry, urls } = await buildFileContext(filePath);
const ret = await registered.mount(root, { filePath, entry, urls, host });
if (typeof ret === 'function') cleanup = ret;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
renderStatus(`Plugin runtime error: ${msg}`, true);
}
};
await mountError();
window.addEventListener('beforeunload', () => {
try {
cleanup?.();
} catch {
void 0;
}
});
}
void main();

View File

@@ -0,0 +1,279 @@
/**
* 插件外部依赖注入模块
*
* 宿主应用通过 window.__FOXEL_EXTERNALS__ 暴露共享依赖给插件使用
* 插件开发时可以直接从 window.__FOXEL_EXTERNALS__ 获取 React、Antd 等依赖
*/
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as antd from 'antd';
import * as AntdIcons from '@ant-design/icons';
// 宿主共享依赖(供插件复用)
import MonacoEditor from '@monaco-editor/react';
import Artplayer from 'artplayer';
// API 模块
import request, { vfsApi, API_BASE_URL } from '../api/client';
import { pluginsApi } from '../api/plugins';
// 类型定义
import type { VfsEntry, DirListing } from '../api/client';
import type { PluginItem } from '../api/plugins';
type Lang = 'zh' | 'en';
type Dict = Record<string, string>;
type Dicts = Partial<Record<Lang, Dict>>;
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
/**
* 宿主 API 接口
*/
export interface FoxelHostApi {
/**
* 关闭当前插件窗口
*/
close: () => void;
/**
* 在文件浏览器中打开指定路径
*/
openPath?: (path: string) => void;
/**
* 使用指定应用打开文件
*/
openFile?: (filePath: string, appKey?: string) => void;
/**
* 显示消息提示
*/
showMessage: (type: 'success' | 'error' | 'info' | 'warning', content: string) => void;
/**
* 调用 API
*/
callApi: <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) => Promise<T>;
/**
* 获取文件的临时公开链接
*/
getTempLink?: (filePath: string) => Promise<string>;
/**
* 获取文件流式播放/下载 URL
*/
getStreamUrl?: (filePath: string) => string;
}
/**
* 插件上下文 - 文件打开模式
*/
export interface PluginContext {
/** 文件路径 */
filePath: string;
/** 文件信息 */
entry: VfsEntry;
/** 文件 URL */
urls: {
/** 临时下载链接 */
downloadUrl: string;
/** 流式播放链接 */
streamUrl?: string;
};
/** 宿主 API */
host: FoxelHostApi;
}
/**
* 插件上下文 - 独立应用模式
*/
export interface PluginAppContext {
/** 宿主 API */
host: FoxelHostApi;
}
/**
* 已注册的插件接口
*/
export interface RegisteredPlugin {
/**
* 挂载插件(文件打开模式)
* @returns 可选的清理函数
*/
mount: (container: HTMLElement, ctx: PluginContext) => void | (() => void) | Promise<void | (() => void)>;
/**
* 卸载插件(文件打开模式)
*/
unmount?: (container: HTMLElement) => void | Promise<void>;
/**
* 挂载独立应用
* @returns 可选的清理函数
*/
mountApp?: (container: HTMLElement, ctx: PluginAppContext) => void | (() => void) | Promise<void | (() => void)>;
/**
* 卸载独立应用
*/
unmountApp?: (container: HTMLElement) => void | Promise<void>;
}
/**
* VFS API 包装
*/
const vfsApiWrapper = {
list: vfsApi.list,
stat: vfsApi.stat,
mkdir: vfsApi.mkdir,
rename: vfsApi.rename,
deletePath: vfsApi.deletePath,
copy: vfsApi.copy,
move: vfsApi.move,
streamUrl: vfsApi.streamUrl,
getTempLinkToken: vfsApi.getTempLinkToken,
getTempPublicUrl: vfsApi.getTempPublicUrl,
readFile: vfsApi.readFile,
uploadFile: vfsApi.uploadFile,
};
/**
* 暴露给插件的外部依赖
*/
export interface FoxelExternals {
// React 核心
React: typeof React;
ReactDOM: typeof ReactDOM;
// UI 库
antd: typeof antd;
AntdIcons: typeof AntdIcons;
// 编辑器/播放器
MonacoEditor: typeof MonacoEditor;
Artplayer: typeof Artplayer;
// i18n
i18n?: {
getLang: () => Lang;
subscribe: (cb: (lang: Lang) => void) => () => void;
create: (dicts: Dicts) => {
t: (key: string, params?: Record<string, string | number>) => string;
useI18n: () => { lang: Lang; t: (key: string, params?: Record<string, string | number>) => string };
};
};
// Foxel API
foxelApi: {
/** HTTP 请求函数 */
request: typeof request;
/** VFS 文件系统 API */
vfs: typeof vfsApiWrapper;
/** 插件管理 API */
plugins: typeof pluginsApi;
/** API 基础 URL */
baseUrl: string;
};
// 版本信息
version: string;
}
// 声明全局类型
declare global {
interface Window {
__FOXEL_EXTERNALS__?: FoxelExternals;
FoxelRegister?: (plugin: RegisteredPlugin) => void;
}
}
/**
* 初始化并暴露外部依赖
*/
export function initExternals(): void {
const normalizeLang = (raw: unknown): Lang => (raw === 'en' ? 'en' : 'zh');
const i18nApi = {
getLang: () => normalizeLang(localStorage.getItem('lang')),
subscribe: (cb: (lang: Lang) => void) => {
const handler = (e: Event) => {
const lang = (e as CustomEvent)?.detail?.lang as Lang;
cb(normalizeLang(lang));
};
window.addEventListener('foxel:langchange', handler as any);
return () => window.removeEventListener('foxel:langchange', handler as any);
},
create: (dicts: Dicts) => {
const t = (key: string, params?: Record<string, string | number>) => {
const lang = i18nApi.getLang();
const dict = dicts[lang] || {};
return interpolate(dict[key] ?? key, params);
};
const useI18n = () => {
const [lang, setLang] = React.useState<Lang>(() => i18nApi.getLang());
React.useEffect(() => i18nApi.subscribe(setLang), []);
const tt = React.useCallback((key: string, params?: Record<string, string | number>) => {
const dict = dicts[lang] || {};
return interpolate(dict[key] ?? key, params);
}, [lang]);
return { lang, t: tt };
};
return { t, useI18n };
},
};
if (window.__FOXEL_EXTERNALS__) {
window.__FOXEL_EXTERNALS__.i18n = i18nApi;
window.__FOXEL_EXTERNALS__.MonacoEditor = MonacoEditor;
window.__FOXEL_EXTERNALS__.Artplayer = Artplayer;
return; // 已初始化
}
window.__FOXEL_EXTERNALS__ = {
// React 核心
React,
ReactDOM,
// UI 库
antd,
AntdIcons,
// 编辑器/播放器
MonacoEditor,
Artplayer,
// i18n
i18n: i18nApi,
// Foxel API
foxelApi: {
request,
vfs: vfsApiWrapper,
plugins: pluginsApi,
baseUrl: API_BASE_URL,
},
// 版本信息
version: '1.0.0',
};
console.debug('[Foxel] Plugin externals initialized');
}
/**
* 获取外部依赖
*/
export function getExternals(): FoxelExternals | undefined {
return window.__FOXEL_EXTERNALS__;
}
// 导出类型供插件 SDK 使用
export type { VfsEntry, DirListing, PluginItem };

View File

@@ -1,64 +1,55 @@
import { pluginsApi, type PluginManifestUpdate, type PluginItem } from '../api/plugins';
/**
* 插件运行时模块
*
* 负责:
* 1. 加载和管理插件
* 2. 处理插件注册
*/
export interface RegisteredPlugin {
mount: (container: HTMLElement, ctx: {
filePath: string;
entry: any;
urls: { downloadUrl: string };
host: HostApi;
}) => void | Promise<void>;
unmount?: (container: HTMLElement) => void | Promise<void>;
import type { PluginItem } from '../api/plugins';
import type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi } from './externals';
mountApp?: (container: HTMLElement, ctx: { host: HostApi }) => void | Promise<void>;
unmountApp?: (container: HTMLElement) => void | Promise<void>;
key?: string;
name?: string;
version?: string;
supportedExts?: string[];
defaultBounds?: { x?: number; y?: number; width?: number; height?: number };
defaultMaximized?: boolean;
icon?: string;
description?: string;
author?: string;
website?: string;
github?: string;
}
export interface HostApi {
close: () => void;
}
// 重新导出类型
export type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi };
// 已加载的插件缓存
const loadedPlugins = new Map<string, RegisteredPlugin>();
// 等待加载的插件回调
const waiters = new Map<string, ((p: RegisteredPlugin) => void)[]>();
// 已注入的脚本 URL
const injected = new Set<string>();
declare global {
interface Window { FoxelRegister?: (plugin: RegisteredPlugin) => void; }
}
/**
* 全局插件注册函数
* 插件加载后调用此函数注册自己
*/
window.FoxelRegister = (plugin: RegisteredPlugin) => {
const pendingUrl = sessionStorage.getItem('foxel:pendingPluginUrl') || '';
if (pendingUrl) {
loadedPlugins.set(pendingUrl, plugin);
const resolvers = waiters.get(pendingUrl) || [];
resolvers.forEach(fn => fn(plugin));
resolvers.forEach((fn) => fn(plugin));
waiters.delete(pendingUrl);
sessionStorage.removeItem('foxel:pendingPluginUrl');
} else {
// 回退:使用第一个等待的 URL
const anyUrl = Array.from(waiters.keys())[0];
if (anyUrl) {
loadedPlugins.set(anyUrl, plugin);
const resolvers = waiters.get(anyUrl) || [];
resolvers.forEach(fn => fn(plugin));
resolvers.forEach((fn) => fn(plugin));
waiters.delete(anyUrl);
}
}
};
/**
* 从 URL 加载插件
*/
export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin> {
const existing = loadedPlugins.get(url);
if (existing) return existing;
return new Promise<RegisteredPlugin>((resolve, reject) => {
const arr = waiters.get(url) || [];
arr.push(resolve);
@@ -67,7 +58,7 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
const ready = loadedPlugins.get(url);
if (ready) {
const resolvers = waiters.get(url) || [];
resolvers.forEach(fn => fn(ready));
resolvers.forEach((fn) => fn(ready));
waiters.delete(url);
return;
}
@@ -94,52 +85,61 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
}, 15000);
const last = arr[arr.length - 1];
arr[arr.length - 1] = (p: RegisteredPlugin) => { clearTimeout(t); last(p); };
arr[arr.length - 1] = (p: RegisteredPlugin) => {
clearTimeout(t);
last(p);
};
});
}
export function getPluginBundleUrl(pluginId: number) {
return `/api/plugins/${pluginId}/bundle.js`;
/**
* 获取插件 bundle URL
*/
export function getPluginBundleUrl(pluginKey: string): string {
return `/api/plugins/${pluginKey}/bundle.js`;
}
export async function loadPlugin(plugin: Pick<PluginItem, 'id' | 'url'>): Promise<RegisteredPlugin> {
const bundleUrl = getPluginBundleUrl(plugin.id);
try {
return await loadPluginFromUrl(bundleUrl);
} catch (e) {
if (plugin.url && plugin.url !== bundleUrl) {
try {
return await loadPluginFromUrl(plugin.url);
} catch {
throw e;
}
}
throw e;
}
/**
* 获取插件资源 URL
*/
export function getPluginAssetUrl(pluginKey: string, assetPath: string): string {
return `/api/plugins/${pluginKey}/assets/${assetPath}`;
}
export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
const manifest: PluginManifestUpdate = {
key: plugin.key,
name: plugin.name,
version: plugin.version,
open_app: typeof plugin.mountApp === 'function',
supported_exts: plugin.supportedExts,
default_bounds: plugin.defaultBounds,
default_maximized: plugin.defaultMaximized,
icon: plugin.icon,
description: plugin.description,
author: plugin.author,
website: plugin.website,
github: plugin.github,
};
try { console.debug('[foxel] report manifest', pluginId, manifest); } catch { void 0; }
const key = `foxel:manifestReported:${pluginId}`;
if (sessionStorage.getItem(key) === '1') return;
try {
await pluginsApi.updateManifest(pluginId, manifest);
sessionStorage.setItem(key, '1');
} catch {
void 0;
/**
* 加载插件
*/
export async function loadPlugin(plugin: Pick<PluginItem, 'key'>): Promise<RegisteredPlugin> {
const bundleUrl = getPluginBundleUrl(plugin.key);
return await loadPluginFromUrl(bundleUrl);
}
/**
* 检查插件是否已加载
*/
export function isPluginLoaded(key: string): boolean {
const bundleUrl = getPluginBundleUrl(key);
return loadedPlugins.has(bundleUrl);
}
/**
* 获取已加载的插件
*/
export function getLoadedPlugin(key: string): RegisteredPlugin | undefined {
const bundleUrl = getPluginBundleUrl(key);
return loadedPlugins.get(bundleUrl);
}
/**
* 清除插件缓存(用于开发/调试)
*/
export function clearPluginCache(key?: string): void {
if (key) {
const bundleUrl = getPluginBundleUrl(key);
loadedPlugins.delete(bundleUrl);
injected.delete(bundleUrl);
} else {
loadedPlugins.clear();
injected.clear();
}
}

View File

@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// 代理 API 请求到后端服务器
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
rollupOptions: {
input: {
main: 'index.html',
'plugin-frame': 'plugin-frame.html',
},
},
},
})