1542 lines
55 KiB
React
1542 lines
55 KiB
React
|
|
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>
|
||
|
|
);
|
||
|
|
}
|