| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273 |
- <script lang="ts">
- import { onMount } from "svelte";
- import AboutModal from "./lib/AboutModal.svelte";
- import BankBar from "./lib/BankBar.svelte";
- import PadPanel from "./lib/PadPanel.svelte";
- import SequencePanel from "./lib/SequencePanel.svelte";
- import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
- import { app, newProject, refresh, ui, type Tab } from "./lib/store.svelte";
- import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
- onMount(refresh);
- const st = $derived(app.state);
- // Sequences by slot, for the grid and the panel.
- const seqBySlot = $derived(new Map((st?.sequences ?? []).map((q) => [q.slot, q])));
- function perBank(filled: (slot: number) => boolean): number[] {
- return Array.from({ length: BANKS.length }, (_, b) => {
- let n = 0;
- for (let i = 0; i < PER_BANK; i++) if (filled(b * PER_BANK + i)) n++;
- return n;
- });
- }
- const padCounts = $derived(perBank((s) => (st?.pads[s]?.file ?? "") !== ""));
- const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
- const padCells: Cell[] = $derived(
- st
- ? gridSlots(ui.padBank).map((slot) => {
- const p = st.pads[slot];
- const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : ""].filter(Boolean);
- return {
- slot,
- label: p.label,
- name: p.name,
- meta: p.file ? badges.join(" · ") : "",
- aria: `Pad ${p.label}, ${p.file || "empty"}`,
- };
- })
- : [],
- );
- const seqCells: Cell[] = $derived(
- st
- ? gridSlots(ui.seqBank).map((slot) => {
- const q = seqBySlot.get(slot);
- const label = st.pads[slot].label; // pad and sequence slots share labels
- return {
- slot,
- label,
- name: q?.name ?? "",
- meta: q ? `${q.bpm} · ${bars(q.bars)}` : "",
- aria: `Sequence slot ${label}, ${q?.name ?? "empty"}`,
- };
- })
- : [],
- );
- function pickTab(t: Tab) {
- ui.tab = t;
- }
- // Arrow keys move between the two tabs, as in the WAI-ARIA tabs pattern.
- function tabKey(e: KeyboardEvent) {
- if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
- ui.tab = ui.tab === "pads" ? "sequences" : "pads";
- document.getElementById("tab-" + ui.tab)?.focus();
- }
- }
- </script>
- <div class="page">
- <div class="titlebar">
- <h1>mpckit</h1>
- <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
- </div>
- <header>
- <div class="name">
- <label class="eyebrow" for="project-name">Project name</label>
- <input id="project-name" type="text" value={st?.name ?? ""} readonly />
- </div>
- <div class="actions">
- <button type="button" class="btn" onclick={newProject}>New</button>
- <button type="button" class="btn" disabled>Load</button>
- <button type="button" class="btn" disabled>Export zip</button>
- <button type="button" class="btn primary" disabled>Export to folder</button>
- </div>
- </header>
- {#if app.error}
- <p class="error" role="alert">{app.error}</p>
- {/if}
- <div class="tabs" role="tablist" aria-label="Views">
- <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>
- <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>
- </div>
- {#if st}
- {#if ui.tab === "pads"}
- <main id="view-pads" class="panels" role="tabpanel" aria-labelledby="tab-pads">
- <section class="panel grid-panel" aria-label="Pads">
- <div class="grid-head">
- <h2 class="eyebrow">Pad bank</h2>
- <span class="mono summary">Bank {BANKS[ui.padBank]} · {padCounts[ui.padBank]} of 16 filled</span>
- </div>
- <BankBar
- bank={ui.padBank}
- counts={padCounts}
- what="pads filled"
- onpick={(b) => {
- ui.padBank = b;
- ui.padSlot = b * PER_BANK;
- }}
- />
- <SlotGrid cells={padCells} selected={ui.padSlot} onpick={(s) => (ui.padSlot = s)} />
- <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>
- </section>
- <PadPanel pad={st.pads[ui.padSlot]} limits={st.limits} />
- </main>
- {:else}
- <main id="view-sequences" class="panels" role="tabpanel" aria-labelledby="tab-sequences">
- <section class="panel grid-panel" aria-label="Sequence slots">
- <div class="grid-head">
- <h2 class="eyebrow">Sequence bank</h2>
- <span class="mono summary">Bank {BANKS[ui.seqBank]} · {seqCounts[ui.seqBank]} of 16 used</span>
- </div>
- <BankBar
- bank={ui.seqBank}
- counts={seqCounts}
- what="slots used"
- onpick={(b) => {
- ui.seqBank = b;
- ui.seqSlot = b * PER_BANK;
- }}
- />
- <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
- <p class="hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
- </section>
- <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
- </main>
- {/if}
- <footer class="mono">
- <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
- <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
- <span>WAV files only</span>
- </footer>
- {:else if !app.error}
- <p class="hint">Loading…</p>
- {/if}
- {#if ui.aboutOpen}
- <AboutModal onclose={() => (ui.aboutOpen = false)} />
- {/if}
- </div>
- <style>
- .page {
- max-width: 1280px;
- margin: 0 auto;
- padding: 24px;
- display: flex;
- flex-direction: column;
- gap: 20px;
- }
- .titlebar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--line);
- }
- h1 {
- margin: 0;
- font-family: var(--display);
- font-weight: 700;
- font-size: 24px;
- letter-spacing: 0.02em;
- }
- .about-link {
- min-height: 44px;
- padding: 0 8px;
- background: transparent;
- color: var(--accent);
- border: 0;
- font: inherit;
- font-weight: 500;
- text-decoration: underline;
- text-underline-offset: 3px;
- cursor: pointer;
- }
- header {
- display: flex;
- flex-wrap: wrap;
- align-items: flex-end;
- gap: 16px;
- }
- .name {
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex: 1 1 240px;
- min-width: 0;
- max-width: 360px;
- }
- .name input {
- height: 44px;
- padding: 0 12px;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--edge);
- border-radius: 6px;
- font: inherit;
- font-weight: 600;
- width: 100%;
- }
- .actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-left: auto;
- }
- .tabs {
- display: flex;
- gap: 4px;
- border-bottom: 1px solid var(--line);
- }
- .tabs button {
- height: 48px;
- padding: 0 22px;
- background: transparent;
- color: var(--muted);
- border: 0;
- border-bottom: 3px solid transparent;
- font-family: var(--display);
- font-size: 16px;
- font-weight: 700;
- cursor: pointer;
- }
- .tabs button[aria-selected="true"] {
- color: var(--text);
- border-bottom-color: var(--accent);
- }
- .grid-head {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
- }
- .summary {
- font-size: 12px;
- color: var(--muted);
- }
- footer {
- display: flex;
- flex-wrap: wrap;
- gap: 8px 24px;
- font-size: 12px;
- color: var(--muted);
- }
- .error {
- margin: 0;
- padding: 10px 12px;
- border: 1px solid #e5484d;
- border-radius: 6px;
- color: #ffb4b4;
- }
- </style>
|