diff --git a/package-lock.json b/package-lock.json index 40a78f3..5c13c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index feb697f..51f2f90 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/migrations/20250625130123_artist_social/migration.sql b/prisma/migrations/20250625130123_artist_social/migration.sql new file mode 100644 index 0000000..3cd335a --- /dev/null +++ b/prisma/migrations/20250625130123_artist_social/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14bce66..7201db1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/actions/artists/createArtist.ts b/src/actions/artists/createArtist.ts index f6e4d03..06cdafd 100644 --- a/src/actions/artists/createArtist.ts +++ b/src/actions/artists/createArtist.ts @@ -9,7 +9,15 @@ export async function createArtist(values: z.infer) { 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, + })), + }, } }) } \ No newline at end of file diff --git a/src/actions/artists/updateArtist.ts b/src/actions/artists/updateArtist.ts index fcf9c1f..494dcf7 100644 --- a/src/actions/artists/updateArtist.ts +++ b/src/actions/artists/updateArtist.ts @@ -8,6 +8,16 @@ export async function updateArtist( values: z.infer, 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, + })), + }, } }) } \ No newline at end of file diff --git a/src/app/artists/edit/[id]/page.tsx b/src/app/artists/edit/[id]/page.tsx index 2cf888d..c1514f3 100644 --- a/src/app/artists/edit/[id]/page.tsx +++ b/src/app/artists/edit/[id]/page.tsx @@ -7,6 +7,9 @@ export default async function ArtistsEditPage({ params }: { params: { id: string const artist = await prisma.artist.findUnique({ where: { id, + }, + include: { + socials: true } }); diff --git a/src/components/artists/edit/EditArtistForm.tsx b/src/components/artists/edit/EditArtistForm.tsx index 5e27349..1e46ca0 100644 --- a/src/components/artists/edit/EditArtistForm.tsx +++ b/src/components/artists/edit/EditArtistForm.tsx @@ -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>({ 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) { const updatedArtist = await updateArtist(values, artist.id) if (updatedArtist) { @@ -83,6 +98,95 @@ export default function EditAlbumForm({ artist }: { artist: Artist }) { )} /> + + {/* Socials section */} +
+ Social Links + {fields.map((field, index) => ( +
+
+ {getSocialIcon(watchSocials?.[index]?.platform ?? "")} + ( + + Platform + + + + + + )} + /> +
+ ( + + Handle + + + + + + )} + /> + ( + + Link + + + + + + )} + /> + ( + + + form.setValue( + "socials", + (watchSocials ?? []).map((s, i) => ({ + ...s, + isPrimary: i === index, + })) + ) + } + /> + Set as primary + + )} + /> + +
+ ))} + +
+
diff --git a/src/components/artists/new/CreateArtistForm.tsx b/src/components/artists/new/CreateArtistForm.tsx index 167081a..6bcb0ff 100644 --- a/src/components/artists/new/CreateArtistForm.tsx +++ b/src/components/artists/new/CreateArtistForm.tsx @@ -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) { const artist = await createArtist(values) if (artist) { @@ -81,6 +90,95 @@ export default function CreateArtistForm() { )} /> + + {/* Socials section */} +
+ Social Links + {fields.map((field, index) => ( +
+
+ {getSocialIcon(watchSocials?.[index]?.platform ?? "")} + ( + + Platform + + + + + + )} + /> +
+ ( + + Handle + + + + + + )} + /> + ( + + Link + + + + + + )} + /> + ( + + + form.setValue( + "socials", + (watchSocials ?? []).map((s, i) => ({ + ...s, + isPrimary: i === index, + })) + ) + } + /> + Set as primary + + )} + /> + +
+ ))} + +
+
diff --git a/src/schemas/artists/artistSchema.ts b/src/schemas/artists/artistSchema.ts index 7759c51..0cc3ef0 100644 --- a/src/schemas/artists/artistSchema.ts +++ b/src/schemas/artists/artistSchema.ts @@ -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"], + } +); diff --git a/src/utils/socialIconMap.tsx b/src/utils/socialIconMap.tsx new file mode 100644 index 0000000..3ff2066 --- /dev/null +++ b/src/utils/socialIconMap.tsx @@ -0,0 +1,14 @@ +import { FaGlobe, FaLink, FaMastodon, FaTelegram } from "react-icons/fa"; + +export const getSocialIcon = (platform: string) => { + switch (platform.toLowerCase()) { + case "telegram": + return ; + case "linktree": + return ; + case "fediverse": + return ; + default: + return ; + } +}; \ No newline at end of file