From 2f7c5eb66645023e4bcb6303d6749eadf64b66c4 Mon Sep 17 00:00:00 2001 From: Luis Diaz Date: Wed, 14 Jan 2026 13:30:14 -0500 Subject: [PATCH 1/2] feat: added credits metadata to Song and Album in JSON format --- .../migration.sql | 5 ++ prisma/schema.prisma | 22 +++--- prisma/seed.ts | 6 ++ shared/schemas/credits.ts | 67 +++++++++++++++++++ types/album.ts | 3 + 5 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260114040845_added_credits_metadata/migration.sql create mode 100644 shared/schemas/credits.ts diff --git a/prisma/migrations/20260114040845_added_credits_metadata/migration.sql b/prisma/migrations/20260114040845_added_credits_metadata/migration.sql new file mode 100644 index 0000000..e4f6be8 --- /dev/null +++ b/prisma/migrations/20260114040845_added_credits_metadata/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Album" ADD COLUMN "credits" JSONB; + +-- AlterTable +ALTER TABLE "Song" ADD COLUMN "credits" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eac8ad6..8bf1a43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,7 +10,7 @@ datasource db { } model Song { - id String @id @unique + id String @id @unique titleEnglish String titleJapanese String length String @@ -20,8 +20,10 @@ model Song { youtubeId String? coverArt String themeColor String? - lyrics Lyrics[] - albumTracks AlbumTrack[] + + credits Json? + lyrics Lyrics[] + albumTracks AlbumTrack[] @@index([releaseDate(sort: Desc)], name: "song_release_date_idx") @@index([titleEnglish], name: "song_title_english_idx") @@ -33,20 +35,23 @@ model Lyrics { language String translator String? lines String[] - song Song @relation(fields: [songId], references: [id], onDelete: Cascade) + + song Song @relation(fields: [songId], references: [id], onDelete: Cascade) @@id([songId, language]) @@index([songId]) } model Album { - id String @id @unique + id String @id @unique titleEnglish String titleJapanese String releaseDate DateTime type AlbumType coverArt String - tracks AlbumTrack[] + + credits Json? + tracks AlbumTrack[] @@index([releaseDate(sort: Desc)], name: "album_release_date_idx") @@index([type], name: "album_type_idx") @@ -57,8 +62,9 @@ model AlbumTrack { songId String trackNumber Int isBonusTrack Boolean @default(false) - album Album @relation(fields: [albumId], references: [id]) - song Song @relation(fields: [songId], references: [id]) + + album Album @relation(fields: [albumId], references: [id]) + song Song @relation(fields: [songId], references: [id]) @@id([albumId, songId]) @@unique([albumId, trackNumber]) diff --git a/prisma/seed.ts b/prisma/seed.ts index 960b3d4..818f544 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -7,6 +7,10 @@ import { prisma } from "./client"; import { AlbumType } from "./generated/client"; import { serializeSongSeed } from "./serializer"; +import { + assertAlbumCredits, + assertSongCredits, +} from "@/shared/schemas/credits"; import type { AlbumDefinition } from "@/types/album"; import type { Lyrics } from "@/types/lyrics"; import type { Song, SongSeedInput } from "@/types/song"; @@ -90,6 +94,7 @@ function normalizeSongs(songs: Song[]): SongSeedInput[] { .map((item) => (Array.isArray(item) ? item.join("\n") : item)) .join("\n") : (song.description ?? ""), + credits: song.credits ? assertSongCredits(song.credits) : "", })); } @@ -148,6 +153,7 @@ async function seedAlbums( ...data, type: data.type as AlbumType, releaseDate: new Date(data.releaseDate), + credits: album.credits ? assertAlbumCredits(album.credits) : "", }, }); diff --git a/shared/schemas/credits.ts b/shared/schemas/credits.ts new file mode 100644 index 0000000..4f09eee --- /dev/null +++ b/shared/schemas/credits.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { Prisma } from "@/prisma/generated/client"; + +const creditArray = z.array(z.string().trim().min(1)).nonempty(); +const credit = creditArray.optional(); + +export const SongCreditsSchema = z + .object({ + vocalist: credit, + producer: credit, + composer: credit, + lyricist: credit, + arranger: credit, + recordingEngineer: credit, + mixingEngineer: credit, + masteringEngineer: credit, + illustrator: credit, + videoDirector: credit, + videoAnimator: credit, + }) + .strict(); + +export const AlbumCreditsSchema = z + .object({ + mainArtist: credit, + producer: credit, + executiveProducer: credit, + composer: credit, + lyricist: credit, + arranger: credit, + mixingEngineer: credit, + masteringEngineer: credit, + artDirector: credit, + illustrator: credit, + photographer: credit, + designer: credit, + label: credit, + }) + .strict(); + +export type SongCredits = z.infer; +export type AlbumCredits = z.infer; + +export function assertSongCredits(json: unknown): SongCredits { + return SongCreditsSchema.parse(json); +} + +export function assertAlbumCredits(json: unknown): AlbumCredits { + return AlbumCreditsSchema.parse(json); +} + +function parseCredits( + schema: z.ZodType, + json: Prisma.JsonValue, +): T | null { + if (json == null) return null; + + const result = schema.safeParse(json); + return result.success ? result.data : null; +} + +export const parseSongCredits = (json: Prisma.JsonValue) => + parseCredits(SongCreditsSchema, json); + +export const parseAlbumCredits = (json: Prisma.JsonValue) => + parseCredits(AlbumCreditsSchema, json); diff --git a/types/album.ts b/types/album.ts index 3aa32f4..100c60d 100644 --- a/types/album.ts +++ b/types/album.ts @@ -1,6 +1,8 @@ import type { ExternalLinkDefinition } from "./externalLink"; import type { Song } from "./song"; +import { AlbumCredits } from "@/shared/schemas/credits"; + export type Album = { id: string; title: { @@ -32,6 +34,7 @@ export type AlbumDefinition = { trackNumber: number; }>; externalLinks: ExternalLinkDefinition[]; + credits?: AlbumCredits; }; export type AlbumMinimal = Omit; From a4405693bfe41c4a396e7fe8a77f4f7a3ad9f55c Mon Sep 17 00:00:00 2001 From: Luis Diaz Date: Sat, 17 Jan 2026 18:11:50 -0500 Subject: [PATCH 2/2] feat: added song metada to lyrics' song page --- app/lyrics/[song_id]/page.tsx | 4 +- .../reader/components/SongCreditsDetails.tsx | 118 +++++++++++++++--- prisma/seed.ts | 37 ++++-- prisma/select/songSelect.ts | 1 + prisma/serialize/songSerialize.ts | 3 + shared/schemas/credits.ts | 70 +++-------- types/song.ts | 2 + 7 files changed, 150 insertions(+), 85 deletions(-) diff --git a/app/lyrics/[song_id]/page.tsx b/app/lyrics/[song_id]/page.tsx index b8ea99f..391ba93 100644 --- a/app/lyrics/[song_id]/page.tsx +++ b/app/lyrics/[song_id]/page.tsx @@ -41,9 +41,7 @@ export default async function LyricsSongPage({
diff --git a/features/lyrics/reader/components/SongCreditsDetails.tsx b/features/lyrics/reader/components/SongCreditsDetails.tsx index d0c02da..867b477 100644 --- a/features/lyrics/reader/components/SongCreditsDetails.tsx +++ b/features/lyrics/reader/components/SongCreditsDetails.tsx @@ -1,26 +1,110 @@ -import { Quote } from "lucide-react"; +"use client"; + +import { useGSAP } from "@gsap/react"; +import gsap from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { BookOpen, Sparkle } from "lucide-react"; + +import { useRef } from "react"; import type { Song } from "@/types/song"; +gsap.registerPlugin(ScrollTrigger); + export function SongCreditsDetails({ song }: { song: Song }) { - if (!song.description) return null; + const sectionRef = useRef(null); + const hasDescription = !!song.description; + const hasCredits = song.credits && song.credits.credits.length > 0; + + useGSAP( + () => { + if (!sectionRef.current) return; + + gsap.from(sectionRef.current.children, { + opacity: 0, + y: 20, + duration: 0.8, + stagger: 0.15, + ease: "power2.out", + scrollTrigger: { + trigger: sectionRef.current, + start: "top 80%", + }, + }); + }, + { scope: sectionRef }, + ); + + if (!hasDescription && !hasCredits) return null; return ( -
-
- -

- About this Song -

-
- -
-

- {song.description} -

-
- -
+
+ {hasDescription && ( +
+
+
+
+ +

+ About this Song +

+

Background & Context

+
+
+
+ +

+ {song.description} +

+
+ )} + + {hasCredits && ( +
+
+
+
+ +

+ Credits +

+

+ Artists & Contributors +

+
+
+
+ +
+ {song.credits!.credits.map((credit, index) => ( +
+ + {credit.role} + +
+ {credit.entities.map((entity, entityIndex) => ( +
+ + {entity.name} + + {entity.romanizedName && ( + + {entity.romanizedName} + + )} +
+ ))} +
+
+ ))} +
+
+ )} + +
); } diff --git a/prisma/seed.ts b/prisma/seed.ts index 818f544..68329de 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -7,10 +7,7 @@ import { prisma } from "./client"; import { AlbumType } from "./generated/client"; import { serializeSongSeed } from "./serializer"; -import { - assertAlbumCredits, - assertSongCredits, -} from "@/shared/schemas/credits"; +import { Credits, assertCredits } from "@/shared/schemas/credits"; import type { AlbumDefinition } from "@/types/album"; import type { Lyrics } from "@/types/lyrics"; import type { Song, SongSeedInput } from "@/types/song"; @@ -86,15 +83,31 @@ function loadLyrics(path: string): Lyrics[] { return lyrics; } -function normalizeSongs(songs: Song[]): SongSeedInput[] { +function normalizeDescription( + description: SongSeedInput["description"], +): string { + if (!description) return ""; + + if (Array.isArray(description)) { + return description + .map((item) => (Array.isArray(item) ? item.join("\n") : item)) + .join("\n"); + } + + return description; +} + +function normalizeCredits(credits: unknown): Credits | "" { + if (!credits) return ""; + const normalized = Array.isArray(credits) ? { credits } : credits; + return assertCredits(normalized); +} + +export function normalizeSongs(songs: Song[]): SongSeedInput[] { return serializeSongSeed(songs).map((song) => ({ ...song, - description: Array.isArray(song.description) - ? song.description - .map((item) => (Array.isArray(item) ? item.join("\n") : item)) - .join("\n") - : (song.description ?? ""), - credits: song.credits ? assertSongCredits(song.credits) : "", + description: normalizeDescription(song.description), + credits: normalizeCredits(song.credits), })); } @@ -153,7 +166,7 @@ async function seedAlbums( ...data, type: data.type as AlbumType, releaseDate: new Date(data.releaseDate), - credits: album.credits ? assertAlbumCredits(album.credits) : "", + credits: album.credits ? assertCredits(album.credits) : "", }, }); diff --git a/prisma/select/songSelect.ts b/prisma/select/songSelect.ts index a499504..8c62b97 100644 --- a/prisma/select/songSelect.ts +++ b/prisma/select/songSelect.ts @@ -13,6 +13,7 @@ const songBaseSelect = { export const songPrismaSelect = { ...songBaseSelect, + credits: true, }; export const songListPrismaSelect = { diff --git a/prisma/serialize/songSerialize.ts b/prisma/serialize/songSerialize.ts index 1048486..8da1963 100644 --- a/prisma/serialize/songSerialize.ts +++ b/prisma/serialize/songSerialize.ts @@ -1,6 +1,7 @@ import { Prisma } from "../generated/client"; import { songListPrismaSelect, songPrismaSelect } from "../select/songSelect"; +import { parseCredits } from "@/shared/schemas/credits"; import type { Song, SongListItem, SongSeedInput } from "@/types/song"; export function serializeSongListItem( @@ -35,6 +36,7 @@ export function serializeSong( youtubeId: song.youtubeId, coverArt: song.coverArt, themeColor: song.themeColor ?? undefined, + credits: parseCredits(song.credits), albumTrack: undefined, }; } @@ -51,6 +53,7 @@ export function serializeSongSeed(songs: Song[]): SongSeedInput[] { youtubeId: song.youtubeId ?? null, coverArt: song.coverArt, themeColor: song.themeColor ?? null, + credits: song.credits ?? undefined, externalLinks: song.externalLinks, })); } diff --git a/shared/schemas/credits.ts b/shared/schemas/credits.ts index 4f09eee..9615911 100644 --- a/shared/schemas/credits.ts +++ b/shared/schemas/credits.ts @@ -2,66 +2,30 @@ import { z } from "zod"; import { Prisma } from "@/prisma/generated/client"; -const creditArray = z.array(z.string().trim().min(1)).nonempty(); -const credit = creditArray.optional(); +const nonEmptyString = z.string().trim().min(1); -export const SongCreditsSchema = z - .object({ - vocalist: credit, - producer: credit, - composer: credit, - lyricist: credit, - arranger: credit, - recordingEngineer: credit, - mixingEngineer: credit, - masteringEngineer: credit, - illustrator: credit, - videoDirector: credit, - videoAnimator: credit, - }) - .strict(); +const CreditEntitySchema = z.object({ + name: nonEmptyString, + romanizedName: nonEmptyString.optional(), +}); -export const AlbumCreditsSchema = z - .object({ - mainArtist: credit, - producer: credit, - executiveProducer: credit, - composer: credit, - lyricist: credit, - arranger: credit, - mixingEngineer: credit, - masteringEngineer: credit, - artDirector: credit, - illustrator: credit, - photographer: credit, - designer: credit, - label: credit, - }) - .strict(); +const CreditEntrySchema = z.object({ + role: nonEmptyString, + entities: z.array(CreditEntitySchema).nonempty(), +}); -export type SongCredits = z.infer; -export type AlbumCredits = z.infer; +const CreditsSchema = z + .object({ credits: z.array(CreditEntrySchema).default([]) }) + .strict(); -export function assertSongCredits(json: unknown): SongCredits { - return SongCreditsSchema.parse(json); -} +export type Credits = z.infer; -export function assertAlbumCredits(json: unknown): AlbumCredits { - return AlbumCreditsSchema.parse(json); +export function assertCredits(json: unknown): Credits { + return CreditsSchema.parse(json); } -function parseCredits( - schema: z.ZodType, - json: Prisma.JsonValue, -): T | null { +export function parseCredits(json: Prisma.JsonValue): Credits | null { if (json == null) return null; - - const result = schema.safeParse(json); + const result = CreditsSchema.safeParse(json); return result.success ? result.data : null; } - -export const parseSongCredits = (json: Prisma.JsonValue) => - parseCredits(SongCreditsSchema, json); - -export const parseAlbumCredits = (json: Prisma.JsonValue) => - parseCredits(AlbumCreditsSchema, json); diff --git a/types/song.ts b/types/song.ts index 08ecf09..8bd1c0b 100644 --- a/types/song.ts +++ b/types/song.ts @@ -1,6 +1,7 @@ import type { ExternalLinkDefinition } from "./externalLink"; import type { SongCreateInput } from "@/prisma/generated/models"; +import type { Credits } from "@/shared/schemas/credits"; export type Song = { id: string; @@ -15,6 +16,7 @@ export type Song = { youtubeId?: string | null; coverArt: string; themeColor?: string; + credits?: Credits | null; externalLinks?: ExternalLinkDefinition[]; albumTrack?: { trackNumber: number;