Add Next.js frontend scaffold
This commit is contained in:
parent
904e0ff20f
commit
607d0b112e
12
frontend/app/globals.css
Normal file
12
frontend/app/globals.css
Normal 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
44
frontend/app/layout.tsx
Normal 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
18
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/app/projects/[id]/page.tsx
Normal file
57
frontend/app/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/app/projects/page.tsx
Normal file
60
frontend/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/components/AudioUpload.tsx
Normal file
55
frontend/components/AudioUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/components/ClipGrid.tsx
Normal file
52
frontend/components/ClipGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/components/LyricsEditor.tsx
Normal file
65
frontend/components/LyricsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
frontend/components/providers.tsx
Normal file
14
frontend/components/providers.tsx
Normal 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
78
frontend/lib/api.ts
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
8
frontend/next.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
typedRoutes: true
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.mjs
Normal file
6
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
14
frontend/tailwind.config.ts
Normal file
14
frontend/tailwind.config.ts
Normal 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
21
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user