mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 05:02:42 +08:00
feat: enhance plugin functionality
This commit is contained in:
142
web/bun.lock
142
web/bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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
28
web/plugin-frame.html
Normal 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>
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 默认布局背景 */
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "数据库设置",
|
||||
|
||||
@@ -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
247
web/src/plugin-frame.ts
Normal 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();
|
||||
279
web/src/plugins/externals.ts
Normal file
279
web/src/plugins/externals.ts
Normal 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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user