Refine CRUD for artists
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
21
prisma/migrations/20250625130123_artist_social/migration.sql
Normal file
21
prisma/migrations/20250625130123_artist_social/migration.sql
Normal 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;
|
@ -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])
|
||||
|
@ -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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
@ -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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
@ -7,6 +7,9 @@ export default async function ArtistsEditPage({ params }: { params: { id: string
|
||||
const artist = await prisma.artist.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
socials: true
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"],
|
||||
}
|
||||
);
|
||||
|
14
src/utils/socialIconMap.tsx
Normal file
14
src/utils/socialIconMap.tsx
Normal 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" />;
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user