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;