Merge pull request #3 from BusyBee3333/codex/create-next.js-14-frontend-with-app-router

Add Next.js frontend scaffold
This commit is contained in:
BusyBee3333 2025-11-11 01:28:15 -05:00 committed by GitHub
commit b2da668bce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 537 additions and 0 deletions

12
frontend/app/globals.css Normal file
View File

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
min-height: 100vh;
background-color: #f8fafc;
}

44
frontend/app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body className="bg-slate-50 text-slate-900">
<Providers>
<div className="min-h-screen flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-6 py-4">
<Link href="/" className="text-lg font-semibold">
Beatmatchr
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/" className="hover:text-slate-600">
Home
</Link>
<Link href="/projects" className="hover:text-slate-600">
Projects
</Link>
</nav>
</div>
</header>
<main className="flex-1">
<div className="mx-auto w-full max-w-5xl px-6 py-8">{children}</div>
</main>
</div>
</Providers>
</body>
</html>
);
}

18
frontend/app/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link';
export default function HomePage() {
return (
<div className="flex flex-col items-center justify-center gap-6 py-24 text-center">
<h1 className="text-4xl font-bold text-slate-900">Welcome to Beatmatchr</h1>
<p className="max-w-xl text-lg text-slate-600">
Manage your audio projects, organize source clips, and fine-tune lyrics in a clean, focused editor.
</p>
<Link
href="/projects"
className="rounded-md bg-indigo-600 px-6 py-3 text-white shadow-sm transition hover:bg-indigo-500"
>
Create Project
</Link>
</div>
);
}

View File

@ -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<Project, Error>({
queryKey: ['projects', projectId],
queryFn: () => getProject(projectId as string),
enabled: Boolean(projectId)
});
if (!projectId) {
return <p className="text-sm text-red-600">Project ID is missing.</p>;
}
return (
<div className="space-y-8">
{isLoading && <p className="text-slate-500">Loading project...</p>}
{isError && <p className="text-red-600">{error.message}</p>}
{project && (
<>
<div className="space-y-2">
<h1 className="text-3xl font-semibold text-slate-900">{project.name}</h1>
{project.description && <p className="text-slate-600">{project.description}</p>}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<AudioUpload
projectId={projectId}
onSuccess={() =>
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'source-clips'] })
}
/>
<LyricsEditor projectId={projectId} />
</div>
<section className="space-y-4">
<h2 className="text-xl font-semibold text-slate-900">Source Clips</h2>
<ClipGrid projectId={projectId} />
</section>
</>
)}
</div>
);
}

View File

@ -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<Project[], Error>({
queryKey: ['projects'],
queryFn: getProjects
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Projects</h1>
<p className="text-sm text-slate-600">Browse and open your existing audio projects.</p>
</div>
<Link
href="/"
className="rounded-md border border-indigo-200 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50"
>
New Project
</Link>
</div>
{isLoading && <p className="text-slate-500">Loading projects...</p>}
{isError && <p className="text-red-600">{error.message}</p>}
{projects && projects.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">
No projects yet. Create one from the backend or CLI.
</div>
)}
{projects && projects.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2">
{projects.map((project) => (
<li key={project.id} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h2 className="text-lg font-medium text-slate-900">{project.name}</h2>
{project.description && <p className="mt-1 text-sm text-slate-600">{project.description}</p>}
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
{project.created_at && <span>Created {new Date(project.created_at).toLocaleDateString()}</span>}
<Link href={`/projects/${project.id}`} className="text-indigo-600 hover:text-indigo-500">
Open
</Link>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -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<File | null>(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<HTMLFormElement>) => {
event.preventDefault();
mutation.mutate();
};
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div>
<h2 className="text-lg font-semibold text-slate-900">Upload Audio</h2>
<p className="text-sm text-slate-600">Attach a new audio track to this project.</p>
</div>
<input
type="file"
accept="audio/*"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={mutation.isPending}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{mutation.isPending ? 'Uploading...' : 'Upload'}
</button>
{mutation.isError && (
<p className="text-sm text-red-600">{(mutation.error as Error).message}</p>
)}
{mutation.isSuccess && <p className="text-sm text-green-600">Audio uploaded successfully!</p>}
</form>
);
}

View File

@ -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<SourceClip[], Error>({
queryKey: ['projects', projectId, 'source-clips'],
queryFn: () => getSourceClips(projectId)
});
if (isLoading) {
return <p className="text-sm text-slate-500">Loading source clips...</p>;
}
if (isError) {
return <p className="text-sm text-red-600">{error.message}</p>;
}
if (!clips || clips.length === 0) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">
No source clips found yet.
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{clips.map((clip) => (
<div key={clip.id} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h3 className="text-base font-medium text-slate-900">{clip.name}</h3>
{clip.duration != null && (
<p className="text-sm text-slate-500">Duration: {clip.duration.toFixed(1)}s</p>
)}
{clip.waveform_url && (
<p className="truncate text-sm text-indigo-600">Waveform: {clip.waveform_url}</p>
)}
</div>
))}
</div>
);
}

View File

@ -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<Lyrics, Error>({
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 (
<div className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div>
<h2 className="text-lg font-semibold text-slate-900">Lyrics</h2>
<p className="text-sm text-slate-600">Review and edit the current lyrics.</p>
</div>
{isLoading && <p className="text-sm text-slate-500">Loading lyrics...</p>}
{isError && <p className="text-sm text-red-600">{error.message}</p>}
{!isLoading && !isError && (
<>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={8}
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{mutation.isPending ? 'Saving...' : 'Save Lyrics'}
</button>
{mutation.isError && (
<p className="text-sm text-red-600">{(mutation.error as Error).message}</p>
)}
{mutation.isSuccess && <p className="text-sm text-green-600">Lyrics saved.</p>}
</>
)}
</div>
);
}

View File

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

78
frontend/lib/api.ts Normal file
View File

@ -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<T>(response: Response): Promise<T> {
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<Project[]> {
const response = await fetch(`${API_BASE_URL}/projects`, { cache: 'no-store' });
return handleResponse<Project[]>(response);
}
export async function getProject(projectId: string): Promise<Project> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, { cache: 'no-store' });
return handleResponse<Project>(response);
}
export async function uploadProjectAudio(projectId: string, file: File): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/audio`, {
method: 'POST',
body: formData
});
await handleResponse<void>(response);
}
export async function getSourceClips(projectId: string): Promise<SourceClip[]> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/source-clips`, {
cache: 'no-store'
});
return handleResponse<SourceClip[]>(response);
}
export async function getLyrics(projectId: string): Promise<Lyrics> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
cache: 'no-store'
});
return handleResponse<Lyrics>(response);
}
export async function updateLyrics(projectId: string, lyrics: Lyrics): Promise<Lyrics> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/lyrics`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(lyrics)
});
return handleResponse<Lyrics>(response);
}

5
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

8
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
typedRoutes: true
}
};
export default nextConfig;

28
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -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;

21
frontend/tsconfig.json Normal file
View File

@ -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"]
}