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:
AJ Avezzano
2026-04-01 11:14:46 -04:00
commit 5b772655c6
31 changed files with 2762 additions and 0 deletions

42
components/SearchBar.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
interface SearchBarProps {
initialValue?: string;
}
export default function SearchBar({ initialValue = "" }: SearchBarProps) {
const router = useRouter();
const [value, setValue] = useState(initialValue);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!value.trim()) return;
startTransition(() => {
router.push(`/?q=${encodeURIComponent(value.trim())}`);
});
}
return (
<form onSubmit={handleSubmit} className="flex gap-3">
<input
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search by song title or artist…"
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
autoFocus
/>
<button
type="submit"
disabled={isPending || !value.trim()}
className="rounded-lg bg-green-600 px-6 py-3 font-semibold text-white hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Searching…" : "Search"}
</button>
</form>
);
}