forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { html, nothing, render as litRender } from "lit-html";
2
3/**
4 * @import { TemplateResult } from "lit-html"
5 */
6
7/**
8 * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; onRemove: () => void }} ConnectItem
9 */
10
11/**
12 * Sets up a connect facet UI: a card with "Add audio input" and
13 * "Use as userdata storage" buttons, a dialog with a form, and a
14 * reactive list of configured items below a divider.
15 *
16 * @param {Object} config
17 * @param {string} config.title - Card header title
18 * @param {TemplateResult | string} config.description - Content shown on the left side
19 * @param {TemplateResult} [config.rightContent] - Extra content shown at the top of the right side
20 * @param {TemplateResult} config.formFields - Form body content (inputs, footnotes, etc.)
21 * @param {(mode: 'input' | 'output') => Promise<void>} config.onSubmit
22 * @param {boolean} [config.hasInput] - Whether to show the "Add audio input" button (default: true)
23 * @param {boolean} [config.hasOutput] - Whether to show the "Use as userdata storage" button (default: true)
24 * @param {() => Promise<void>} [config.onOutputActivate] - Called instead of opening the dialog when output is already configured but inactive
25 *
26 * @returns {{ setItems: (items: ConnectItem[]) => void, setError: (message: string | null) => void }}
27 */
28export function setup(
29 {
30 title,
31 description,
32 rightContent = nothing,
33 formFields,
34 onSubmit,
35 hasInput = true,
36 hasOutput = true,
37 onOutputActivate,
38 },
39) {
40 const main = document.querySelector("main");
41 if (!main) throw new Error("No <main> element");
42
43 litRender(
44 html`
45 <div class="facet__left">
46 <div>
47 <a href="./dashboard/" class="diffuse-logo-container">
48 <svg viewBox="0 0 902 134" width="160">
49 <title>Diffuse</title>
50 <use
51 xlink:href="images/diffuse-current.svg#diffuse"
52 href="images/diffuse-current.svg#diffuse"
53 ></use>
54 </svg>
55 </a>
56 </div>
57 <h1>${title}</h1>
58 ${description}
59 </div>
60 <div class="facet__right">
61 ${rightContent}
62 <div class="button-row">
63 ${hasInput
64 ? html`
65 <button id="connect-add-input-btn">
66 <i class="ph-fill ph-music-notes"></i>
67 Add audio input
68 </button>
69 `
70 : nothing}
71 ${hasOutput
72 ? html`
73 <button id="connect-add-output-btn" class="button--brand">
74 <i class="ph-fill ph-person"></i>
75 Use as userdata storage
76 </button>
77 `
78 : nothing}
79 </div>
80 <div id="connect-card-error" class="callout callout--danger" hidden></div>
81 <hr id="connect-divider" hidden>
82 <ul id="connect-list" class="connect-list" hidden></ul>
83 </div>
84
85 <dialog id="connect-dialog">
86 <div class="dialog-header">
87 <strong id="connect-dialog-title"></strong>
88 </div>
89 <form id="connect-form" class="dialog-body">
90 ${formFields}
91 <div id="connect-error" class="callout callout--danger" hidden></div>
92 </form>
93 <div class="dialog-footer">
94 <button id="connect-submit-btn" type="submit" form="connect-form" class="button--brand">Add</button>
95 <button id="connect-cancel-btn" type="button">Cancel</button>
96 </div>
97 </dialog>
98 `,
99 main,
100 );
101
102 const dialog =
103 /** @type {HTMLDialogElement} */ (main.querySelector("#connect-dialog"));
104 const dialogTitleEl =
105 /** @type {HTMLElement} */ (main.querySelector("#connect-dialog-title"));
106 const form =
107 /** @type {HTMLFormElement} */ (main.querySelector("#connect-form"));
108 const dialogErrorEl =
109 /** @type {HTMLElement} */ (main.querySelector("#connect-error"));
110 const cardErrorEl =
111 /** @type {HTMLElement} */ (main.querySelector("#connect-card-error"));
112 const divider =
113 /** @type {HTMLElement} */ (main.querySelector("#connect-divider"));
114 const list = /** @type {HTMLElement} */ (main.querySelector("#connect-list"));
115 const outputBtn =
116 /** @type {HTMLElement} */ (main.querySelector("#connect-add-output-btn"));
117
118 /** @type {'input' | 'output'} */
119 let mode = "input";
120
121 /** @type {ConnectItem[]} */
122 let currentItems = [];
123
124 /** @param {string | null} message */
125 const setDialogError = (message) => {
126 dialogErrorEl.hidden = message === null;
127 dialogErrorEl.textContent = message;
128 };
129
130 /** @param {string | null} message */
131 const setError = (message) => {
132 cardErrorEl.hidden = message === null;
133 cardErrorEl.textContent = message;
134 };
135
136 /** @param {'input' | 'output'} m */
137 const openDialog = (m) => {
138 mode = m;
139 dialogTitleEl.textContent = m === "input"
140 ? "Add audio input"
141 : "Use as userdata storage";
142 form.reset();
143 setDialogError(null);
144 dialog.showModal();
145 };
146
147 if (hasInput) {
148 main
149 .querySelector("#connect-add-input-btn")
150 ?.addEventListener("click", () => openDialog("input"));
151 }
152
153 main
154 .querySelector("#connect-add-output-btn")
155 ?.addEventListener("click", () => {
156 if (onOutputActivate && currentItems.some((i) => i.isOutput)) {
157 onOutputActivate();
158 } else {
159 openDialog("output");
160 }
161 });
162
163 main.querySelector("#connect-cancel-btn")?.addEventListener("click", () => {
164 setDialogError(null);
165 dialog.close();
166 });
167
168 const submitBtn =
169 /** @type {HTMLElement} */ (main.querySelector("#connect-submit-btn"));
170
171 form.addEventListener("submit", async (e) => {
172 e.preventDefault();
173 setDialogError(null);
174 submitBtn.setAttribute("disabled", "");
175 submitBtn.textContent = "Loading …";
176 try {
177 await onSubmit(mode);
178 dialog.close();
179 } catch (err) {
180 setDialogError(
181 err instanceof Error ? err.message : "Something went wrong",
182 );
183 } finally {
184 submitBtn.removeAttribute("disabled");
185 submitBtn.textContent = "Add";
186 }
187 });
188
189 return {
190 setError,
191
192 /**
193 * Updates the list of configured items below the divider.
194 * Call inside an effect() for reactivity.
195 *
196 * @param {ConnectItem[]} items
197 */
198 setItems(items) {
199 currentItems = items;
200 divider.hidden = items.length === 0;
201 list.hidden = items.length === 0;
202 if (outputBtn) outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput);
203 litRender(
204 html`
205 ${items.map(
206 ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) =>
207 html`
208 <li class="connect-item">
209 <div class="connect-item__info">
210 <span class="connect-item__name">${name}</span>
211 <span class="connect-item__detail">${detail}</span>
212 </div>
213 <div class="connect-item__tags">
214 ${isInput
215 ? html`<span class="badge">Input</span>`
216 : nothing}
217 ${isOutput
218 ? html`<span class="badge ${isSelectedOutput ? "badge--brand" : "badge--warning"}">Output</span>`
219 : nothing}
220 </div>
221 <button
222 class="button--plain button--small"
223 aria-label="Remove"
224 @click="${onRemove}"
225 >
226 <i class="ph-bold ph-x"></i>
227 </button>
228 </li>
229 `,
230 )}
231 `,
232 list,
233 );
234 },
235 };
236}