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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^3.25.67" "zod": "^3.25.67"
@ -6154,6 +6155,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@ -24,6 +24,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^3.25.67" "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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
name String? platform String
handle String? handle String
url String? isPrimary Boolean
type String?
icon String? link String?
artistId String? artistId String?
artist Artist? @relation(fields: [artistId], references: [id]) artist Artist? @relation(fields: [artistId], references: [id])

View File

@ -9,7 +9,15 @@ export async function createArtist(values: z.infer<typeof artistSchema>) {
data: { data: {
displayName: values.displayName, displayName: values.displayName,
slug: values.slug, 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>, values: z.infer<typeof artistSchema>,
id: string 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({ return await prisma.artist.update({
where: { where: {
id: id id: id
@ -15,7 +25,31 @@ export async function updateArtist(
data: { data: {
displayName: values.displayName, displayName: values.displayName,
slug: values.slug, 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({ const artist = await prisma.artist.findUnique({
where: { where: {
id, id,
},
include: {
socials: true
} }
}); });

View File

@ -4,15 +4,16 @@ import { updateArtist } from "@/actions/artists/updateArtist";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Artist } from "@/generated/prisma"; import { Artist, Social } from "@/generated/prisma";
import { artistSchema } from "@/schemas/artists/artistSchema"; import { artistSchema } from "@/schemas/artists/artistSchema";
import { getSocialIcon } from "@/utils/socialIconMap";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; 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 router = useRouter();
const form = useForm<z.infer<typeof artistSchema>>({ const form = useForm<z.infer<typeof artistSchema>>({
resolver: zodResolver(artistSchema), resolver: zodResolver(artistSchema),
@ -20,9 +21,23 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
displayName: artist.displayName, displayName: artist.displayName,
slug: artist.slug, slug: artist.slug,
nickname: artist.nickname || "", 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>) { async function onSubmit(values: z.infer<typeof artistSchema>) {
const updatedArtist = await updateArtist(values, artist.id) const updatedArtist = await updateArtist(values, artist.id)
if (updatedArtist) { if (updatedArtist) {
@ -83,6 +98,95 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) {
</FormItem> </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"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { artistSchema } from "@/schemas/artists/artistSchema"; import { artistSchema } from "@/schemas/artists/artistSchema";
import { getSocialIcon } from "@/utils/socialIconMap";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import * as z from "zod/v4";
@ -19,9 +20,17 @@ export default function CreateArtistForm() {
displayName: "", displayName: "",
slug: "", slug: "",
nickname: "", 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>) { async function onSubmit(values: z.infer<typeof artistSchema>) {
const artist = await createArtist(values) const artist = await createArtist(values)
if (artist) { if (artist) {
@ -81,6 +90,95 @@ export default function CreateArtistForm() {
</FormItem> </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"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>

View File

@ -1,8 +1,25 @@
import * as z from "zod/v4"; 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({ export const artistSchema = z.object({
displayName: z.string().min(3, "Name is required. Min 3 characters."), 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)"), 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(), 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" />;
}
};