App.svelte 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. <script lang="ts">
  2. import { onMount } from "svelte";
  3. import AboutModal from "./lib/AboutModal.svelte";
  4. import BankBar from "./lib/BankBar.svelte";
  5. import PadPanel from "./lib/PadPanel.svelte";
  6. import SequencePanel from "./lib/SequencePanel.svelte";
  7. import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
  8. import { app, newProject, refresh, ui, type Tab } from "./lib/store.svelte";
  9. import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
  10. onMount(refresh);
  11. const st = $derived(app.state);
  12. // The bindings type Go slices as `T[] | null`; Go always sends arrays.
  13. const pads = $derived(st?.pads ?? []);
  14. // Sequences by slot, for the grid and the panel.
  15. const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
  16. function perBank(filled: (slot: number) => boolean): number[] {
  17. return Array.from({ length: BANKS.length }, (_, b) => {
  18. let n = 0;
  19. for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
  20. return n;
  21. });
  22. }
  23. const padCounts = $derived(perBank((s) => (pads[s]?.file ?? "") !== ""));
  24. const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
  25. const padCells: Cell[] = $derived(
  26. st
  27. ? gridSlots(ui.padBank).map((slot) => {
  28. const p = pads[slot];
  29. const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : ""].filter(Boolean);
  30. return {
  31. slot,
  32. label: p.label,
  33. name: p.name,
  34. meta: p.file ? badges.join(" · ") : "",
  35. aria: `Pad ${p.label}, ${p.file || "empty"}`,
  36. };
  37. })
  38. : [],
  39. );
  40. const seqCells: Cell[] = $derived(
  41. st
  42. ? gridSlots(ui.seqBank).map((slot) => {
  43. const q = seqBySlot.get(slot);
  44. const label = pads[slot].label; // pad and sequence slots share labels
  45. return {
  46. slot,
  47. label,
  48. name: q?.name ?? "",
  49. meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
  50. aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
  51. };
  52. })
  53. : [],
  54. );
  55. function pickTab(t: Tab) {
  56. ui.tab = t;
  57. }
  58. // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
  59. function tabKey(e: KeyboardEvent) {
  60. if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
  61. ui.tab = ui.tab === "pads" ? "sequences" : "pads";
  62. document.getElementById("tab-" + ui.tab)?.focus();
  63. }
  64. }
  65. </script>
  66. <div class="page">
  67. <header>
  68. <h1>mpckit</h1>
  69. <div class="name">
  70. <label class="eyebrow" for="project-name">Project name</label>
  71. <input id="project-name" type="text" value={st?.name ?? ""} readonly />
  72. </div>
  73. <div class="actions">
  74. <button type="button" class="btn" onclick={newProject}>New</button>
  75. <button type="button" class="btn" disabled>Load</button>
  76. <button type="button" class="btn" disabled>Export zip</button>
  77. <button type="button" class="btn primary" disabled>Export to folder</button>
  78. </div>
  79. <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
  80. </header>
  81. {#if app.error}
  82. <p class="error" role="alert">{app.error}</p>
  83. {/if}
  84. <div class="tabs" role="tablist" aria-label="Views">
  85. <button id="tab-pads" type="button" role="tab" aria-selected={ui.tab === "pads"} aria-controls="view-pads" tabindex={ui.tab === "pads" ? 0 : -1} onclick={() => pickTab("pads")} onkeydown={tabKey}>Pads</button>
  86. <button id="tab-sequences" type="button" role="tab" aria-selected={ui.tab === "sequences"} aria-controls="view-sequences" tabindex={ui.tab === "sequences" ? 0 : -1} onclick={() => pickTab("sequences")} onkeydown={tabKey}>Sequences</button>
  87. </div>
  88. {#if st}
  89. {#if ui.tab === "pads"}
  90. <div id="view-pads" class="panels" role="tabpanel" aria-labelledby="tab-pads">
  91. <section class="panel grid-panel" aria-label="Pads">
  92. <div class="grid-head">
  93. <h2 class="eyebrow">Pad bank</h2>
  94. <span class="mono summary">Bank {BANKS[ui.padBank]} · {padCounts[ui.padBank]} of 16 filled</span>
  95. </div>
  96. <BankBar
  97. bank={ui.padBank}
  98. counts={padCounts}
  99. what="pads filled"
  100. onpick={(b) => {
  101. ui.padBank = b;
  102. ui.padSlot = b * PER_BANK;
  103. }}
  104. />
  105. <SlotGrid cells={padCells} selected={ui.padSlot} onpick={(s) => (ui.padSlot = s)} />
  106. <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
  107. </section>
  108. <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
  109. </div>
  110. {:else}
  111. <div id="view-sequences" class="panels" role="tabpanel" aria-labelledby="tab-sequences">
  112. <section class="panel grid-panel" aria-label="Sequence slots">
  113. <div class="grid-head">
  114. <h2 class="eyebrow">Sequence bank</h2>
  115. <span class="mono summary">Bank {BANKS[ui.seqBank]} · {seqCounts[ui.seqBank]} of 16 used</span>
  116. </div>
  117. <BankBar
  118. bank={ui.seqBank}
  119. counts={seqCounts}
  120. what="slots used"
  121. onpick={(b) => {
  122. ui.seqBank = b;
  123. ui.seqSlot = b * PER_BANK;
  124. }}
  125. />
  126. <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
  127. <p class="hint grid-hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
  128. </section>
  129. <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
  130. </div>
  131. {/if}
  132. <footer class="mono">
  133. <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
  134. <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
  135. <span>WAV files only</span>
  136. </footer>
  137. {:else if !app.error}
  138. <p class="hint">Loading…</p>
  139. {/if}
  140. {#if ui.aboutOpen}
  141. <AboutModal onclose={() => (ui.aboutOpen = false)} />
  142. {/if}
  143. </div>
  144. <style>
  145. /* Exactly the window's height: header, tabs, panels (which take the rest)
  146. and footer. Nothing scrolls at the page level. */
  147. .page {
  148. height: 100%;
  149. max-width: 1440px;
  150. margin: 0 auto;
  151. padding: 12px 20px;
  152. display: flex;
  153. flex-direction: column;
  154. gap: 12px;
  155. }
  156. h1 {
  157. margin: 0;
  158. font-family: var(--display);
  159. font-weight: 700;
  160. font-size: 24px;
  161. letter-spacing: 0.02em;
  162. }
  163. .about-link {
  164. min-height: 40px;
  165. padding: 0 8px;
  166. background: transparent;
  167. color: var(--accent);
  168. border: 0;
  169. font: inherit;
  170. font-weight: 500;
  171. text-decoration: underline;
  172. text-underline-offset: 3px;
  173. cursor: pointer;
  174. }
  175. header {
  176. flex: none;
  177. display: flex;
  178. align-items: center;
  179. gap: 16px;
  180. padding-bottom: 12px;
  181. border-bottom: 1px solid var(--line);
  182. }
  183. .name {
  184. display: flex;
  185. align-items: center;
  186. gap: 10px;
  187. flex: 1 1 auto;
  188. min-width: 0;
  189. max-width: 380px;
  190. }
  191. .name label {
  192. white-space: nowrap;
  193. }
  194. .name input {
  195. height: 40px;
  196. min-width: 120px;
  197. padding: 0 12px;
  198. background: var(--surface);
  199. color: var(--text);
  200. border: 1px solid var(--edge);
  201. border-radius: 6px;
  202. font: inherit;
  203. font-weight: 600;
  204. width: 100%;
  205. }
  206. .actions {
  207. display: flex;
  208. gap: 8px;
  209. margin-left: auto;
  210. }
  211. .tabs {
  212. flex: none;
  213. display: flex;
  214. gap: 4px;
  215. border-bottom: 1px solid var(--line);
  216. }
  217. .tabs button {
  218. height: 40px;
  219. padding: 0 22px;
  220. background: transparent;
  221. color: var(--muted);
  222. border: 0;
  223. border-bottom: 3px solid transparent;
  224. font-family: var(--display);
  225. font-size: 16px;
  226. font-weight: 700;
  227. cursor: pointer;
  228. }
  229. .tabs button[aria-selected="true"] {
  230. color: var(--text);
  231. border-bottom-color: var(--accent);
  232. }
  233. .grid-head {
  234. display: flex;
  235. align-items: baseline;
  236. justify-content: space-between;
  237. gap: 12px;
  238. }
  239. .summary {
  240. font-size: 12px;
  241. color: var(--muted);
  242. }
  243. footer {
  244. flex: none;
  245. display: flex;
  246. flex-wrap: wrap;
  247. gap: 8px 24px;
  248. font-size: 12px;
  249. color: var(--muted);
  250. }
  251. /* Narrow window: drop the visible name label (the input keeps it). */
  252. @media (max-width: 1100px) {
  253. .name label {
  254. position: absolute;
  255. width: 1px;
  256. height: 1px;
  257. overflow: hidden;
  258. clip-path: inset(50%);
  259. }
  260. }
  261. /* Short window: give the grid the hint's lines. */
  262. @media (max-height: 600px) {
  263. .grid-hint {
  264. display: none;
  265. }
  266. }
  267. .error {
  268. flex: none;
  269. margin: 0;
  270. padding: 10px 12px;
  271. border: 1px solid #e5484d;
  272. border-radius: 6px;
  273. color: #ffb4b4;
  274. }
  275. </style>