A music player that connects to your cloud/distributed storage.
1import { Temporal } from "~/common/temporal.js";
2
3import "@awesome.me/webawesome/dist/components/split-panel/split-panel.js";
4import "@awesome.me/webawesome/dist/components/dialog/dialog.js";
5import "@awesome.me/webawesome/dist/components/button/button.js";
6import "@awesome.me/webawesome/dist/components/input/input.js";
7import "@awesome.me/webawesome/dist/components/icon/icon.js";
8import "@awesome.me/webawesome/dist/components/select/select.js";
9import "@awesome.me/webawesome/dist/components/option/option.js";
10
11import "~/common/webawesome/detect-dark.js";
12import foundation from "~/common/foundation.js";
13import * as Output from "~/common/output.js";
14
15// Set doc title
16document.title = "Split View | Diffuse";
17
18/**
19 * @import { default as WaSplitPanel } from "@awesome.me/webawesome/dist/components/split-panel/split-panel.js"
20 * @import { default as WaDialog } from "@awesome.me/webawesome/dist/components/dialog/dialog.js"
21 * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js"
22 * @import { default as WaIcon } from "@awesome.me/webawesome/dist/components/icon/icon.js"
23 * @import { default as WaSelect } from "@awesome.me/webawesome/dist/components/select/select.js"
24 */
25
26/**
27 * @typedef {{ type: "pane", facet: string | null }} PaneNode
28 * @typedef {{ type: "split", orientation: "horizontal" | "vertical", position: number, start: Node, end: Node }} SplitNode
29 * @typedef {PaneNode | SplitNode} Node
30 */
31
32const STORAGE_KEY = "diffuse/facets/misc/split-view/builder/layout";
33
34// ─── State ───────────────────────────────────────────────────────────────────
35
36/** @type {Node} */
37let state = loadState();
38
39let editMode = false;
40
41/** @type {string | null} */
42let pendingPaneId = null;
43
44/** @returns {Node} */
45function loadState() {
46 try {
47 return /** @type {Node} */ (JSON.parse(
48 localStorage.getItem(STORAGE_KEY) ?? "null",
49 )) ?? defaultState();
50 } catch {
51 return defaultState();
52 }
53}
54
55function saveState() {
56 localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
57}
58
59/** @returns {PaneNode} */
60function defaultState() {
61 return { type: "pane", facet: null };
62}
63
64// ─── Tree helpers ─────────────────────────────────────────────────────────────
65// Nodes are identified by a dot-separated path: "root", "root.start", "root.end.start"
66
67/**
68 * @param {string} path
69 * @returns {Node}
70 */
71function getNode(path) {
72 if (path === "root") return state;
73 const parts = path.replace(/^root\./, "").split(".");
74 /** @type {any} */
75 let node = state;
76 for (const part of parts) node = node[part];
77 return /** @type {Node} */ (node);
78}
79
80/**
81 * @param {string} path
82 * @param {Node} newNode
83 */
84function replaceNode(path, newNode) {
85 if (path === "root") {
86 state = newNode;
87 return;
88 }
89 const parts = path.split(".");
90 const parentPath = parts.slice(0, -1).join(".");
91 const childKey = parts[parts.length - 1];
92 const parent = /** @type {any} */ (getNode(parentPath));
93 parent[childKey] = newNode;
94}
95
96// ─── Mutations ────────────────────────────────────────────────────────────────
97
98/**
99 * @param {string} nodePath
100 * @param {"horizontal" | "vertical"} orientation
101 */
102function splitPane(nodePath, orientation) {
103 const existing = getNode(nodePath);
104 replaceNode(nodePath, {
105 type: "split",
106 orientation,
107 position: 50,
108 start: existing,
109 end: { type: "pane", facet: null },
110 });
111 saveState();
112 render();
113}
114
115/** @param {string} nodePath */
116function removePane(nodePath) {
117 if (nodePath === "root") return;
118 const parts = nodePath.split(".");
119 const parentPath = parts.slice(0, -1).join(".");
120 const childKey = parts[parts.length - 1];
121 const siblingKey = childKey === "start" ? "end" : "start";
122 const parent = /** @type {any} */ (getNode(parentPath));
123 const sibling = /** @type {Node} */ (parent[siblingKey]);
124 replaceNode(parentPath, sibling);
125 saveState();
126 render();
127}
128
129/**
130 * @param {string} nodePath
131 * @param {string} facetPath
132 */
133function setFacet(nodePath, facetPath) {
134 const pane = /** @type {PaneNode} */ (getNode(nodePath));
135 pane.facet = facetPath;
136 saveState();
137 render();
138}
139
140// ─── Rendering ────────────────────────────────────────────────────────────────
141
142const layout = /** @type {HTMLElement} */ (document.querySelector("#layout"));
143
144function render() {
145 layout.innerHTML = "";
146 layout.appendChild(renderNode(state, "root", true));
147 layout.classList.toggle("edit-mode", editMode);
148}
149
150/**
151 * @param {Node} node
152 * @param {string} path
153 * @param {boolean} isRoot
154 * @returns {HTMLElement}
155 */
156function renderNode(node, path, isRoot) {
157 if (node.type === "split") {
158 const panel =
159 /** @type {WaSplitPanel} */ (document.createElement("wa-split-panel"));
160 panel.position = node.position;
161 if (node.orientation === "vertical") panel.orientation = "vertical";
162
163 const startSlot = document.createElement("div");
164 startSlot.slot = "start";
165 startSlot.appendChild(renderNode(node.start, path + ".start", false));
166
167 const endSlot = document.createElement("div");
168 endSlot.slot = "end";
169 endSlot.appendChild(renderNode(node.end, path + ".end", false));
170
171 panel.appendChild(startSlot);
172 panel.appendChild(endSlot);
173
174 panel.addEventListener("wa-reposition", () => {
175 const splitNode = /** @type {SplitNode} */ (getNode(path));
176 splitNode.position = panel.position;
177 saveState();
178 });
179
180 return panel;
181 }
182
183 // Pane
184 const pane = document.createElement("div");
185 pane.className = "pane" + (node.facet ? "" : " pane--empty");
186
187 if (node.facet) {
188 const iframe = document.createElement("iframe");
189 const uri = node.facet.includes("://")
190 ? node.facet
191 : `diffuse://${node.facet}`;
192 iframe.src = "l/?uri=" + encodeURIComponent(uri);
193 iframe.allow = "autoplay";
194 pane.appendChild(iframe);
195 }
196
197 const overlay = document.createElement("div");
198 overlay.className = "pane-overlay";
199
200 if (node.facet) {
201 const title =
202 document.querySelector(`#facet-select wa-option[value="${node.facet}"]`)
203 ?.textContent?.trim() ?? node.facet;
204 const label = document.createElement("div");
205 label.className = "pane-name";
206 label.style.fontWeight = "700";
207 label.textContent = title;
208 overlay.appendChild(label);
209 }
210
211 overlay.appendChild(
212 makeWaButton(
213 node.facet ? "Change facet" : "+ Add facet",
214 "neutral",
215 "filled",
216 () => openPicker(path),
217 ),
218 );
219 overlay.appendChild(
220 makeWaButton(
221 "Split left / right",
222 "neutral",
223 "outlined",
224 () => splitPane(path, "horizontal"),
225 ),
226 );
227 overlay.appendChild(
228 makeWaButton(
229 "Split top / bottom",
230 "neutral",
231 "outlined",
232 () => splitPane(path, "vertical"),
233 ),
234 );
235
236 if (!isRoot) {
237 overlay.appendChild(
238 makeWaButton("Remove", "danger", "outlined", () => removePane(path)),
239 );
240 }
241
242 pane.appendChild(overlay);
243 return pane;
244}
245
246/**
247 * @param {string} text
248 * @param {string} variant
249 * @param {string} appearance
250 * @param {() => void} onClick
251 * @returns {HTMLElement}
252 */
253function makeWaButton(text, variant, appearance, onClick) {
254 const btn = document.createElement("wa-button");
255 btn.setAttribute("variant", variant);
256 btn.setAttribute("appearance", appearance);
257 btn.setAttribute("size", "small");
258 btn.style.width = "100%";
259 btn.textContent = text;
260 btn.addEventListener("click", (e) => {
261 e.stopPropagation();
262 onClick();
263 });
264 return btn;
265}
266
267// ─── Divider drag: disable iframe pointer events while dragging ───────────────
268
269document.addEventListener("mousedown", (e) => {
270 const isDivider = e.composedPath().some(
271 (el) => el instanceof Element && el.getAttribute("part") === "divider",
272 );
273 if (isDivider) layout.classList.add("dragging");
274}, { capture: true });
275
276document.addEventListener("mouseup", () => {
277 layout.classList.remove("dragging");
278});
279
280// ─── Edit mode ────────────────────────────────────────────────────────────────
281
282const editToggle =
283 /** @type {HTMLElement} */ (document.querySelector("#edit-toggle"));
284const editIcon = /** @type {WaIcon} */ (document.querySelector("#edit-icon"));
285const saveCopyBtn =
286 /** @type {HTMLElement} */ (document.querySelector("#save-copy-btn"));
287const saveCopyIcon =
288 /** @type {WaIcon} */ (document.querySelector("#save-copy-icon"));
289
290editToggle.addEventListener("click", () => {
291 editMode = !editMode;
292 editToggle.setAttribute("aria-label", editMode ? "Done" : "Edit layout");
293 editIcon.name = editMode ? "xmark" : "border-all";
294 layout.classList.toggle("edit-mode", editMode);
295 saveCopyBtn.style.display = editMode ? "" : "none";
296});
297
298saveCopyBtn.addEventListener("click", async () => {
299 await saveSimplifiedCopy();
300 saveCopyIcon.name = "check";
301 setTimeout(() => {
302 saveCopyIcon.name = "floppy-disk";
303 }, 2000);
304});
305
306// ─── Facet picker ─────────────────────────────────────────────────────────────
307
308const pickerDialog =
309 /** @type {WaDialog} */ (document.querySelector("#facet-picker"));
310const facetSelect =
311 /** @type {WaSelect} */ (document.querySelector("#facet-select"));
312const customPath =
313 /** @type {WaInput} */ (document.querySelector("#custom-path"));
314const customConfirm =
315 /** @type {HTMLElement} */ (document.querySelector("#custom-confirm"));
316
317customConfirm.addEventListener("click", () => {
318 const val = customPath.value?.trim() || /** @type {string} */
319 (facetSelect.value) || "";
320 if (val && pendingPaneId !== null) setFacet(pendingPaneId, val);
321 pendingPaneId = null;
322 pickerDialog.open = false;
323});
324
325pickerDialog.addEventListener("wa-hide", (e) => {
326 if (e.target === pickerDialog) pendingPaneId = null;
327});
328
329/** @param {string} nodePath */
330function openPicker(nodePath) {
331 pendingPaneId = nodePath;
332 facetSelect.value = null;
333 customPath.value = "";
334 pickerDialog.open = true;
335}
336
337// ─── Save simplified copy ─────────────────────────────────────────────────────
338
339/**
340 * @param {Node} node
341 * @param {string} indent
342 * @returns {string}
343 */
344function generateNodeHTML(node, indent = "") {
345 const inner = indent + " ";
346
347 if (node.type === "split") {
348 const orientationAttr = node.orientation === "vertical"
349 ? ' orientation="vertical"'
350 : "";
351 return `${indent}<wa-split-panel position="${node.position}"${orientationAttr}>
352${inner}<div slot="start">
353${generateNodeHTML(node.start, inner + " ")}
354${inner}</div>
355${inner}<div slot="end">
356${generateNodeHTML(node.end, inner + " ")}
357${inner}</div>
358${indent}</wa-split-panel>`;
359 }
360
361 if (node.facet) {
362 const uri = node.facet.includes("://")
363 ? node.facet
364 : `diffuse://${node.facet}`;
365 const src = "l/?uri=" + encodeURIComponent(uri);
366 return `${indent}<div class="pane">
367${inner}<iframe src="${src}" allow="autoplay"></iframe>
368${indent}</div>`;
369 }
370
371 return `${indent}<div class="pane"></div>`;
372}
373
374/**
375 * @param {string} id
376 */
377function generateSimplifiedHTML(id) {
378 const scriptClose = "</" + "script>";
379 return `\
380<link rel="stylesheet" href="vendor/@awesome.me/webawesome/styles/themes/default.css" />
381
382<style>
383 body { margin: 0; height: 100dvh; overflow: hidden; }
384 #layout, #layout > * { height: 100%; }
385 wa-split-panel { height: 100%; }
386 [slot="start"], [slot="end"] { height: 100%; }
387 .pane { height: 100%; }
388 .pane iframe { border: none; width: 100%; height: 100%; }
389 .dragging iframe { pointer-events: none; }
390</style>
391
392<div id="layout">
393${generateNodeHTML(state, " ")}
394</div>
395
396<script type="module">
397 import "@awesome.me/webawesome/dist/components/split-panel/split-panel.js";
398 import "~/common/webawesome/detect-dark.js";
399
400 const layout = document.querySelector("#layout");
401
402 document.addEventListener("mousedown", (e) => {
403 const isDivider = e.composedPath().some(
404 (el) => el instanceof Element && el.getAttribute("part") === "divider",
405 );
406 if (isDivider) layout.classList.add("dragging");
407 }, { capture: true });
408
409 document.addEventListener("mouseup", () => {
410 layout.classList.remove("dragging");
411 });
412
413 const POSITIONS_KEY = "diffuse/facets/misc/split-view/${id}/positions";
414 const savedPositions = (() => {
415 try { return JSON.parse(localStorage.getItem(POSITIONS_KEY) ?? "{}"); }
416 catch { return {}; }
417 })();
418
419 document.querySelectorAll("wa-split-panel").forEach((panel, i) => {
420 if (savedPositions[i] !== undefined) panel.position = savedPositions[i];
421 panel.addEventListener("wa-reposition", () => {
422 savedPositions[i] = panel.position;
423 localStorage.setItem(POSITIONS_KEY, JSON.stringify(savedPositions));
424 });
425 });
426${scriptClose}`;
427}
428
429async function saveSimplifiedCopy() {
430 const output = await foundation.orchestrator.output();
431 const id = crypto.randomUUID();
432 const html = generateSimplifiedHTML(id);
433 const now = new Date().toISOString();
434
435 await output.facets.save([
436 ...(await Output.data(output.facets)),
437 {
438 $type: "sh.diffuse.output.facet",
439 id,
440 name: `Split View (${Temporal.Now.instant().toLocaleString()})`,
441 html,
442 createdAt: now,
443 updatedAt: now,
444 },
445 ]);
446}
447
448// ─── Init ─────────────────────────────────────────────────────────────────────
449
450render();