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:
commit
b2da668bce
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