diff --git a/pics/515AxpWj0QL._UF894,1000_QL80_.jpg b/pics/515AxpWj0QL._UF894,1000_QL80_.jpg
new file mode 100644
index 0000000..977b5c6
Binary files /dev/null and b/pics/515AxpWj0QL._UF894,1000_QL80_.jpg differ
diff --git a/pics/81GU366NSiL._UF1000,1000_QL80_.jpg b/pics/81GU366NSiL._UF1000,1000_QL80_.jpg
new file mode 100644
index 0000000..1f72963
Binary files /dev/null and b/pics/81GU366NSiL._UF1000,1000_QL80_.jpg differ
diff --git a/pics/ab67616d0000b27302c1331a8a44330610dd4cbe.jpeg b/pics/ab67616d0000b27302c1331a8a44330610dd4cbe.jpeg
new file mode 100644
index 0000000..f16276f
Binary files /dev/null and b/pics/ab67616d0000b27302c1331a8a44330610dd4cbe.jpeg differ
diff --git a/pics/ab67616d0000b2731df28c6164bc1bf01e304daa.jpeg b/pics/ab67616d0000b2731df28c6164bc1bf01e304daa.jpeg
new file mode 100644
index 0000000..6d7c4f0
Binary files /dev/null and b/pics/ab67616d0000b2731df28c6164bc1bf01e304daa.jpeg differ
diff --git a/pics/ab67616d0000b27364ba6b515bb7a9c2a0de8f90.jpeg b/pics/ab67616d0000b27364ba6b515bb7a9c2a0de8f90.jpeg
new file mode 100644
index 0000000..8db6973
Binary files /dev/null and b/pics/ab67616d0000b27364ba6b515bb7a9c2a0de8f90.jpeg differ
diff --git a/pics/ab67616d0000b273bc6b0bc222552902755ee599.jpeg b/pics/ab67616d0000b273bc6b0bc222552902755ee599.jpeg
new file mode 100644
index 0000000..862c09e
Binary files /dev/null and b/pics/ab67616d0000b273bc6b0bc222552902755ee599.jpeg differ
diff --git a/pics/ab67616d0000b273d2675cb6847d1b1da9fc28a3.jpeg b/pics/ab67616d0000b273d2675cb6847d1b1da9fc28a3.jpeg
new file mode 100644
index 0000000..4bb5d5e
Binary files /dev/null and b/pics/ab67616d0000b273d2675cb6847d1b1da9fc28a3.jpeg differ
diff --git a/src/App.css b/src/App.css
index 393b616..a9848f4 100644
--- a/src/App.css
+++ b/src/App.css
@@ -6,7 +6,17 @@
}
.app-header {
- margin-bottom: 24px;
+ background-color: #fce4ec;
+ padding: 24px;
+ text-align: center;
+ margin: -24px -24px 24px -24px;
+}
+
+.app-header h1 {
+ color: #c2185b;
+ font-family: Georgia, serif;
+ font-size: 32px;
+ margin: 0;
}
.app-content {
@@ -27,6 +37,116 @@
min-width: 0;
}
+.song-controls {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.sidebar button,
+.sidebar select {
+ background-color: #fce4ec;
+ width: 100%;
+ padding: 8px 12px;
+ border: none;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ display: inline-flex;
+ align-items: center;
+}
+
+.sidebar button:hover {
+ background-color: #f48fb1;
+}
+
+.sidebar button:active {
+ background-color: #f48fb1;
+}
+
+.sidebar button.active {
+ background-color: #f48fb1;
+}
+
+.sidebar ul {
+ list-style: none;
+ padding: 0;
+}
+
+.sidebar li {
+ margin-bottom: 8px;
+}
+
+.sidebar > section > div {
+ margin-bottom: 16px;
+}
+
+.song-controls {
+ align-items: center;
+}
+
+.song-controls button {
+ padding: 8px 12px;
+ border: none;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ display: inline-flex;
+ align-items: center;
+}
+
+.song-controls nav {
+ display: flex;
+ gap: 12px;
+}
+
+.song-controls nav button {
+ background-color: #e0e0e0;
+ padding: 8px 16px;
+}
+
+.song-controls nav button:hover {
+ background-color: #b3b3b3;
+}
+
+.song-controls nav button:active {
+ background-color: #b3b3b3;
+}
+
+.song-controls nav button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.song-lyrics-wrapper {
+ margin-top: 16px;
+}
+
+.song-lyrics {
+ margin: 0;
+}
+
+.song-lyrics-stanza {
+ margin: 0 0 1.5em;
+}
+
+.song-lyrics-stanza:last-child {
+ margin-bottom: 0;
+}
+
+.song-lyrics-line {
+ display: block;
+}
+
+.song-album-art {
+ margin-top: 24px;
+}
+
+.song-album-art img {
+ display: block;
+ width: min(100%, 320px);
+ height: auto;
+ border-radius: 4px;
+}
+
@media (max-width: 768px) {
.app {
padding: 16px;
diff --git a/src/App.tsx b/src/App.tsx
index be860e4..b2f86a7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -51,21 +51,22 @@ function App() {
- handleToggleFavorites(currentSong.id)}/>
+
+ handleToggleFavorites(currentSong.id)}/>
+ setCurrentSongIndex(currentSongIndex - 1)}
+ onNext={() => setCurrentSongIndex(currentSongIndex + 1)}
+ />
+
- setCurrentSongIndex(currentSongIndex - 1)}
- onNext={() => setCurrentSongIndex(currentSongIndex + 1)}
- />
-
diff --git a/src/components/LyricsDisplay.tsx b/src/components/LyricsDisplay.tsx
new file mode 100644
index 0000000..2a3964d
--- /dev/null
+++ b/src/components/LyricsDisplay.tsx
@@ -0,0 +1,27 @@
+import { parseLyrics } from '../utils/formatLyrics'
+
+type LyricsDisplayProps = {
+ lyrics: string
+}
+
+export function LyricsDisplay({ lyrics }: LyricsDisplayProps) {
+ const stanzas = parseLyrics(lyrics)
+
+ if (!stanzas.length) {
+ return null
+ }
+
+ return (
+
+ {stanzas.map((stanza, stanzaIndex) => (
+
+ {stanza.lines.map((line, lineIndex) => (
+
+ {line}
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx
index 2db4850..a3797d5 100644
--- a/src/components/SongCard.tsx
+++ b/src/components/SongCard.tsx
@@ -1,5 +1,7 @@
import type { Song } from '../data/songs'
+import { getAlbumImage } from '../data/albumImages'
import { useLyrics } from '../hooks/useLyrics'
+import { LyricsDisplay } from './LyricsDisplay'
type SongCardProps = {
song: Song
@@ -8,20 +10,29 @@ type SongCardProps = {
export function SongCard({ song }: SongCardProps) {
const { data, isLoading, error } = useLyrics(song.artist, song.title)
-
+ const albumImage = getAlbumImage(song.album)
+
return (
{song.title}
- {song.artist}
- {song.album}
+ Artist: {song.artist}
+ Album: {song.album}
-
-
{isLoading ? 'Laster…' : error || data?.error ? 'Fant ingen sangtekst for denne sangen' : data?.lyrics}
+
+ {isLoading ? (
+
Laster…
+ ) : error || data?.error ? (
+
Fant ingen sangtekst for denne sangen
+ ) : data?.lyrics ? (
+
+ ) : null}
-
+ {albumImage && (
+
+

+
+ )}
)
diff --git a/src/components/SongList.tsx b/src/components/SongList.tsx
index fbfb0b3..8c7d071 100644
--- a/src/components/SongList.tsx
+++ b/src/components/SongList.tsx
@@ -3,9 +3,10 @@ import type { Song } from '../data/songs'
type SongListProps = {
songs: Song[]
onSelectSong: (song: Song) => void
+ currentSong: Song
}
-export function SongList({songs, onSelectSong}: SongListProps) {
+export function SongList({songs, onSelectSong, currentSong}: SongListProps) {
return (
@@ -15,7 +16,7 @@ export function SongList({songs, onSelectSong}: SongListProps) {
{songs.map((song) => (
-
-
diff --git a/src/data/albumImages.ts b/src/data/albumImages.ts
new file mode 100644
index 0000000..8ea3371
--- /dev/null
+++ b/src/data/albumImages.ts
@@ -0,0 +1,21 @@
+import fasterThanTheSpeedOfNight from '../../pics/81GU366NSiL._UF1000,1000_QL80_.jpg'
+import secretDreams from '../../pics/ab67616d0000b273d2675cb6847d1b1da9fc28a3.jpeg'
+import naturalForce from '../../pics/ab67616d0000b27302c1331a8a44330610dd4cbe.jpeg'
+import theWorldStartsTonight from '../../pics/ab67616d0000b2731df28c6164bc1bf01e304daa.jpeg'
+import freeSpirit from '../../pics/ab67616d0000b27364ba6b515bb7a9c2a0de8f90.jpeg'
+import singel from '../../pics/515AxpWj0QL._UF894,1000_QL80_.jpg'
+import allInOneVoice from '../../pics/ab67616d0000b273bc6b0bc222552902755ee599.jpeg'
+
+export const albumImages: Record = {
+ 'Faster Than the Speed of Night': fasterThanTheSpeedOfNight,
+ 'Secret Dreams and Forbidden Fire': secretDreams,
+ 'Natural Force': naturalForce,
+ 'The World Starts Tonight': theWorldStartsTonight,
+ 'Free Spirit': freeSpirit,
+ Singel: singel,
+ 'All in One Voice': allInOneVoice,
+}
+
+export function getAlbumImage(album: string): string | undefined {
+ return albumImages[album]
+}
diff --git a/src/utils/formatLyrics.test.ts b/src/utils/formatLyrics.test.ts
new file mode 100644
index 0000000..90b9271
--- /dev/null
+++ b/src/utils/formatLyrics.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { parseLyrics } from './formatLyrics'
+
+describe('parseLyrics', () => {
+ it('legger mellomrom mellom vers og refreng uten tom linje i API-tekst', () => {
+ const lyrics = `Someone told me long ago
+There's a calm before the storm
+I know it's been coming for some time
+When it's over so they say
+It'll rain a sunny day
+I know the shining down on me
+I wanna know, have you ever seen the rain?
+I wanna know, have you ever seen the rain?
+Coming down on a sunny day
+Yesterday and days before
+Sun is cold and rain is hard
+I know been that way before
+I wanna know, have you ever seen the rain?
+I wanna know, have you ever seen the rain?
+Coming down on a sunny day`
+
+ const stanzas = parseLyrics(lyrics)
+
+ expect(stanzas.length).toBeGreaterThanOrEqual(4)
+ expect(stanzas[0].lines[0]).toContain('Someone told me')
+ expect(stanzas[1].lines[0]).toContain('I wanna know')
+ })
+})
diff --git a/src/utils/formatLyrics.ts b/src/utils/formatLyrics.ts
new file mode 100644
index 0000000..0f6571a
--- /dev/null
+++ b/src/utils/formatLyrics.ts
@@ -0,0 +1,141 @@
+export type LyricsStanza = {
+ lines: string[]
+}
+
+function normalizeLyrics(raw: string): string {
+ return raw.replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim()
+}
+
+function normalizeLine(line: string): string {
+ return line.toLowerCase().trim()
+}
+
+const sectionMarker = /^\s*[\[(](?:verse|chorus|bridge|intro|outro|pre-chorus)/i
+
+function stanzasFromBreaks(lines: string[], breaks: Set): LyricsStanza[] {
+ const starts = [...breaks]
+ .filter((index) => index >= 0 && index < lines.length)
+ .sort((a, b) => a - b)
+
+ if (!starts.includes(0)) {
+ starts.unshift(0)
+ }
+
+ const stanzas: LyricsStanza[] = []
+
+ for (let index = 0; index < starts.length; index++) {
+ const start = starts[index]
+ const end = starts[index + 1] ?? lines.length
+ const slice = lines.slice(start, end)
+ if (slice.length) {
+ stanzas.push({ lines: slice })
+ }
+ }
+
+ return stanzas.length ? stanzas : [{ lines }]
+}
+
+function splitOnSectionMarkers(lines: string[]): LyricsStanza[] {
+ const breaks = new Set([0])
+
+ for (let index = 0; index < lines.length; index++) {
+ if (sectionMarker.test(lines[index]) && index > 0) {
+ breaks.add(index)
+ }
+ }
+
+ return stanzasFromBreaks(lines, breaks)
+}
+
+function findBestRepeatingBlock(
+ lines: string[],
+): { size: number; indices: number[] } | null {
+ let best: { size: number; indices: number[]; score: number } | null = null
+
+ for (let size = Math.min(12, lines.length); size >= 2; size--) {
+ const groups = new Map()
+
+ for (let start = 0; start <= lines.length - size; start++) {
+ const key = lines.slice(start, start + size).map(normalizeLine).join('\n')
+ const indices = groups.get(key) ?? []
+ indices.push(start)
+ groups.set(key, indices)
+ }
+
+ for (const indices of groups.values()) {
+ if (indices.length < 2) continue
+
+ const sorted = [...indices].sort((a, b) => a - b)
+ const score = size * sorted.length
+ if (!best || score > best.score) {
+ best = { size, indices: sorted, score }
+ }
+ }
+ }
+
+ return best ? { size: best.size, indices: best.indices } : null
+}
+
+function splitOnRepeatingSections(lines: string[]): LyricsStanza[] {
+ if (lines.length < 4) {
+ return [{ lines }]
+ }
+
+ const repeatingBlock = findBestRepeatingBlock(lines)
+ if (!repeatingBlock) {
+ return [{ lines }]
+ }
+
+ const breaks = new Set([0])
+
+ for (const start of repeatingBlock.indices) {
+ breaks.add(start)
+ const afterChorus = start + repeatingBlock.size
+ if (afterChorus < lines.length) {
+ breaks.add(afterChorus)
+ }
+ }
+
+ return stanzasFromBreaks(lines, breaks)
+}
+
+function linesFromPlainParagraph(text: string): string[] {
+ return text
+ .split(/\.\s+(?=[A-Z([])/)
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .map((part) => (part.endsWith('.') ? part : `${part}.`))
+}
+
+function splitIntoLines(normalized: string): string[] {
+ return normalized
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+}
+
+export function parseLyrics(raw: string): LyricsStanza[] {
+ const normalized = normalizeLyrics(raw)
+ if (!normalized) return []
+
+ if (!normalized.includes('\n')) {
+ return [{ lines: linesFromPlainParagraph(normalized) }]
+ }
+
+ const stanzaTexts = normalized
+ .split(/\n\s*\n/)
+ .map((block) => block.trim())
+ .filter(Boolean)
+
+ let stanzas: LyricsStanza[] = stanzaTexts
+ .map((block) => ({ lines: splitIntoLines(block) }))
+ .filter((stanza) => stanza.lines.length > 0)
+
+ if (stanzas.length === 1) {
+ const lines = stanzas[0].lines
+ const marked = splitOnSectionMarkers(lines)
+ stanzas = marked.length > 1 ? marked : splitOnRepeatingSections(lines)
+ }
+
+ return stanzas
+}