generic markdown modal and plugins wiring
Alessandro committed
Feb 24, 2026 at 15:14 UTC
59ed70d8c1919c55c6c11260d3bbad165ad29d84
6 files changed
+138
python/api/plugins.py
+19
@@ -132,4 +132,23 @@ class Plugins(ApiHandler):
132
)
133
return {"ok": True}
134
135
+ if action == "get_doc":
136
+ plugin_name = input.get("plugin_name", "")
137
+ doc = input.get("doc", "") # "readme" or "license"
138
+ if not plugin_name:
139
+ return Response(status=400, response="Missing plugin_name")
140
+ if doc not in ("readme", "license"):
141
+ return Response(status=400, response="doc must be 'readme' or 'license'")
142
+
143
+ plugin_dir = plugins.find_plugin_dir(plugin_name)
144
+ if not plugin_dir:
145
+ return Response(status=404, response="Plugin not found")
146
+
147
+ filename = "README.md" if doc == "readme" else "LICENSE"
148
+ file_path = files.get_abs_path(plugin_dir, filename)
149
+ if not files.exists(file_path):
150
+ return Response(status=404, response=f"{filename} not found")
151
+
152
+ return {"ok": True, "content": files.read_file(file_path), "filename": filename}
153
+
154
return Response(status=400, response=f"Unknown action: {action}")
python/helpers/plugins.py
+6
@@ -53,6 +53,8 @@ class PluginListItem(BaseModel):
53
is_custom: bool = False
54
has_main_screen: bool = False
55
has_config_screen: bool = False
56
+ has_readme: bool = False
57
+ has_license: bool = False
58
toggle_state: ToggleState = "disabled"
59
60
@@ -96,6 +98,8 @@ def get_enhanced_plugins_list(
98
)
99
has_main_screen = files.exists(str(d / "webui" / "main.html"))
100
has_config_screen = files.exists(str(d / "webui" / "config.html"))
101
+ has_readme = files.exists(str(d / "README.md"))
102
+ has_license = files.exists(str(d / "LICENSE"))
103
toggle_state = get_toggle_state(d.name)
104
results.append(
105
PluginListItem(
@@ -111,6 +115,8 @@ def get_enhanced_plugins_list(
115
is_custom=is_custom,
116
has_main_screen=has_main_screen,
117
has_config_screen=has_config_screen,
118
+ has_readme=has_readme,
119
+ has_license=has_license,
120
toggle_state=toggle_state,
121
)
122
)
webui/components/modals/markdown/markdown-modal.html
new
+54
@@ -0,0 +1,54 @@
1
+<html>
2
+<head>
3
+ <title>Document</title>
4
+ <script type="module">
5
+ import { store } from "/components/modals/markdown/markdown-store.js";
6
+ </script>
7
+</head>
8
+<body>
9
+ <div x-data>
10
+ <template x-if="$store.markdownModal">
11
+ <div x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = $store.markdownModal.title)"
12
+ x-destroy="$store.markdownModal.cleanup()"
13
+ class="md-modal-root">
14
+
15
+ <div x-show="$store.markdownModal.error" class="md-modal-error">
16
+ <span class="material-symbols-outlined">error</span>
17
+ <span x-text="$store.markdownModal.error"></span>
18
+ </div>
19
+
20
+ <div class="md-modal-body msg-content"
21
+ x-show="!$store.markdownModal.error"
22
+ x-html="$store.markdownModal.renderedHtml">
23
+ </div>
24
+ </div>
25
+ </template>
26
+ </div>
27
+
28
+ <div class="modal-footer" data-modal-footer>
29
+ <button class="btn btn-cancel" @click="window.closeModal?.()">Close</button>
30
+ </div>
31
+
32
+ <style>
33
+ .md-modal-root {
34
+ display: flex;
35
+ flex-direction: column;
36
+ width: 100%;
37
+ }
38
+
39
+ .md-modal-body {
40
+ padding: 1.25rem 1.5rem;
41
+ overflow-y: auto;
42
+ max-height: 72vh;
43
+ }
44
+
45
+ .md-modal-error {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 0.5rem;
49
+ color: var(--color-error, #e74c3c);
50
+ padding: 1rem 1.5rem;
51
+ }
52
+ </style>
53
+</body>
54
+</html>
webui/components/modals/markdown/markdown-store.js
new
+25
@@ -0,0 +1,25 @@
1
+import { marked } from "/vendor/marked/marked.esm.js";
2
+import { createStore } from "/js/AlpineStore.js";
3
+
4
+export const store = createStore("markdownModal", {
5
+ title: "",
6
+ content: "",
7
+ error: null,
8
+
9
+ open(title, content) {
10
+ this.title = title;
11
+ this.content = content;
12
+ this.error = null;
13
+ },
14
+
15
+ get renderedHtml() {
16
+ if (!this.content) return "";
17
+ return marked.parse(this.content, { breaks: true });
18
+ },
19
+
20
+ cleanup() {
21
+ this.title = "";
22
+ this.content = "";
23
+ this.error = null;
24
+ },
25
+});
webui/components/plugins/list/plugin-list.html
+16
@@ -79,6 +79,22 @@
79
<span class="icon material-symbols-outlined">settings</span> Config
80
</button>
81
</template>
82
+ <template x-if="plugin.has_readme">
83
+ <button type="button"
84
+ class="button"
85
+ title="README"
86
+ @click="$store.pluginListStore.openPluginDoc(plugin, 'readme')">
87
+ <span class="icon material-symbols-outlined">description</span> README
88
+ </button>
89
+ </template>
90
+ <template x-if="plugin.has_license">
91
+ <button type="button"
92
+ class="button"
93
+ title="LICENSE"
94
+ @click="$store.pluginListStore.openPluginDoc(plugin, 'license')">
95
+ <span class="icon material-symbols-outlined">gavel</span> License
96
+ </button>
97
+ </template>
98
<button type="button"
99
class="button"
100
title="Info"
webui/components/plugins/list/pluginListStore.js
+18
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2
import * as api from "/js/api.js";
3
import "/components/plugins/plugin-settings-store.js";
4
import "/components/plugins/toggle/plugin-toggle-store.js";
5
+import "/components/modals/markdown/markdown-store.js";
6
import {
7
store as notificationStore,
8
defaultPriority,
@@ -126,6 +127,23 @@ const model = {
127
}
128
},
129
130
+ async openPluginDoc(plugin, doc) {
131
+ try {
132
+ const response = await api.callJsonApi("plugins", {
133
+ action: "get_doc",
134
+ plugin_name: plugin.name,
135
+ doc,
136
+ });
137
+ if (response?.error) throw new Error(response.error);
138
+ const markdownModal = Alpine.store("markdownModal");
139
+ if (!markdownModal) throw new Error("Markdown modal store unavailable.");
140
+ markdownModal.open(response.filename, response.content);
141
+ window.openModal?.("components/modals/markdown/markdown-modal.html");
142
+ } catch (e) {
143
+ showErrorNotification(e, "Failed to open document");
144
+ }
145
+ },
146
+
147
openPluginInfo(plugin) {
148
if (!plugin) return;
149
this.selectedPlugin = plugin;