diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..8295d3d
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,12 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ color-scheme: light;
+}
+
+body {
+ min-height: 100vh;
+ background-color: #f8fafc;
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..e6d1bfa
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,44 @@
+import './globals.css';
+import type { Metadata } from 'next';
+import { Providers } from '../components/providers';
+import Link from 'next/link';
+
+export const metadata: Metadata = {
+ title: 'Beatmatchr',
+ description: 'Frontend for Beatmatchr project editor'
+};
+
+export default function RootLayout({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..bf7dc81
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link';
+
+export default function HomePage() {
+ return (
+
+
Welcome to Beatmatchr
+
+ Manage your audio projects, organize source clips, and fine-tune lyrics in a clean, focused editor.
+
+
+ Create Project
+
+
+ );
+}
diff --git a/frontend/app/projects/[id]/page.tsx b/frontend/app/projects/[id]/page.tsx
new file mode 100644
index 0000000..82a4f0d
--- /dev/null
+++ b/frontend/app/projects/[id]/page.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useParams } from 'next/navigation';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { getProject, Project } from '../../../lib/api';
+import { AudioUpload } from '../../../components/AudioUpload';
+import { ClipGrid } from '../../../components/ClipGrid';
+import { LyricsEditor } from '../../../components/LyricsEditor';
+
+export default function ProjectDetailPage() {
+ const params = useParams<{ id: string }>();
+ const projectId = params?.id;
+ const queryClient = useQueryClient();
+
+ const {
+ data: project,
+ isLoading,
+ isError,
+ error
+ } = useQuery({
+ queryKey: ['projects', projectId],
+ queryFn: () => getProject(projectId as string),
+ enabled: Boolean(projectId)
+ });
+
+ if (!projectId) {
+ return Project ID is missing.
;
+ }
+
+ return (
+
+ {isLoading &&
Loading project...
}
+ {isError &&
{error.message}
}
+ {project && (
+ <>
+
+
{project.name}
+ {project.description &&
{project.description}
}
+
+
+
+ queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'source-clips'] })
+ }
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/app/projects/page.tsx b/frontend/app/projects/page.tsx
new file mode 100644
index 0000000..d8dbafb
--- /dev/null
+++ b/frontend/app/projects/page.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import Link from 'next/link';
+import { useQuery } from '@tanstack/react-query';
+import { getProjects, Project } from '../../lib/api';
+
+export default function ProjectsPage() {
+ const {
+ data: projects,
+ isLoading,
+ isError,
+ error
+ } = useQuery({
+ queryKey: ['projects'],
+ queryFn: getProjects
+ });
+
+ return (
+
+
+
+
Projects
+
Browse and open your existing audio projects.
+
+
+ New Project
+
+
+
+ {isLoading &&
Loading projects...
}
+ {isError &&
{error.message}
}
+
+ {projects && projects.length === 0 && (
+
+ No projects yet. Create one from the backend or CLI.
+
+ )}
+
+ {projects && projects.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/frontend/components/AudioUpload.tsx b/frontend/components/AudioUpload.tsx
new file mode 100644
index 0000000..4ed75d4
--- /dev/null
+++ b/frontend/components/AudioUpload.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { useMutation } from '@tanstack/react-query';
+import { FormEvent, useState } from 'react';
+import { uploadProjectAudio } from '../lib/api';
+
+interface AudioUploadProps {
+ projectId: string;
+ onSuccess?: () => void;
+}
+
+export function AudioUpload({ projectId, onSuccess }: AudioUploadProps) {
+ const [file, setFile] = useState(null);
+
+ const mutation = useMutation({
+ mutationFn: async () => {
+ if (!file) {
+ throw new Error('Please select an audio file to upload.');
+ }
+ await uploadProjectAudio(projectId, file);
+ },
+ onSuccess
+ });
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ mutation.mutate();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ClipGrid.tsx b/frontend/components/ClipGrid.tsx
new file mode 100644
index 0000000..cbc6aab
--- /dev/null
+++ b/frontend/components/ClipGrid.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { getSourceClips, SourceClip } from '../lib/api';
+
+interface ClipGridProps {
+ projectId: string;
+}
+
+export function ClipGrid({ projectId }: ClipGridProps) {
+ const {
+ data: clips,
+ isLoading,
+ isError,
+ error
+ } = useQuery({
+ queryKey: ['projects', projectId, 'source-clips'],
+ queryFn: () => getSourceClips(projectId)
+ });
+
+ if (isLoading) {
+ return Loading source clips...
;
+ }
+
+ if (isError) {
+ return {error.message}
;
+ }
+
+ if (!clips || clips.length === 0) {
+ return (
+
+ No source clips found yet.
+
+ );
+ }
+
+ return (
+
+ {clips.map((clip) => (
+
+
{clip.name}
+ {clip.duration != null && (
+
Duration: {clip.duration.toFixed(1)}s
+ )}
+ {clip.waveform_url && (
+
Waveform: {clip.waveform_url}
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/components/LyricsEditor.tsx b/frontend/components/LyricsEditor.tsx
new file mode 100644
index 0000000..0ca981a
--- /dev/null
+++ b/frontend/components/LyricsEditor.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { getLyrics, Lyrics, updateLyrics } from '../lib/api';
+import { useEffect, useState } from 'react';
+
+interface LyricsEditorProps {
+ projectId: string;
+}
+
+export function LyricsEditor({ projectId }: LyricsEditorProps) {
+ const queryClient = useQueryClient();
+ const { data, isLoading, isError, error } = useQuery({
+ queryKey: ['projects', projectId, 'lyrics'],
+ queryFn: () => getLyrics(projectId)
+ });
+
+ const [text, setText] = useState('');
+
+ useEffect(() => {
+ if (data) {
+ setText(data.raw_text ?? '');
+ }
+ }, [data]);
+
+ const mutation = useMutation({
+ mutationFn: () => updateLyrics(projectId, { raw_text: text }),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['projects', projectId, 'lyrics'], updated);
+ }
+ });
+
+ return (
+
+
+
Lyrics
+
Review and edit the current lyrics.
+
+ {isLoading &&
Loading lyrics...
}
+ {isError &&
{error.message}
}
+ {!isLoading && !isError && (
+ <>
+
+ );
+}
diff --git a/frontend/components/providers.tsx b/frontend/components/providers.tsx
new file mode 100644
index 0000000..82d4879
--- /dev/null
+++ b/frontend/components/providers.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactNode, useState } from 'react';
+
+interface ProvidersProps {
+ children: ReactNode;
+}
+
+export function Providers({ children }: ProvidersProps) {
+ const [queryClient] = useState(() => new QueryClient());
+
+ return {children};
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..d367180
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,78 @@
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8000/api';
+
+export interface Project {
+ id: string;
+ name: string;
+ description?: string | null;
+ created_at?: string;
+}
+
+export interface SourceClip {
+ id: string;
+ name: string;
+ duration?: number;
+ waveform_url?: string;
+}
+
+export interface Lyrics {
+ raw_text: string;
+ updated_at?: string;
+}
+
+async function handleResponse(response: Response): Promise {
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(errorText || response.statusText);
+ }
+ if (response.status === 204) {
+ return undefined as T;
+ }
+ return (await response.json()) as T;
+}
+
+export async function getProjects(): Promise {
+ const response = await fetch(`${API_BASE_URL}/projects`, { cache: 'no-store' });
+ return handleResponse(response);
+}
+
+export async function getProject(projectId: string): Promise {
+ const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, { cache: 'no-store' });
+ return handleResponse(response);
+}
+
+export async function uploadProjectAudio(projectId: string, file: File): Promise {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch(`${API_BASE_URL}/projects/${projectId}/audio`, {
+ method: 'POST',
+ body: formData
+ });
+
+ await handleResponse(response);
+}
+
+export async function getSourceClips(projectId: string): Promise {
+ const response = await fetch(`${API_BASE_URL}/projects/${projectId}/source-clips`, {
+ cache: 'no-store'
+ });
+ return handleResponse(response);
+}
+
+export async function getLyrics(projectId: string): Promise {
+ const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
+ cache: 'no-store'
+ });
+ return handleResponse(response);
+}
+
+export async function updateLyrics(projectId: string, lyrics: Lyrics): Promise {
+ const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(lyrics)
+ });
+ return handleResponse(response);
+}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
new file mode 100644
index 0000000..b44fec7
--- /dev/null
+++ b/frontend/next.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: {
+ typedRoutes: true
+ }
+};
+
+export default nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..201fad7
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "beatmatchr-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.25.4",
+ "next": "14.1.0",
+ "react": "18.2.0",
+ "react-dom": "18.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "20.11.30",
+ "@types/react": "18.2.64",
+ "@types/react-dom": "18.2.19",
+ "autoprefixer": "10.4.17",
+ "eslint": "8.57.0",
+ "eslint-config-next": "14.1.0",
+ "postcss": "8.4.35",
+ "tailwindcss": "3.4.1",
+ "typescript": "5.3.3"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..ba80730
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..f4e4860
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,14 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './app/**/*.{js,ts,jsx,tsx}',
+ './components/**/*.{js,ts,jsx,tsx}'
+ ],
+ theme: {
+ extend: {}
+ },
+ plugins: []
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..ff5bb9f
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "types": ["node"]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}