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/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..68329de 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -7,6 +7,7 @@ import { prisma } from "./client";
import { AlbumType } from "./generated/client";
import { serializeSongSeed } from "./serializer";
+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";
@@ -82,14 +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 ?? ""),
+ description: normalizeDescription(song.description),
+ credits: normalizeCredits(song.credits),
}));
}
@@ -148,6 +166,7 @@ async function seedAlbums(
...data,
type: data.type as AlbumType,
releaseDate: new Date(data.releaseDate),
+ 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
new file mode 100644
index 0000000..9615911
--- /dev/null
+++ b/shared/schemas/credits.ts
@@ -0,0 +1,31 @@
+import { z } from "zod";
+
+import { Prisma } from "@/prisma/generated/client";
+
+const nonEmptyString = z.string().trim().min(1);
+
+const CreditEntitySchema = z.object({
+ name: nonEmptyString,
+ romanizedName: nonEmptyString.optional(),
+});
+
+const CreditEntrySchema = z.object({
+ role: nonEmptyString,
+ entities: z.array(CreditEntitySchema).nonempty(),
+});
+
+const CreditsSchema = z
+ .object({ credits: z.array(CreditEntrySchema).default([]) })
+ .strict();
+
+export type Credits = z.infer;
+
+export function assertCredits(json: unknown): Credits {
+ return CreditsSchema.parse(json);
+}
+
+export function parseCredits(json: Prisma.JsonValue): Credits | null {
+ if (json == null) return null;
+ const result = CreditsSchema.safeParse(json);
+ return result.success ? result.data : null;
+}
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;
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;