Add basic CRUD for artists
This commit is contained in:
32
prisma/migrations/20250625102620_artist_social/migration.sql
Normal file
32
prisma/migrations/20250625102620_artist_social/migration.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Artist" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"nickname" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Artist_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Social" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"handle" TEXT,
|
||||||
|
"url" TEXT,
|
||||||
|
"type" TEXT,
|
||||||
|
"icon" TEXT,
|
||||||
|
"artistId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Social_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Artist_slug_key" ON "Artist"("slug");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Social" ADD CONSTRAINT "Social_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -47,3 +47,35 @@ model Album {
|
|||||||
|
|
||||||
// images Image[]
|
// images Image[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Artist {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
displayName String
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
|
nickname String?
|
||||||
|
// mentionUrl String?
|
||||||
|
// contact String?
|
||||||
|
// urls String[]
|
||||||
|
|
||||||
|
socials Social[]
|
||||||
|
// images Image[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Social {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
name String?
|
||||||
|
handle String?
|
||||||
|
url String?
|
||||||
|
type String?
|
||||||
|
icon String?
|
||||||
|
|
||||||
|
artistId String?
|
||||||
|
artist Artist? @relation(fields: [artistId], references: [id])
|
||||||
|
}
|
||||||
|
@ -3,5 +3,5 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
export async function deleteAlbum(id: string) {
|
export async function deleteAlbum(id: string) {
|
||||||
await prisma.gallery.delete({ where: { id } });
|
await prisma.album.delete({ where: { id } });
|
||||||
}
|
}
|
15
src/actions/artists/createArtist.ts
Normal file
15
src/actions/artists/createArtist.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export async function createArtist(values: z.infer<typeof artistSchema>) {
|
||||||
|
return await prisma.artist.create({
|
||||||
|
data: {
|
||||||
|
displayName: values.displayName,
|
||||||
|
slug: values.slug,
|
||||||
|
nickname: values.nickname
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
7
src/actions/artists/deleteArtist.ts
Normal file
7
src/actions/artists/deleteArtist.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function deleteArtist(id: string) {
|
||||||
|
await prisma.artist.delete({ where: { id } });
|
||||||
|
}
|
21
src/actions/artists/updateArtist.ts
Normal file
21
src/actions/artists/updateArtist.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export async function updateArtist(
|
||||||
|
values: z.infer<typeof artistSchema>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
return await prisma.artist.update({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
displayName: values.displayName,
|
||||||
|
slug: values.slug,
|
||||||
|
nickname: values.nickname
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
19
src/app/artists/edit/[id]/page.tsx
Normal file
19
src/app/artists/edit/[id]/page.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import EditArtistForm from "@/components/artists/edit/EditArtistForm";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function ArtistsEditPage({ params }: { params: { id: string } }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const artist = await prisma.artist.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Edit album</h1>
|
||||||
|
{artist ? <EditArtistForm artist={artist} /> : 'Artist not found...'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
10
src/app/artists/new/page.tsx
Normal file
10
src/app/artists/new/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import CreateArtistForm from "@/components/artists/new/CreateArtistForm";
|
||||||
|
|
||||||
|
export default function ArtistsNewPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">New artist</h1>
|
||||||
|
<CreateArtistForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
20
src/app/artists/page.tsx
Normal file
20
src/app/artists/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import ListArtists from "@/components/artists/list/ListArtists";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function ArtistsPage() {
|
||||||
|
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-4 justify-between">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Artists</h1>
|
||||||
|
<Link href="/artists/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||||
|
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Artist
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{artists.length > 0 ? <ListArtists artists={artists} /> : <p className="text-muted-foreground italic">No artists found.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -26,7 +26,7 @@ export default function EditAlbumForm({ album, galleries }: { album: Album, gall
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
||||||
var updatedAlbum = await updateAlbum(values, album.id)
|
const updatedAlbum = await updateAlbum(values, album.id)
|
||||||
if (updatedAlbum) {
|
if (updatedAlbum) {
|
||||||
toast.success("Album updated")
|
toast.success("Album updated")
|
||||||
router.push(`/albums`)
|
router.push(`/albums`)
|
||||||
|
@ -26,7 +26,7 @@ export default function CreateAlbumForm({ galleries }: { galleries: Gallery[] })
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
async function onSubmit(values: z.infer<typeof albumSchema>) {
|
||||||
var album = await createAlbum(values)
|
const album = await createAlbum(values)
|
||||||
if (album) {
|
if (album) {
|
||||||
toast.success("Album created")
|
toast.success("Album created")
|
||||||
router.push(`/albums`)
|
router.push(`/albums`)
|
||||||
|
94
src/components/artists/edit/EditArtistForm.tsx
Normal file
94
src/components/artists/edit/EditArtistForm.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { updateArtist } from "@/actions/artists/updateArtist";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Artist } from "@/generated/prisma";
|
||||||
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export default function EditAlbumForm({ artist }: { artist: Artist }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<z.infer<typeof artistSchema>>({
|
||||||
|
resolver: zodResolver(artistSchema),
|
||||||
|
defaultValues: {
|
||||||
|
displayName: artist.displayName,
|
||||||
|
slug: artist.slug,
|
||||||
|
nickname: artist.nickname || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
||||||
|
const updatedArtist = await updateArtist(values, artist.id)
|
||||||
|
if (updatedArtist) {
|
||||||
|
toast.success("Artist updated")
|
||||||
|
router.push(`/artists`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="displayName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your public display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist slug" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Will be used for the navigation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nickname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist nickname (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist nickname" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Nickname of the artist.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
23
src/components/artists/list/ListArtists.tsx
Normal file
23
src/components/artists/list/ListArtists.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Artist } from "@/generated/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ListArtists({ artists }: { artists: Artist[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{artists.map((artist) => (
|
||||||
|
<Link href={`/artists/edit/${artist.id}`} key={artist.id}>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base truncate">{artist.displayName}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
91
src/components/artists/new/CreateArtistForm.tsx
Normal file
91
src/components/artists/new/CreateArtistForm.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createArtist } from "@/actions/artists/createArtist";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { artistSchema } from "@/schemas/artists/artistSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export default function CreateArtistForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<z.infer<typeof artistSchema>>({
|
||||||
|
resolver: zodResolver(artistSchema),
|
||||||
|
defaultValues: {
|
||||||
|
displayName: "",
|
||||||
|
slug: "",
|
||||||
|
nickname: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof artistSchema>) {
|
||||||
|
const artist = await createArtist(values)
|
||||||
|
if (artist) {
|
||||||
|
toast.success("Artist created")
|
||||||
|
router.push(`/artists`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="displayName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your public display name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist slug" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Will be used for the navigation.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nickname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Artist nickname</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Artist nickname" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Nickname of the artist.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
@ -22,6 +22,11 @@ export default function TopNav() {
|
|||||||
<Link href="/albums">Albums</Link>
|
<Link href="/albums">Albums</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
|
<Link href="/artists">Artists</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
);
|
);
|
||||||
|
8
src/schemas/artists/artistSchema.ts
Normal file
8
src/schemas/artists/artistSchema.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const artistSchema = z.object({
|
||||||
|
displayName: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
Reference in New Issue
Block a user