Refine CRUD for artists

This commit is contained in:
2025-06-25 17:04:45 +02:00
parent 887b0ead93
commit d608267a62
11 changed files with 323 additions and 13 deletions

10
package-lock.json generated
View File

@ -23,6 +23,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.67"
@ -6154,6 +6155,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@ -24,6 +24,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.67"

View File

@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `icon` on the `Social` table. All the data in the column will be lost.
- You are about to drop the column `name` on the `Social` table. All the data in the column will be lost.
- You are about to drop the column `type` on the `Social` table. All the data in the column will be lost.
- You are about to drop the column `url` on the `Social` table. All the data in the column will be lost.
- Added the required column `isPrimary` to the `Social` table without a default value. This is not possible if the table is not empty.
- Added the required column `platform` to the `Social` table without a default value. This is not possible if the table is not empty.
- Made the column `handle` on table `Social` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Social" DROP COLUMN "icon",
DROP COLUMN "name",
DROP COLUMN "type",
DROP COLUMN "url",
ADD COLUMN "isPrimary" BOOLEAN NOT NULL,
ADD COLUMN "link" TEXT,
ADD COLUMN "platform" TEXT NOT NULL,
ALTER COLUMN "handle" SET NOT NULL;

View File

@ -70,11 +70,11 @@ model Social {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String?
handle String?
url String?
type String?
icon String?
platform String
handle String
isPrimary Boolean
link String?
artistId String?
artist Artist? @relation(fields: [artistId], references: [id])

View File

@ -9,7 +9,15 @@ export async function createArtist(values: z.infer<typeof artistSchema>) {
data: {
displayName: values.displayName,
slug: values.slug,
nickname: values.nickname
nickname: values.nickname,
socials: {
create: (values.socials || []).map((s) => ({
platform: s.platform,
handle: s.handle,
link: s.link,
isPrimary: s.isPrimary,
})),
},
}
})
}

View File

@ -8,6 +8,16 @@ export async function updateArtist(
values: z.infer<typeof artistSchema>,
id: string
) {
const existingSocials = await prisma.social.findMany({
where: { artistId: id },
});
const updatedSocialIds = (values.socials ?? []).filter(s => s.id).map(s => s.id);
const removedSocials = existingSocials.filter(
(s) => !updatedSocialIds.includes(s.id)
);
return await prisma.artist.update({
where: {
id: id
@ -15,7 +25,31 @@ export async function updateArtist(
data: {
displayName: values.displayName,
slug: values.slug,
nickname: values.nickname
nickname: values.nickname,
socials: {
deleteMany: {
id: { in: removedSocials.map(s => s.id) },
},
updateMany: (values.socials ?? [])
.filter((s) => s.id)
.map((s) => ({
where: { id: s.id },
data: {
platform: s.platform,
handle: s.handle,
link: s.link,
isPrimary: s.isPrimary,
},
})),
create: (values.socials ?? [])
.filter((s) => !s.id)
.map((s) => ({
platform: s.platform,
handle: s.handle,
link: s.link,
isPrimary: s.isPrimary,
})),
},
}
})
}

View File

@ -7,6 +7,9 @@ export default async function ArtistsEditPage({ params }: { params: { id: string
const artist = await prisma.artist.findUnique({
where: {
id,
},
include: {
socials: true
}
});

View File

@ -4,15 +4,16 @@ 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 { Artist, Social } from "@/generated/prisma";
import { artistSchema } from "@/schemas/artists/artistSchema";
import { getSocialIcon } from "@/utils/socialIconMap";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
export default function EditAlbumForm({ artist }: { artist: Artist }) {
export default function EditAlbumForm({ artist }: { artist: Artist & { socials: Social[] } }) {
const router = useRouter();
const form = useForm<z.infer<typeof artistSchema>>({
resolver: zodResolver(artistSchema),
@ -20,9 +21,23 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
displayName: artist.displayName,
slug: artist.slug,
nickname: artist.nickname || "",
socials: artist.socials.map((s) => ({
id: s.id,
platform: s.platform,
handle: s.handle,
link: s.link ?? "", // Convert null to empty string
isPrimary: s.isPrimary,
})),
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "socials",
});
const watchSocials = form.watch("socials");
async function onSubmit(values: z.infer<typeof artistSchema>) {
const updatedArtist = await updateArtist(values, artist.id)
if (updatedArtist) {
@ -83,6 +98,95 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
</FormItem>
)}
/>
{/* Socials section */}
<div className="space-y-4">
<FormLabel>Social Links</FormLabel>
{fields.map((field, index) => (
<div key={field.id} className="flex flex-col gap-2 border rounded p-4">
<div className="flex items-center gap-2">
{getSocialIcon(watchSocials?.[index]?.platform ?? "")}
<FormField
control={form.control}
name={`socials.${index}.platform`}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Platform</FormLabel>
<FormControl>
<Input placeholder="e.g. telegram" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name={`socials.${index}.handle`}
render={({ field }) => (
<FormItem>
<FormLabel>Handle</FormLabel>
<FormControl>
<Input placeholder="@username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`socials.${index}.link`}
render={({ field }) => (
<FormItem>
<FormLabel>Link</FormLabel>
<FormControl>
<Input placeholder="https://example.com/username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`socials.${index}.isPrimary`}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<input
type="radio"
checked={field.value}
onChange={
() =>
form.setValue(
"socials",
(watchSocials ?? []).map((s, i) => ({
...s,
isPrimary: i === index,
}))
)
}
/>
<FormLabel className="m-0 text-sm">Set as primary</FormLabel>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
>
Remove Social
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false })}
>
+ Add Social
</Button>
</div>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>

View File

@ -5,9 +5,10 @@ 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 { getSocialIcon } from "@/utils/socialIconMap";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
@ -19,9 +20,17 @@ export default function CreateArtistForm() {
displayName: "",
slug: "",
nickname: "",
socials: [],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "socials",
});
const watchSocials = form.watch("socials");
async function onSubmit(values: z.infer<typeof artistSchema>) {
const artist = await createArtist(values)
if (artist) {
@ -81,6 +90,95 @@ export default function CreateArtistForm() {
</FormItem>
)}
/>
{/* Socials section */}
<div className="space-y-4">
<FormLabel>Social Links</FormLabel>
{fields.map((field, index) => (
<div key={field.id} className="flex flex-col gap-2 border rounded p-4">
<div className="flex items-center gap-2">
{getSocialIcon(watchSocials?.[index]?.platform ?? "")}
<FormField
control={form.control}
name={`socials.${index}.platform`}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Platform</FormLabel>
<FormControl>
<Input placeholder="e.g. telegram" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name={`socials.${index}.handle`}
render={({ field }) => (
<FormItem>
<FormLabel>Handle</FormLabel>
<FormControl>
<Input placeholder="@username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`socials.${index}.link`}
render={({ field }) => (
<FormItem>
<FormLabel>Link</FormLabel>
<FormControl>
<Input placeholder="https://example.com/username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`socials.${index}.isPrimary`}
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<input
type="radio"
checked={field.value}
onChange={
() =>
form.setValue(
"socials",
(watchSocials ?? []).map((s, i) => ({
...s,
isPrimary: i === index,
}))
)
}
/>
<FormLabel className="m-0 text-sm">Set as primary</FormLabel>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
>
Remove Social
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false })}
>
+ Add Social
</Button>
</div>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>

View File

@ -1,8 +1,25 @@
import * as z from "zod/v4";
export const socialSchema = z.object({
id: z.string().optional(),
platform: z.string().min(1, "Platform is required"),
handle: z.string().min(1, "Handle is required"),
isPrimary: z.boolean(),
link: z.string().optional(),
});
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(),
})
socials: z.array(socialSchema).optional(),
}).refine(
(data) => {
const socials = data.socials || [];
return socials.filter((s) => s.isPrimary).length <= 1;
},
{
message: "Only one social can be primary",
path: ["socials"],
}
);

View File

@ -0,0 +1,14 @@
import { FaGlobe, FaLink, FaMastodon, FaTelegram } from "react-icons/fa";
export const getSocialIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case "telegram":
return <FaTelegram className="text-sky-500" />;
case "linktree":
return <FaLink className="text-green-600" />;
case "fediverse":
return <FaMastodon className="text-indigo-500" />;
default:
return <FaGlobe className="text-muted-foreground" />;
}
};