Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions app/lyrics/[song_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ export default async function LyricsSongPage({

<div
className="relative overflow-hidden"
style={{
background: `linear-gradient(180deg, ${song.themeColor} 0%, ${song.themeColor}dd 100%)`,
}}
style={{ background: song.themeColor }}
>
<div className="container mx-auto px-4 sm:px-6">
<div className="flex justify-center">
Expand Down
118 changes: 101 additions & 17 deletions features/lyrics/reader/components/SongCreditsDetails.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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 (
<section className="w-full max-w-3xl pt-12 pb-4 sm:pb-8">
<header className="flex justify-start gap-2 text-white/70">
<Quote className="h-4 w-4" />
<h3 className="text-sm font-medium tracking-widest uppercase">
About this Song
</h3>
</header>

<div className="mt-2 mb-4">
<p className="text-sm whitespace-pre-wrap sm:text-base">
{song.description}
</p>
</div>

<div className="h-px w-full border border-white/10" />
<section ref={sectionRef} className="relative my-5 w-full max-w-4xl p-5">
{hasDescription && (
<div className="relative my-6">
<div className="mb-6 flex items-center gap-4">
<div className="h-px flex-1 bg-linear-to-r from-transparent to-white/15" />
<div className="flex flex-col items-center">
<BookOpen className="mb-2 size-4 text-white/50" />
<h3 className="text-xs font-medium tracking-wide text-white/70 uppercase">
About this Song
</h3>
<p className="mt-1 text-xs text-white/50">Background & Context</p>
</div>
<div className="h-px flex-1 bg-linear-to-l from-transparent to-white/15" />
</div>

<p className="text-sm leading-relaxed whitespace-pre-wrap text-white/70 sm:text-base sm:leading-loose">
{song.description}
</p>
</div>
)}

{hasCredits && (
<div className="relative mt-4">
<div className="mb-6 flex items-center gap-4">
<div className="h-px flex-1 bg-linear-to-r from-transparent to-white/15" />
<div className="flex flex-col items-center">
<Sparkle className="mb-2 size-4 text-white/50" />
<h3 className="text-xs font-medium tracking-wide text-white/70 uppercase">
Credits
</h3>
<p className="mt-1 text-xs text-white/50">
Artists & Contributors
</p>
</div>
<div className="h-px flex-1 bg-linear-to-l from-transparent to-white/15" />
</div>

<div className="grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4">
{song.credits!.credits.map((credit, index) => (
<div key={index} className="group">
<span className="mb-1 block text-xs font-semibold text-white/50 capitalize">
{credit.role}
</span>
<div className="space-y-px">
{credit.entities.map((entity, entityIndex) => (
<div
key={entityIndex}
className="flex items-baseline gap-2"
>
<span className="text-sm text-white/85">
{entity.name}
</span>
{entity.romanizedName && (
<span className="text-xs text-white/50">
{entity.romanizedName}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}

<div className="mt-8 h-px w-full bg-linear-to-r from-transparent via-white/15 to-transparent" />
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "credits" JSONB;

-- AlterTable
ALTER TABLE "Song" ADD COLUMN "credits" JSONB;
22 changes: 14 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ datasource db {
}

model Song {
id String @id @unique
id String @id @unique
titleEnglish String
titleJapanese String
length String
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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])
Expand Down
31 changes: 25 additions & 6 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
}));
}

Expand Down Expand Up @@ -148,6 +166,7 @@ async function seedAlbums(
...data,
type: data.type as AlbumType,
releaseDate: new Date(data.releaseDate),
credits: album.credits ? assertCredits(album.credits) : "",
},
});

Expand Down
1 change: 1 addition & 0 deletions prisma/select/songSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const songBaseSelect = {

export const songPrismaSelect = {
...songBaseSelect,
credits: true,
};

export const songListPrismaSelect = {
Expand Down
3 changes: 3 additions & 0 deletions prisma/serialize/songSerialize.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -35,6 +36,7 @@ export function serializeSong(
youtubeId: song.youtubeId,
coverArt: song.coverArt,
themeColor: song.themeColor ?? undefined,
credits: parseCredits(song.credits),
albumTrack: undefined,
};
}
Expand All @@ -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,
}));
}
31 changes: 31 additions & 0 deletions shared/schemas/credits.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CreditsSchema>;

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;
}
3 changes: 3 additions & 0 deletions types/album.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -32,6 +34,7 @@ export type AlbumDefinition = {
trackNumber: number;
}>;
externalLinks: ExternalLinkDefinition[];
credits?: AlbumCredits;
};

export type AlbumMinimal = Omit<Album, "tracks">;
2 changes: 2 additions & 0 deletions types/song.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +16,7 @@ export type Song = {
youtubeId?: string | null;
coverArt: string;
themeColor?: string;
credits?: Credits | null;
externalLinks?: ExternalLinkDefinition[];
albumTrack?: {
trackNumber: number;
Expand Down
Loading