Files
Church-Music/new-site/frontend/src/pages/SongViewPage.jsx

1542 lines
55 KiB
JavaScript

import { useState, useEffect, useRef, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import toast from "react-hot-toast";
import {
ChevronLeft,
ChevronRight,
Edit,
Save,
X,
Music,
Mic2,
ZoomIn,
ZoomOut,
Home,
ArrowUp,
Check,
ChevronDown,
Plus,
Trash2,
} from "lucide-react";
import api from "@utils/api";
import { useTheme } from "@context/ThemeContext";
import { useSongs, useSong, useDataMutations } from "@hooks/useDataFetch";
import {
ALL_KEYS,
ALL_CHORDS,
ROOT_NOTES,
CHORD_QUALITIES,
parseLyricsWithChords,
transposeLyricsText,
detectKeyFromLyrics,
getSemitonesBetweenKeys,
getDiatonicChords,
getCommonProgressions,
} from "@utils/chordSheetUtils";
import LyricsRichTextEditor from "@components/LyricsRichTextEditor";
export default function SongViewPage() {
const { songId: id } = useParams();
const navigate = useNavigate();
const { isDark } = useTheme();
const contentRef = useRef(null);
// Use cached data from the global store
const { songs } = useSongs();
const { song: cachedSong, loading: songLoading } = useSong(id);
const { updateSongInCache, invalidateSongDetail } = useDataMutations();
// Core state
const [song, setSong] = useState(null);
const [loading, setLoading] = useState(true);
// Key transposition state
const [originalKey, setOriginalKey] = useState("C");
const [selectedKey, setSelectedKey] = useState("C");
const [showKeySelector, setShowKeySelector] = useState(false);
// UI state
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState({});
const [fontSize, setFontSize] = useState(18);
const [showNav, setShowNav] = useState(false);
const [saving, setSaving] = useState(false);
const [showChords, setShowChords] = useState(true); // Toggle chord visibility
const [showDeleteModal, setShowDeleteModal] = useState(false);
// Click-to-place chord editor state
const [chordEditMode, setChordEditMode] = useState(false);
const [selectedChordToPlace, setSelectedChordToPlace] = useState(null);
const [showTextEditor, setShowTextEditor] = useState(false);
const [showRichTextEditor, setShowRichTextEditor] = useState(false);
const [editableLyrics, setEditableLyrics] = useState("");
// Extract original key from song data
const extractOriginalKey = (songData) => {
if (!songData) return "C";
if (songData.key_chord)
return songData.key_chord.replace(/\s+/g, "").split(/[,\/]/)[0];
if (songData.chords)
return songData.chords.replace(/\s+/g, "").split(/[,\/\s]/)[0];
if (songData.original_key) return songData.original_key;
if (songData.key) return songData.key;
// Try to detect from lyrics
return detectKeyFromLyrics(songData.lyrics);
};
// Update song state when cached data changes
useEffect(() => {
if (cachedSong) {
setSong(cachedSong);
setEditForm(cachedSong);
// Extract and set the original key
const key = extractOriginalKey(cachedSong);
setOriginalKey(key);
setSelectedKey(key);
setLoading(false);
}
}, [cachedSong]);
// Scroll listener for floating nav
useEffect(() => {
const handleScroll = () => {
setShowNav(window.scrollY > 150);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Navigation between songs
const currentIndex = songs.findIndex((s) => s.id === id);
const prevSong = currentIndex > 0 ? songs[currentIndex - 1] : null;
const nextSong =
currentIndex < songs.length - 1 ? songs[currentIndex + 1] : null;
// Compute transposed lyrics
const transposedLyrics = useMemo(() => {
if (!song?.lyrics) return "";
return transposeLyricsText(song.lyrics, originalKey, selectedKey);
}, [song?.lyrics, originalKey, selectedKey]);
// Parse lyrics with chords for display
const parsedLyrics = useMemo(() => {
return parseLyricsWithChords(transposedLyrics);
}, [transposedLyrics]);
// Get diatonic chords for current key
const diatonicChords = useMemo(() => {
return getDiatonicChords(selectedKey);
}, [selectedKey]);
// Calculate semitone difference for display
const semitonesDiff = useMemo(() => {
return getSemitonesBetweenKeys(originalKey, selectedKey);
}, [originalKey, selectedKey]);
// Apply chords progression above lyrics
// This takes the diatonic chord progression and applies it over the lyrics
const lyricsWithAppliedChords = useMemo(() => {
if (!song?.lyrics || !showChords) return [];
const lyrics = transposedLyrics || song.lyrics;
// Use the parser to handle both [Chord] embedded format AND chords-above-lyrics format
const parsedLines = parseLyricsWithChords(lyrics);
return parsedLines.map((segments, lineIdx) => {
// Check if this is an empty line
if (
segments.length === 1 &&
!segments[0].chord &&
!segments[0].text.trim()
) {
return {
isHeader: false,
isEmpty: true,
text: "",
chordLine: null,
};
}
// Check if this is a section header
const lineText = segments.map((s) => s.text).join("");
const trimmedLine = lineText.trim();
const isHeader =
/^[\[\(]?(verse|chorus|bridge|pre-?chorus|intro|outro|interlude|hook|tag|ending|v\d|c\d|ch\d)[\d\s]*[\]\)]?:?$/i.test(
trimmedLine,
);
if (isHeader) {
return {
isHeader: true,
isEmpty: false,
text: lineText,
chordLine: null,
};
}
// Check if this line has any chords
const hasChords = segments.some((seg) => seg.chord !== null);
if (hasChords) {
// Line has chords - return segments for rendering
return {
isHeader: false,
isEmpty: false,
text: segments.map((s) => s.text).join(""),
segments: segments,
hasChords: true,
};
}
// Plain text line (no chords)
return {
isHeader: false,
isEmpty: false,
text: lineText,
chordLine: null,
hasChords: false,
};
});
}, [song?.lyrics, transposedLyrics, diatonicChords, showChords]);
// Handle key change
const handleKeyChange = (newKey) => {
setSelectedKey(newKey);
setShowKeySelector(false);
};
// Quick transpose up/down by semitone
const transposeUp = () => {
const majorKeys = ALL_KEYS.filter((k) => k.type === "major");
const currentIdx = majorKeys.findIndex((k) => k.value === selectedKey);
if (currentIdx >= 0) {
const nextIdx = (currentIdx + 1) % majorKeys.length;
setSelectedKey(majorKeys[nextIdx].value);
} else {
// For minor keys
const minorKeys = ALL_KEYS.filter((k) => k.type === "minor");
const minorIdx = minorKeys.findIndex((k) => k.value === selectedKey);
const nextIdx = (minorIdx + 1) % minorKeys.length;
setSelectedKey(minorKeys[nextIdx].value);
}
};
const transposeDown = () => {
const majorKeys = ALL_KEYS.filter((k) => k.type === "major");
const currentIdx = majorKeys.findIndex((k) => k.value === selectedKey);
if (currentIdx >= 0) {
const prevIdx = (currentIdx - 1 + majorKeys.length) % majorKeys.length;
setSelectedKey(majorKeys[prevIdx].value);
} else {
const minorKeys = ALL_KEYS.filter((k) => k.type === "minor");
const minorIdx = minorKeys.findIndex((k) => k.value === selectedKey);
const prevIdx = (minorIdx - 1 + minorKeys.length) % minorKeys.length;
setSelectedKey(minorKeys[prevIdx].value);
}
};
// Apply chords to lyrics and create embedded chord format
const applyChordsTolLyrics = () => {
if (!song?.lyrics) return song?.lyrics || "";
const lines = (song.lyrics || "").split("\n");
const chords = diatonicChords;
let chordIndex = 0;
const result = lines.map((line) => {
const trimmedLine = line.trim();
// Skip empty lines or section headers
if (
!trimmedLine ||
/^[\[\(]?(verse|chorus|bridge|pre-?chorus|intro|outro|interlude|hook|tag|ending|v\d|c\d|ch\d)[\d\s]*[\]\)]?:?$/i.test(
trimmedLine,
)
) {
return line;
}
// Skip if already has chords
if (/\[[A-Ga-g][#b]?/.test(line)) {
return line;
}
// Apply chords at word boundaries
const words = line.split(/\s+/);
const wordCount = words.length;
const chordsPerLine = Math.min(4, Math.max(1, Math.floor(wordCount / 3)));
const interval = Math.max(1, Math.floor(wordCount / chordsPerLine));
let chordedLine = "";
words.forEach((word, idx) => {
if (idx % interval === 0 && idx < wordCount - 1) {
chordedLine += `[${chords[chordIndex % chords.length]}]${word} `;
chordIndex++;
} else {
chordedLine += word + " ";
}
});
return chordedLine.trim();
});
return result.join("\n");
};
// Convert pasted chord-over-lyrics format to embedded [Chord] format
const convertChordsOverLyrics = (text) => {
if (!text) return { lyrics: "", detectedChords: [] };
const lines = text.split("\n");
const result = [];
const allChords = new Set();
// Chord pattern: line with mostly chords and spaces
const chordPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
console.log("🎵 Converting chords-over-lyrics format...");
for (let i = 0; i < lines.length; i++) {
const currentLine = lines[i];
const nextLine = lines[i + 1];
// Check if current line is a chord line (has chords, next line is lyrics)
const isChordLine =
currentLine &&
currentLine.trim().length > 0 &&
chordPattern.test(currentLine.trim()) &&
nextLine !== undefined;
if (isChordLine && nextLine) {
// Extract chords with their positions
const chords = [];
let match;
const chordRegex = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
while ((match = chordRegex.exec(currentLine)) !== null) {
chords.push({
chord: match[1],
position: match.index,
});
allChords.add(match[1]);
}
// Build lyric line with embedded chords
let embeddedLine = "";
let lastPos = 0;
chords.forEach(({ chord, position }) => {
// Get text from lyrics line up to chord position
const textUpToChord = nextLine.substring(lastPos, position);
embeddedLine += textUpToChord;
// Add chord in brackets
embeddedLine += `[${chord}]`;
lastPos = position;
});
// Add remaining lyrics
embeddedLine += nextLine.substring(lastPos);
result.push(embeddedLine);
// Skip the lyrics line since we processed it
i++;
} else if (!isChordLine) {
// Regular line (not a chord line)
result.push(currentLine);
}
// Skip standalone chord lines that don't have lyrics below
}
console.log("✅ Chord conversion complete!");
console.log("Detected chords:", Array.from(allChords));
return {
lyrics: result.join("\n"),
detectedChords: Array.from(allChords),
};
};
// Save applied chords to database
const handleApplyAndSaveChords = async () => {
setSaving(true);
try {
// Get current lyrics - could be from editForm or song
let lyricsToWork = isEditing ? editForm.lyrics || "" : song?.lyrics || "";
// Strip HTML if it's from Rich Text Editor
const tempDiv = document.createElement("div");
tempDiv.innerHTML = lyricsToWork;
lyricsToWork = tempDiv.textContent || tempDiv.innerText || lyricsToWork;
// Check if lyrics already have chords
const hasEmbeddedChords = /\[[A-Ga-g][#b]?/.test(lyricsToWork);
const chordOnlyPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
const lines = lyricsToWork.split("\n");
const hasChordAboveLyrics = lines.some(
(line, i) =>
line.trim().length > 0 &&
chordOnlyPattern.test(line.trim()) &&
lines[i + 1] !== undefined,
);
let lyricsToSave = lyricsToWork;
let appliedChords = [];
// If lyrics don't have chords, apply the diatonic chord progression
if (!hasEmbeddedChords && !hasChordAboveLyrics) {
console.log(
"📝 Applying diatonic chord progression to plain lyrics...",
);
lyricsToSave = applyChordsTolLyrics();
appliedChords = diatonicChords;
toast.success(`Applied ${selectedKey} chord progression to lyrics!`);
} else {
// Lyrics have chords - transpose them if key changed
console.log("🎵 Lyrics have chords, checking if key changed...");
if (originalKey !== selectedKey) {
console.log(`🎹 Transposing from ${originalKey} to ${selectedKey}`);
lyricsToSave = transposeLyricsText(
lyricsToWork,
originalKey,
selectedKey,
);
toast.success(`Transposed from ${originalKey} to ${selectedKey}!`);
} else {
lyricsToSave = lyricsToWork;
}
// Extract chords from the (possibly transposed) lyrics
const chordPattern =
/\[?([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)\]?/g;
const detectedChords = new Set();
let match;
while ((match = chordPattern.exec(lyricsToSave)) !== null) {
const chord = match[1];
if (chord.length <= 5) {
detectedChords.add(chord);
}
}
appliedChords = Array.from(detectedChords);
}
// Use the selected key
const songKey = selectedKey;
// Build update data
const updateData = {
...song,
...(isEditing ? editForm : {}),
lyrics: lyricsToSave,
chords:
appliedChords.length > 0
? appliedChords.join(", ")
: diatonicChords.join(", "),
key_chord: songKey,
};
console.log("💾 Saving song with:", updateData);
const res = await api.put(`/songs/${id}`, updateData, {
timeout: 30000, // Increase timeout to 30 seconds
});
if (res.data.success) {
// Update song with new data
const updatedSong = {
...updateData,
id: id,
};
// Update all state
setSong(updatedSong);
setOriginalKey(songKey);
setSelectedKey(songKey);
if (isEditing) {
setEditForm(updatedSong);
}
toast.success(
`Saved! Key: ${songKey}, ${appliedChords.length} chords applied`,
);
}
} catch (err) {
console.error("Failed to save chords:", err);
toast.error(
"Failed to save changes: " +
(err.response?.data?.message || err.message),
);
} finally {
setSaving(false);
}
};
// Save song edits
const handleSave = async () => {
setSaving(true);
try {
// Also update the key_chord field with selected key
const updateData = {
...editForm,
chords: diatonicChords.join(", "),
key_chord: selectedKey,
};
const res = await api.put(`/songs/${id}`, updateData);
if (res.data.success) {
setSong({
...editForm,
key_chord: selectedKey,
chords: diatonicChords.join(", "),
});
setOriginalKey(selectedKey);
setIsEditing(false);
toast.success("Song saved successfully!");
}
} catch (err) {
console.error("Failed to save:", err);
toast.error("Failed to save song");
} finally {
setSaving(false);
}
};
// Delete song
const handleDelete = async () => {
setShowDeleteModal(false);
try {
const res = await api.delete(`/songs/${id}`);
if (res.data.success) {
toast.success("Song deleted successfully!");
// Navigate to database page after successful deletion
setTimeout(() => navigate("/database"), 1000);
} else {
toast.error(
"Failed to delete song: " + (res.data.message || "Unknown error"),
);
}
} catch (err) {
console.error("Failed to delete song:", err);
toast.error(
"Failed to delete song: " +
(err.response?.data?.message || err.message),
);
}
};
// Click-to-place chord on a word
const handleWordClick = (lineIdx, wordIdx, word) => {
if (!chordEditMode || !selectedChordToPlace) return;
// Get current lyrics
const currentLyrics = editableLyrics || song?.lyrics || "";
const lines = currentLyrics.split("\n");
if (lineIdx >= lines.length) return;
const line = lines[lineIdx];
const words = line.split(/(\s+)/);
// Find the actual word position (accounting for spaces)
let actualWordIdx = 0;
let targetIdx = -1;
for (let i = 0; i < words.length; i++) {
if (words[i].trim()) {
if (actualWordIdx === wordIdx) {
targetIdx = i;
break;
}
actualWordIdx++;
}
}
if (targetIdx === -1) return;
// Remove any existing chord from this word
const cleanWord = words[targetIdx].replace(/^\[[^\]]+\]/, "");
// Add the new chord
words[targetIdx] = `[${selectedChordToPlace}]${cleanWord}`;
// Rebuild the line
lines[lineIdx] = words.join("");
// Update state
const newLyrics = lines.join("\n");
setEditableLyrics(newLyrics);
setEditForm({ ...editForm, lyrics: newLyrics });
};
// Remove chord from a word
const handleRemoveChord = (lineIdx, wordIdx) => {
const currentLyrics = editableLyrics || song?.lyrics || "";
const lines = currentLyrics.split("\n");
if (lineIdx >= lines.length) return;
const line = lines[lineIdx];
const words = line.split(/(\s+)/);
let actualWordIdx = 0;
let targetIdx = -1;
for (let i = 0; i < words.length; i++) {
if (words[i].trim()) {
if (actualWordIdx === wordIdx) {
targetIdx = i;
break;
}
actualWordIdx++;
}
}
if (targetIdx === -1) return;
// Remove chord from this word
words[targetIdx] = words[targetIdx].replace(/^\[[^\]]+\]/, "");
lines[lineIdx] = words.join("");
const newLyrics = lines.join("\n");
setEditableLyrics(newLyrics);
setEditForm({ ...editForm, lyrics: newLyrics });
};
// Initialize editable lyrics when entering edit mode
const enterEditMode = () => {
setIsEditing(true);
setEditableLyrics(song?.lyrics || "");
setEditForm({ ...song });
setChordEditMode(false);
setShowTextEditor(true); // Default to text editor
setShowRichTextEditor(false);
};
// Section header detection
const isSectionHeader = (text) => {
const trimmed = text.trim().toLowerCase();
return /^[\[\(]?(verse|chorus|bridge|pre-?chorus|intro|outro|interlude|hook|tag|ending|v\d|c\d|ch\d)[\d\s]*[\]\)]?:?$/i.test(
trimmed,
);
};
// Parse lyrics for click-to-place editor
const parsedEditableLyrics = useMemo(() => {
const lyrics = editableLyrics || song?.lyrics || "";
return lyrics.split("\n").map((line, lineIdx) => {
const words = [];
const regex = /(\[[^\]]+\])?(\S+)/g;
let match;
let wordIdx = 0;
while ((match = regex.exec(line)) !== null) {
const chord = match[1] ? match[1].slice(1, -1) : null;
const word = match[2];
words.push({ chord, word, wordIdx: wordIdx++ });
}
return {
lineIdx,
words,
text: line,
isHeader: isSectionHeader(line),
isEmpty: !line.trim(),
};
});
}, [editableLyrics, song?.lyrics]);
// Theme classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/70" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
const bgCard = isDark ? "bg-slate-800/80" : "bg-white";
const borderColor = isDark ? "border-white/10" : "border-gray-200";
if (loading) {
return (
<div
className={`min-h-screen flex items-center justify-center ${textMuted}`}
>
<div className="text-center">
<div className="w-12 h-12 border-2 border-t-cyan-500 border-cyan-500/20 rounded-full animate-spin mx-auto mb-4" />
<p>Loading song...</p>
</div>
</div>
);
}
if (!song) {
return (
<div
className={`min-h-screen flex items-center justify-center ${textMuted}`}
>
<div className="text-center">
<Music size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg mb-4">Song not found</p>
<button
onClick={() => navigate("/database")}
className="px-4 py-2 rounded-lg bg-cyan-500 text-white hover:bg-cyan-600 transition-colors"
>
Browse Songs
</button>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto pb-20" ref={contentRef}>
{/* Floating Navigation */}
<AnimatePresence>
{showNav && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={`fixed top-16 left-0 right-0 z-40 px-4 py-3 backdrop-blur-xl
${isDark ? "bg-slate-900/90 border-b border-white/10" : "bg-white/90 border-b border-gray-200"}`}
>
<div className="max-w-6xl mx-auto flex items-center justify-between">
<button
onClick={() => prevSong && navigate(`/song/${prevSong.id}`)}
disabled={!prevSong}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors
${prevSong ? (isDark ? "hover:bg-white/10 text-white" : "hover:bg-gray-100 text-gray-900") : "opacity-30 cursor-not-allowed"}`}
>
<ChevronLeft size={20} />
<span className="hidden sm:inline text-sm">Previous</span>
</button>
<div className="flex items-center gap-3">
<button
onClick={() =>
window.scrollTo({ top: 0, behavior: "smooth" })
}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<ArrowUp size={18} className={textSecondary} />
</button>
<span
className={`text-sm font-medium ${textPrimary} max-w-[200px] truncate`}
>
{song.title}
</span>
</div>
<button
onClick={() => nextSong && navigate(`/song/${nextSong.id}`)}
disabled={!nextSong}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors
${nextSong ? (isDark ? "hover:bg-white/10 text-white" : "hover:bg-gray-100 text-gray-900") : "opacity-30 cursor-not-allowed"}`}
>
<span className="hidden sm:inline text-sm">Next</span>
<ChevronRight size={20} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate("/database")}
className={`flex items-center gap-2 mb-4 ${textMuted} hover:text-cyan-500 transition-colors`}
>
<Home size={16} />
<span className="text-sm">Back to Database</span>
</button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<h1
className={`text-2xl sm:text-3xl font-bold ${textPrimary} mb-2`}
>
{song.title}
</h1>
<div
className={`flex flex-wrap items-center gap-3 ${textSecondary} text-sm`}
>
{(song.artist || song.singer) && (
<span className="flex items-center gap-1.5">
<Mic2 size={14} />
{song.artist || song.singer}
</span>
)}
{song.band && (
<span className={`${textMuted}`}> {song.band}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={() => setIsEditing(false)}
className={`px-4 py-2 rounded-lg transition-colors ${isDark ? "bg-white/10 hover:bg-white/20" : "bg-gray-100 hover:bg-gray-200"}`}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white flex items-center gap-2"
>
{saving ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save size={16} />
)}
Save
</button>
</>
) : (
<>
<button
onClick={() => setShowDeleteModal(true)}
className={`p-2.5 rounded-lg transition-colors bg-red-500/10 hover:bg-red-500/20 border border-red-500/30`}
title="Delete song"
>
<Trash2 size={18} className="text-red-500" />
</button>
<button
onClick={enterEditMode}
className={`p-2.5 rounded-lg transition-colors ${isDark ? "bg-white/10 hover:bg-white/20" : "bg-gray-100 hover:bg-gray-200"}`}
>
<Edit size={18} className={textSecondary} />
</button>
</>
)}
</div>
</div>
</div>
{/* Key Selector & Controls */}
<div className={`mb-6 p-4 rounded-xl border ${borderColor} ${bgCard}`}>
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Key Selection */}
<div className="flex flex-wrap items-center gap-3">
<span className={`text-sm font-medium ${textSecondary}`}>Key:</span>
{/* Original Key Display */}
<div
className={`px-3 py-1.5 rounded-lg text-sm ${isDark ? "bg-white/5" : "bg-gray-100"}`}
>
<span className={textMuted}>Original: </span>
<span className={`font-bold ${textPrimary}`}>{originalKey}</span>
</div>
{/* Key Selector Dropdown - ALL KEYS */}
<div className="relative">
<button
onClick={() => setShowKeySelector(!showKeySelector)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold transition-colors
${isDark ? "bg-amber-500/20 text-amber-400 hover:bg-amber-500/30" : "bg-amber-100 text-amber-700 hover:bg-amber-200"}`}
>
<span>{selectedKey}</span>
<ChevronDown
size={16}
className={
showKeySelector
? "rotate-180 transition-transform"
: "transition-transform"
}
/>
</button>
<AnimatePresence>
{showKeySelector && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`absolute top-full left-0 mt-2 w-56 max-h-80 overflow-y-auto rounded-xl shadow-xl border z-50
${isDark ? "bg-slate-800 border-white/10" : "bg-white border-gray-200"}`}
>
{/* Major Keys Section */}
<div
className={`px-3 py-2 text-xs font-bold uppercase tracking-wider sticky top-0 ${isDark ? "bg-slate-700 text-cyan-400" : "bg-gray-100 text-cyan-600"}`}
>
Major Keys
</div>
{ALL_KEYS.filter((k) => k.type === "major").map((key) => (
<button
key={key.value}
onClick={() => handleKeyChange(key.value)}
className={`w-full px-4 py-2 text-left text-sm transition-colors flex items-center justify-between
${
selectedKey === key.value
? isDark
? "bg-amber-500/20 text-amber-400"
: "bg-amber-100 text-amber-700"
: isDark
? "hover:bg-white/5 text-white"
: "hover:bg-gray-50 text-gray-900"
}`}
>
<span className="font-medium">{key.value}</span>
<span className={`text-xs ${textMuted}`}>
{key.label.replace(key.value + " ", "")}
</span>
{selectedKey === key.value && (
<Check size={14} className="text-emerald-500" />
)}
</button>
))}
{/* Minor Keys Section */}
<div
className={`px-3 py-2 text-xs font-bold uppercase tracking-wider sticky top-0 ${isDark ? "bg-slate-700 text-violet-400" : "bg-gray-100 text-violet-600"}`}
>
Minor Keys
</div>
{ALL_KEYS.filter((k) => k.type === "minor").map((key) => (
<button
key={key.value}
onClick={() => handleKeyChange(key.value)}
className={`w-full px-4 py-2 text-left text-sm transition-colors flex items-center justify-between
${
selectedKey === key.value
? isDark
? "bg-violet-500/20 text-violet-400"
: "bg-violet-100 text-violet-700"
: isDark
? "hover:bg-white/5 text-white"
: "hover:bg-gray-50 text-gray-900"
}`}
>
<span className="font-medium">{key.value}</span>
<span className={`text-xs ${textMuted}`}>
{key.label.replace(key.value + " ", "")}
</span>
{selectedKey === key.value && (
<Check size={14} className="text-emerald-500" />
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Quick Transpose Buttons */}
<div className="flex items-center gap-1">
<button
onClick={transposeDown}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
title="Transpose down"
>
<span className={`text-lg font-bold ${textSecondary}`}>-</span>
</button>
<button
onClick={transposeUp}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
title="Transpose up"
>
<span className={`text-lg font-bold ${textSecondary}`}>+</span>
</button>
</div>
{/* Semitones indicator */}
{semitonesDiff !== 0 && (
<span
className={`text-xs px-2 py-1 rounded-full ${isDark ? "bg-violet-500/20 text-violet-400" : "bg-violet-100 text-violet-700"}`}
>
{semitonesDiff > 0 ? `+${semitonesDiff}` : semitonesDiff}{" "}
semitones
</span>
)}
</div>
{/* Show Chords Toggle & Font Size Controls */}
<div className="flex items-center gap-3">
{/* Show/Hide Chords Button */}
<button
onClick={() => setShowChords(!showChords)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all
${
showChords
? isDark
? "bg-emerald-500/20 text-emerald-400 ring-2 ring-emerald-500/50"
: "bg-emerald-100 text-emerald-700 ring-2 ring-emerald-500/50"
: isDark
? "bg-white/10 text-white/60 hover:bg-white/20"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
<Music size={16} />
{showChords ? "Chords ON" : "Chords OFF"}
</button>
{/* Apply & Save Chords Button */}
<button
onClick={handleApplyAndSaveChords}
disabled={saving}
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all
${
isDark
? "bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30"
: "bg-cyan-100 text-cyan-700 hover:bg-cyan-200"
}`}
title="Apply chords to lyrics and save to database"
>
{saving ? (
<div className="w-4 h-4 border-2 border-current/30 border-t-current rounded-full animate-spin" />
) : (
<Save size={16} />
)}
Apply & Save
</button>
{/* Font Size Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => setFontSize((s) => Math.max(12, s - 2))}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<ZoomOut size={18} className={textSecondary} />
</button>
<span className={`text-sm ${textMuted} w-8 text-center`}>
{fontSize}
</span>
<button
onClick={() => setFontSize((s) => Math.min(32, s + 2))}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<ZoomIn size={18} className={textSecondary} />
</button>
</div>
</div>
</div>
{/* Diatonic Chord Progression for Selected Key */}
<div className={`mt-4 pt-4 border-t ${borderColor}`}>
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-xs font-medium uppercase tracking-wider ${textMuted}`}
>
Chords in {selectedKey}:
</span>
<div className="flex flex-wrap gap-1.5">
{diatonicChords.map((chord, idx) => (
<span
key={idx}
className={`px-2.5 py-1 rounded-lg text-sm font-bold cursor-pointer transition-all
${
isDark
? "bg-gradient-to-br from-amber-500/20 to-orange-500/20 text-amber-400 hover:from-amber-500/30 hover:to-orange-500/30"
: "bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700 hover:from-amber-200 hover:to-orange-200"
}`}
title={`Roman numeral: ${["I/i", "ii/ii°", "iii/III", "IV/iv", "V/v", "vi/VI", "vii°/VII"][idx]}`}
>
{chord}
</span>
))}
</div>
</div>
</div>
</div>
{/* Lyrics Display with Chords ABOVE */}
<div className={`rounded-xl border ${borderColor} ${bgCard} p-6 sm:p-8`}>
{isEditing ? (
/* Edit Mode with Chord Palette */
<div className="space-y-4">
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Title
</label>
<input
type="text"
value={editForm.title || ""}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white" : "bg-white border-gray-200 text-gray-900"}`}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Artist
</label>
<input
type="text"
value={editForm.artist || ""}
onChange={(e) =>
setEditForm({ ...editForm, artist: e.target.value })
}
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white" : "bg-white border-gray-200 text-gray-900"}`}
/>
</div>
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Original Key
</label>
<select
value={originalKey}
onChange={(e) => {
setOriginalKey(e.target.value);
setSelectedKey(e.target.value);
}}
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white" : "bg-white border-gray-200 text-gray-900"}`}
>
{ALL_KEYS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
</div>
</div>
{/* Chord Palette - Click to insert at cursor */}
<div
className={`p-4 rounded-lg border ${borderColor} ${isDark ? "bg-white/5" : "bg-gray-50"}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`text-sm font-medium ${textSecondary}`}>
Click chord to insert at cursor position:
</span>
</div>
{/* Diatonic Chords for Current Key */}
<div className="mb-3">
<span
className={`text-xs uppercase tracking-wider ${textMuted} mb-2 block`}
>
Key of {selectedKey} (Diatonic):
</span>
<div className="flex flex-wrap gap-1.5">
{diatonicChords.map((chord, idx) => (
<button
key={idx}
type="button"
onClick={() => {
const textarea =
document.getElementById("lyrics-textarea");
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = editForm.lyrics || "";
const newText =
text.slice(0, start) +
`[${chord}]` +
text.slice(end);
setEditForm({ ...editForm, lyrics: newText });
// Restore cursor position after state update
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(
start + chord.length + 2,
start + chord.length + 2,
);
}, 0);
}
}}
className={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all
${
isDark
? "bg-amber-500/20 text-amber-400 hover:bg-amber-500/30"
: "bg-amber-100 text-amber-700 hover:bg-amber-200"
}`}
>
{chord}
</button>
))}
</div>
</div>
{/* Common Chord Extensions */}
<div className="mb-3">
<span
className={`text-xs uppercase tracking-wider ${textMuted} mb-2 block`}
>
Extensions (add to root):
</span>
<div className="flex flex-wrap gap-1.5">
{[
"7",
"maj7",
"m7",
"dim",
"aug",
"sus2",
"sus4",
"add9",
"9",
"11",
"13",
].map((ext) => (
<button
key={ext}
type="button"
onClick={() => {
const textarea =
document.getElementById("lyrics-textarea");
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = editForm.lyrics || "";
// Check if we're inside a chord bracket
const beforeCursor = text.slice(0, start);
const lastOpen = beforeCursor.lastIndexOf("[");
const lastClose = beforeCursor.lastIndexOf("]");
if (lastOpen > lastClose) {
// Inside a chord, append extension
const newText =
text.slice(0, start) + ext + text.slice(end);
setEditForm({ ...editForm, lyrics: newText });
} else {
// Not in chord, insert with selected key root
const root = selectedKey.replace(/m$/, "");
const newText =
text.slice(0, start) +
`[${root}${ext}]` +
text.slice(end);
setEditForm({ ...editForm, lyrics: newText });
}
setTimeout(() => textarea.focus(), 0);
}
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all
${
isDark
? "bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30"
: "bg-cyan-100 text-cyan-700 hover:bg-cyan-200"
}`}
>
{ext}
</button>
))}
</div>
</div>
{/* All Root Notes */}
<div>
<span
className={`text-xs uppercase tracking-wider ${textMuted} mb-2 block`}
>
All Roots:
</span>
<div className="flex flex-wrap gap-1">
{[
"C",
"C#",
"Db",
"D",
"D#",
"Eb",
"E",
"F",
"F#",
"Gb",
"G",
"G#",
"Ab",
"A",
"A#",
"Bb",
"B",
].map((root) => (
<button
key={root}
type="button"
onClick={() => {
const textarea =
document.getElementById("lyrics-textarea");
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = editForm.lyrics || "";
const newText =
text.slice(0, start) +
`[${root}]` +
text.slice(end);
setEditForm({ ...editForm, lyrics: newText });
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(
start + root.length + 2,
start + root.length + 2,
);
}, 0);
}
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all
${
isDark
? "bg-white/10 text-white hover:bg-white/20"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{root}
</button>
))}
{/* Minor versions */}
{["Cm", "Dm", "Em", "Fm", "Gm", "Am", "Bm"].map((root) => (
<button
key={root}
type="button"
onClick={() => {
const textarea =
document.getElementById("lyrics-textarea");
if (textarea) {
const start = textarea.selectionStart;
const text = editForm.lyrics || "";
const newText =
text.slice(0, start) +
`[${root}]` +
text.slice(start);
setEditForm({ ...editForm, lyrics: newText });
setTimeout(() => textarea.focus(), 0);
}
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all
${
isDark
? "bg-violet-500/20 text-violet-400 hover:bg-violet-500/30"
: "bg-violet-100 text-violet-700 hover:bg-violet-200"
}`}
>
{root}
</button>
))}
</div>
</div>
</div>
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Lyrics
</label>
{/* Rich Text Editor Only - Same as Create Page */}
<LyricsRichTextEditor
content={editForm.lyrics || ""}
onChange={(lyrics) => setEditForm({ ...editForm, lyrics })}
placeholder="Paste lyrics with chords on line above - they'll be auto-detected!"
/>
</div>
</div>
) : (
/* Display Mode - Chords ABOVE lyrics */
<div className="font-mono" style={{ fontSize: `${fontSize}px` }}>
{lyricsWithAppliedChords.map((line, lineIdx) => {
// Section header
if (line.isHeader) {
return (
<div key={lineIdx} className="mt-8 mb-3">
<span
className={`font-bold uppercase text-sm tracking-wider ${isDark ? "text-violet-400" : "text-violet-600"}`}
>
{line.text}
</span>
</div>
);
}
// Empty line
if (line.isEmpty) {
return <div key={lineIdx} className="h-6" />;
}
return (
<div key={lineIdx} className="mb-3">
{/* CHORD LINE - displayed ABOVE lyrics when showChords is ON */}
{showChords && line.chordLine && (
<div
style={{
fontSize: `${Math.max(14, fontSize)}px`,
fontWeight: "bold",
color: isDark ? "#fbbf24" : "#d97706",
letterSpacing: "0.02em",
whiteSpace: "pre",
fontFamily: "monospace",
marginBottom: "2px",
}}
>
{line.chordLine}
</div>
)}
{/* If line has embedded chord segments, render those */}
{showChords && line.segments && (
<div
style={{
fontSize: `${Math.max(14, fontSize)}px`,
fontWeight: "bold",
color: isDark ? "#fbbf24" : "#d97706",
whiteSpace: "pre",
fontFamily: "monospace",
marginBottom: "2px",
}}
>
{line.segments.map((seg, i) => (
<span
key={i}
style={{
display: "inline-block",
minWidth: `${seg.text.length}ch`,
}}
>
{seg.chord || ""}
</span>
))}
</div>
)}
{/* LYRICS LINE - always shown */}
<div
className={textPrimary}
style={{
lineHeight: "1.4",
whiteSpace: "pre-wrap",
}}
>
{line.segments
? line.segments.map((seg, i) => (
<span key={i}>{seg.text}</span>
))
: line.text}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Song Navigation Footer */}
<div className={`mt-6 p-4 rounded-xl border ${borderColor} ${bgCard}`}>
<div className="flex items-center justify-between">
<button
onClick={() => prevSong && navigate(`/song/${prevSong.id}`)}
disabled={!prevSong}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors
${
prevSong
? isDark
? "bg-white/10 hover:bg-white/20 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-900"
: "opacity-30 cursor-not-allowed"
}`}
>
<ChevronLeft size={20} />
<div className="text-left hidden sm:block">
<div className="text-xs opacity-60">Previous</div>
<div className="text-sm font-medium truncate max-w-[150px]">
{prevSong?.title || "—"}
</div>
</div>
</button>
<div className={`text-center ${textMuted} text-sm`}>
{currentIndex + 1} of {songs.length}
</div>
<button
onClick={() => nextSong && navigate(`/song/${nextSong.id}`)}
disabled={!nextSong}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors
${
nextSong
? isDark
? "bg-white/10 hover:bg-white/20 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-900"
: "opacity-30 cursor-not-allowed"
}`}
>
<div className="text-right hidden sm:block">
<div className="text-xs opacity-60">Next</div>
<div className="text-sm font-medium truncate max-w-[150px]">
{nextSong?.title || "—"}
</div>
</div>
<ChevronRight size={20} />
</button>
</div>
</div>
{/* Click outside to close key selector */}
{showKeySelector && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowKeySelector(false)}
/>
)}
{/* Delete Confirmation Modal */}
<AnimatePresence>
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className={`max-w-md w-full rounded-2xl p-6 shadow-2xl ${isDark ? "bg-gray-800" : "bg-white"}`}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 size={24} className="text-red-500" />
</div>
<div>
<h3
className={`text-xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}
>
Delete Song
</h3>
<p className={isDark ? "text-white/60" : "text-gray-600"}>
This action cannot be undone
</p>
</div>
</div>
<p
className={`mb-6 ${isDark ? "text-white/80" : "text-gray-700"}`}
>
Are you sure you want to delete <strong>"{song?.title}"</strong>
?
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteModal(false)}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
isDark
? "bg-white/10 hover:bg-white/20 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-700"
}`}
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors"
>
Delete
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}