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/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;