diff --git a/packages/app/src/settings/extensions/components/ExtensionItem.jsx b/packages/app/src/settings/extensions/components/ExtensionItem.jsx
new file mode 100644
index 00000000..5a31b908
--- /dev/null
+++ b/packages/app/src/settings/extensions/components/ExtensionItem.jsx
@@ -0,0 +1,72 @@
+import React from "react"
+import { Tag, Switch, Button } from "antd"
+import { Icons } from "@components/Icons"
+import Image from "@components/Image"
+
+const ExtensionItem = ({ extension, onClickUninstall, onSwitchEnable }) => {
+ return (
+
+ {extension.manifest.icon && (
+
+
+
+ )}
+
+ {!extension.manifest.icon && (
+
+
+
+ )}
+
+
+
+ {extension.manifest.name} [{extension.id}]
+
+
+ {extension.manifest.description}
+
+
+ }>
+ v{extension.manifest.version}
+
+ }>
+ Load {extension.loadDuration.toFixed(2)}ms
+
+ {extension.manifest.author && (
+ }>
+ {extension.manifest.author}
+
+ )}
+ {extension.manifest.license && (
+ }>
+ {extension.manifest.license}
+
+ )}
+
+ {extension.manifest.homepage && (
+ }>
+ {extension.manifest.homepage}
+
+ )}
+
+
+
+
+ onSwitchEnable(extension, checked)}
+ />
+
+ }
+ onClick={() => onClickUninstall(extension)}
+ />
+
+
+ )
+}
+
+export default ExtensionItem
diff --git a/packages/app/src/settings/extensions/components/InstallCustom.jsx b/packages/app/src/settings/extensions/components/InstallCustom.jsx
new file mode 100644
index 00000000..2ba95e37
--- /dev/null
+++ b/packages/app/src/settings/extensions/components/InstallCustom.jsx
@@ -0,0 +1,137 @@
+import React from "react"
+import { Input, Alert, Button } from "antd"
+
+const confirmInstall = async (url, data) => {
+ return new Promise((resolve, reject) => {
+ app.layout.modal.confirm({
+ headerText: "Confirm Installation",
+ descriptionText:
+ "Check and verify the details of the extension before installing.",
+ onConfirm: async () => {
+ app.extensions
+ .install(url)
+ .then(() => {
+ app.message.success("Extension installed successfully")
+ resolve()
+ })
+ .catch((error) => {
+ console.error("Error installing extension:", error)
+ reject(error)
+ })
+ },
+ onCancel: () => {
+ resolve()
+ },
+ render: () => {
+ return (
+
+ Name: {data.name}
+ Version: {data.version}
+ Description: {data.description}
+ Author: {data.author}
+ License: {data.license}
+ Homepage: {data.homepage}
+
+ )
+ },
+ })
+ })
+}
+
+const InstallCustom = (props) => {
+ const [url, setUrl] = React.useState("")
+ const [error, setError] = React.useState("")
+ const [installing, setInstalling] = React.useState(false)
+
+ const handleInputChange = (event) => {
+ setUrl(event.target.value)
+ }
+
+ const handleInstallClick = async () => {
+ setError(null)
+ setInstalling(true)
+
+ if (!url) {
+ setError("Please enter a valid URL")
+ setInstalling(false)
+ return false
+ }
+
+ let data = await fetch(url).catch((error) => {
+ return null
+ })
+
+ if (
+ !data ||
+ data.status !== 200 ||
+ !data.headers.get("content-type").includes("application/json")
+ ) {
+ setError("Failed to fetch extension data")
+ setInstalling(false)
+ return false
+ }
+
+ try {
+ data = await data.json()
+ } catch (error) {
+ setError("Failed to parse extension data")
+ setInstalling(false)
+ return false
+ }
+
+ await confirmInstall(url, data)
+
+ setInstalling(false)
+
+ if (props.close) {
+ props.close()
+ }
+ }
+
+ return (
+
+
+
Install Custom Extension
+
+ Please enter the URL of the extension you want to install.
+
+
+
+
+
+
+ {error &&
}
+
+
+
+ )
+}
+
+export default InstallCustom
diff --git a/packages/app/src/settings/extensions/index.jsx b/packages/app/src/settings/extensions/index.jsx
index 6b8b5fbb..cb2664a6 100755
--- a/packages/app/src/settings/extensions/index.jsx
+++ b/packages/app/src/settings/extensions/index.jsx
@@ -2,11 +2,12 @@ import React from "react"
import loadable from "@loadable/component"
export default {
- id: "extensions",
- icon: "MdOutlineCode",
- label: "Extensions",
- group: "advanced",
- settings: [
-
- ]
-}
\ No newline at end of file
+ id: "extensions",
+ icon: "MdOutlineCode",
+ label: "Extensions",
+ group: "advanced",
+ render: () => {
+ const ExtensionsPage = loadable(() => import("./page"))
+ return
+ },
+}
diff --git a/packages/app/src/settings/extensions/index.less b/packages/app/src/settings/extensions/index.less
new file mode 100644
index 00000000..08cc1343
--- /dev/null
+++ b/packages/app/src/settings/extensions/index.less
@@ -0,0 +1,117 @@
+.extensions-page {
+ display: flex;
+ flex-direction: column;
+
+ gap: 20px;
+
+ .extensions-page-header {
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+ justify-content: space-between;
+
+ .extensions-page-header-text {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .extensions-page-header-actions {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+ }
+
+ .extensions-list {
+ display: flex;
+ flex-direction: column;
+
+ gap: 10px;
+ }
+}
+
+.extension-item {
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ gap: 10px;
+ padding: 10px;
+
+ border-radius: 12px;
+ background-color: var(--background-color-accent);
+
+ .extension-item-icon {
+ width: 50px;
+ height: 50px;
+
+ overflow: hidden;
+
+ border-radius: 12px;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+
+ object-fit: cover;
+ }
+ }
+
+ .extension-item-details {
+ display: flex;
+ flex-direction: column;
+
+ gap: 5px;
+
+ .ant-tag {
+ display: inline-flex;
+ flex-direction: row;
+ gap: 5px;
+
+ align-items: center;
+
+ width: fit-content;
+ }
+
+ .extension-item-name {
+ font-family: "DM Mono", monospace;
+ font-size: 0.9rem;
+ font-weight: 500;
+ }
+ }
+
+ .extension-item-actions {
+ position: absolute;
+ right: 0;
+ top: 0;
+
+ display: flex;
+ flex-direction: column;
+
+ align-items: center;
+ justify-content: center;
+
+ gap: 10px;
+ height: 100%;
+ padding: 10px;
+ }
+}
+
+.install-custom-extension {
+ display: flex;
+ flex-direction: column;
+
+ width: 100%;
+ gap: 10px;
+
+ font-size: 0.9rem;
+}
diff --git a/packages/app/src/settings/extensions/page.jsx b/packages/app/src/settings/extensions/page.jsx
new file mode 100644
index 00000000..50ca4104
--- /dev/null
+++ b/packages/app/src/settings/extensions/page.jsx
@@ -0,0 +1,104 @@
+import React from "react"
+import { Tag, Switch, Button } from "antd"
+import { Icons } from "@components/Icons"
+import Image from "@components/Image"
+
+import ExtensionItem from "./components/ExtensionItem"
+import InstallCustom from "./components/InstallCustom"
+
+import "./index.less"
+
+function getInstalledExtensions() {
+ let extensions = []
+
+ for (let extension of app.extensions.extensions.values()) {
+ extensions.push(extension)
+ }
+
+ return extensions
+}
+
+const ExtensionsPage = () => {
+ const [loading, setLoading] = React.useState(false)
+ const [extensions, setExtensions] = React.useState([])
+
+ const events = {
+ "extension:installed": () => {
+ setExtensions(getInstalledExtensions())
+ },
+ "extension:uninstalled": () => {
+ setExtensions(getInstalledExtensions())
+ },
+ }
+
+ const onSwitchEnable = (extension, checked) => {
+ app.extensions.toggleExtension(extension.id, checked)
+ return !checked
+ }
+
+ const onClickUninstall = (extension) => {
+ app.layout.modal.confirm({
+ headerText: "Uninstall Extension",
+ descriptionText:
+ "Are you sure you want to uninstall this extension?",
+ onConfirm: async () => {
+ await app.extensions.uninstall(extension.id)
+ app.message.success("Extension uninstalled")
+ },
+ })
+ }
+
+ React.useEffect(() => {
+ setLoading(true)
+ setExtensions(getInstalledExtensions())
+ setLoading(false)
+ }, [])
+
+ React.useEffect(() => {
+ for (const event in events) {
+ app.eventBus.on(event, events[event])
+ }
+
+ return () => {
+ for (const event in events) {
+ app.eventBus.off(event, events[event])
+ }
+ }
+ }, [])
+
+ return (
+
+
+
+
Extensions
+
Manage your extensions here.
+
+
+ }
+ onClick={() =>
+ app.layout.modal.open(
+ "install_custom",
+ InstallCustom,
+ )
+ }
+ />
+
+
+
+
+ {extensions.map((extension) => (
+
+ ))}
+
+
+ )
+}
+
+export default ExtensionsPage