App.svelte 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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. // Sequences by slot, for the grid and the panel.
  13. const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
  14. function perBank(filled: (slot: number) => boolean): number[] {
  15. return Array.from({ length: BANKS.length }, (_, b) => {
  16. let n = 0;
  17. for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
  18. return n;
  19. });
  20. }
  21. const padCounts = $derived(perBank((s) => (st?.pads[s]?.file ?? "") !== ""));
  22. const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
  23. const padCells: Cell[] = $derived(
  24. st
  25. ? gridSlots(ui.padBank).map((slot) => {
  26. const p = st.pads[slot];
  27. const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : ""].filter(Boolean);
  28. return {
  29. slot,
  30. label: p.label,
  31. name: p.name,
  32. meta: p.file ? badges.join(" · ") : "",
  33. aria: `Pad ${p.label}, ${p.file || "empty"}`,
  34. };
  35. })
  36. : [],
  37. );
  38. const seqCells: Cell[] = $derived(
  39. st
  40. ? gridSlots(ui.seqBank).map((slot) => {
  41. const q = seqBySlot.get(slot);
  42. const label = st.pads[slot].label; // pad and sequence slots share labels
  43. return {
  44. slot,
  45. label,
  46. name: q?.name ?? "",
  47. meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
  48. aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
  49. };
  50. })
  51. : [],
  52. );
  53. function pickTab(t: Tab) {
  54. ui.tab = t;
  55. }
  56. // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
  57. function tabKey(e: KeyboardEvent) {
  58. if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
  59. ui.tab = ui.tab === "pads" ? "sequences" : "pads";
  60. document.getElementById("tab-" + ui.tab)?.focus();
  61. }
  62. }
  63. </script>
  64. <div class="page">
  65. <div class="titlebar">
  66. <h1>mpckit</h1>
  67. <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
  68. </div>
  69. <header>
  70. <div class="name">
  71. <label class="eyebrow" for="project-name">Project name</label>
  72. <input id="project-name" type="text" value={st?.name ?? ""} readonly />
  73. </div>
  74. <div class="actions">
  75. <button type="button" class="btn" onclick={newProject}>New</button>
  76. <button type="button" class="btn" disabled>Load</button>
  77. <button type="button" class="btn" disabled>Export zip</button>
  78. <button type="button" class="btn primary" disabled>Export to folder</button>
  79. </div>
  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. <main 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">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={st.pads[ui.padSlot]} limits={st.limits} />
  109. </main>
  110. {:else}
  111. <main 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">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. </main>
  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. .page {
  146. max-width: 1280px;
  147. margin: 0 auto;
  148. padding: 24px;
  149. display: flex;
  150. flex-direction: column;
  151. gap: 20px;
  152. }
  153. .titlebar {
  154. display: flex;
  155. align-items: center;
  156. justify-content: space-between;
  157. gap: 16px;
  158. padding-bottom: 12px;
  159. border-bottom: 1px solid var(--line);
  160. }
  161. h1 {
  162. margin: 0;
  163. font-family: var(--display);
  164. font-weight: 700;
  165. font-size: 24px;
  166. letter-spacing: 0.02em;
  167. }
  168. .about-link {
  169. min-height: 44px;
  170. padding: 0 8px;
  171. background: transparent;
  172. color: var(--accent);
  173. border: 0;
  174. font: inherit;
  175. font-weight: 500;
  176. text-decoration: underline;
  177. text-underline-offset: 3px;
  178. cursor: pointer;
  179. }
  180. header {
  181. display: flex;
  182. flex-wrap: wrap;
  183. align-items: flex-end;
  184. gap: 16px;
  185. }
  186. .name {
  187. display: flex;
  188. flex-direction: column;
  189. gap: 6px;
  190. flex: 1 1 240px;
  191. min-width: 0;
  192. max-width: 360px;
  193. }
  194. .name input {
  195. height: 44px;
  196. padding: 0 12px;
  197. background: var(--surface);
  198. color: var(--text);
  199. border: 1px solid var(--edge);
  200. border-radius: 6px;
  201. font: inherit;
  202. font-weight: 600;
  203. width: 100%;
  204. }
  205. .actions {
  206. display: flex;
  207. flex-wrap: wrap;
  208. gap: 8px;
  209. margin-left: auto;
  210. }
  211. .tabs {
  212. display: flex;
  213. gap: 4px;
  214. border-bottom: 1px solid var(--line);
  215. }
  216. .tabs button {
  217. height: 48px;
  218. padding: 0 22px;
  219. background: transparent;
  220. color: var(--muted);
  221. border: 0;
  222. border-bottom: 3px solid transparent;
  223. font-family: var(--display);
  224. font-size: 16px;
  225. font-weight: 700;
  226. cursor: pointer;
  227. }
  228. .tabs button[aria-selected="true"] {
  229. color: var(--text);
  230. border-bottom-color: var(--accent);
  231. }
  232. .grid-head {
  233. display: flex;
  234. align-items: baseline;
  235. justify-content: space-between;
  236. gap: 12px;
  237. }
  238. .summary {
  239. font-size: 12px;
  240. color: var(--muted);
  241. }
  242. footer {
  243. display: flex;
  244. flex-wrap: wrap;
  245. gap: 8px 24px;
  246. font-size: 12px;
  247. color: var(--muted);
  248. }
  249. .error {
  250. margin: 0;
  251. padding: 10px 12px;
  252. border: 1px solid #e5484d;
  253. border-radius: 6px;
  254. color: #ffb4b4;
  255. }
  256. </style>