feat: initial scaffold for ClickTrack monorepo
Full self-hosted click track generator for cover bands. Core technical pieces implemented: - CTP (Click Track Protocol) TypeScript schema, Zod validator, and WAV renderer (44.1 kHz, 16-bit PCM, accented downbeats, ramp sections) - MusicBrainz API client with 1 req/s rate limiting - PostgreSQL schema (songs, tempo_maps, registry_sync_log) with triggers - Git registry sync logic (clone/pull → validate CTP → upsert DB) - Next.js 14 App Router: search page, track page, API routes (/api/songs, /api/tracks, /api/generate) - UI components: SearchBar, SongResult, TempoMapEditor, ClickTrackPlayer (Web Audio API in-browser playback + WAV download) - Docker Compose stack: app + postgres + redis + nginx + registry-sync - Multi-stage Dockerfile with standalone Next.js output - .env.example documenting all configuration variables - README with setup instructions, CTP format spec, and API reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
130
components/TempoMapEditor.tsx
Normal file
130
components/TempoMapEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TempoMapEditor
|
||||
*
|
||||
* Renders a read-only or editable view of a CTP document's sections.
|
||||
* The full interactive editor (drag-to-resize bars, BPM knob, etc.)
|
||||
* is a future milestone — this version is a structured table view
|
||||
* with inline editing stubs.
|
||||
*/
|
||||
|
||||
import type { CTPDocument, CTPSection } from "@/lib/ctp/schema";
|
||||
import { isRampSection, sectionStartBpm } from "@/lib/ctp/schema";
|
||||
|
||||
interface TempoMapEditorProps {
|
||||
ctpDoc: CTPDocument;
|
||||
/** When true, all inputs are disabled. */
|
||||
readOnly?: boolean;
|
||||
onChange?: (doc: CTPDocument) => void;
|
||||
}
|
||||
|
||||
function timeSigLabel(ts: CTPSection["time_signature"]): string {
|
||||
return `${ts.numerator}/${ts.denominator}`;
|
||||
}
|
||||
|
||||
function bpmLabel(section: CTPSection): string {
|
||||
if (isRampSection(section)) {
|
||||
return `${section.bpm_start} → ${section.bpm_end}`;
|
||||
}
|
||||
return String(section.bpm);
|
||||
}
|
||||
|
||||
export default function TempoMapEditor({
|
||||
ctpDoc,
|
||||
readOnly = false,
|
||||
}: TempoMapEditorProps) {
|
||||
const { metadata, count_in, sections } = ctpDoc;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Metadata strip */}
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-zinc-500">
|
||||
<span>
|
||||
Contributed by{" "}
|
||||
<span className="text-zinc-300 font-medium">{metadata.contributed_by}</span>
|
||||
</span>
|
||||
{metadata.verified && (
|
||||
<span className="text-green-400 font-medium">✓ Verified</span>
|
||||
)}
|
||||
<span>CTP v{ctpDoc.version}</span>
|
||||
{metadata.mbid && (
|
||||
<a
|
||||
href={`https://musicbrainz.org/recording/${metadata.mbid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
MusicBrainz
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Count-in */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-zinc-500">Count-in:</span>
|
||||
<span className="text-zinc-300">
|
||||
{count_in.enabled ? `${count_in.bars} bar${count_in.bars !== 1 ? "s" : ""}` : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs uppercase tracking-widest text-zinc-600">
|
||||
<th className="px-4 py-3">Label</th>
|
||||
<th className="px-4 py-3">Start Bar</th>
|
||||
<th className="px-4 py-3">BPM</th>
|
||||
<th className="px-4 py-3">Time Sig</th>
|
||||
<th className="px-4 py-3">Transition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-zinc-800/60 last:border-0 hover:bg-zinc-800/30"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-zinc-200">
|
||||
{section.label}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{section.start_bar}</td>
|
||||
<td className="px-4 py-3 font-mono text-green-400">
|
||||
{bpmLabel(section)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">
|
||||
{timeSigLabel(section.time_signature)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
section.transition === "ramp"
|
||||
? "bg-amber-900/40 text-amber-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{section.transition}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<p className="text-xs text-zinc-600">
|
||||
Interactive editor coming soon — contribute via the{" "}
|
||||
<a
|
||||
href="https://github.com/your-org/clicktrack-registry"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
community registry
|
||||
</a>{" "}
|
||||
in the meantime.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user