Adds ability to rename favorite

This commit is contained in:
2025-03-05 00:10:23 -08:00
parent 0a86dbed49
commit 2b533cf1db
6 changed files with 187 additions and 20 deletions

View File

@@ -106,6 +106,24 @@ export class FavoritesStore {
await this.saveFavorites(); await this.saveFavorites();
} }
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
console.log(`Updating title for favorite ${filename} to "${title}"`);
const index = this.favorites.findIndex(f => f.filename === filename);
if (index !== -1) {
// Create metadata object if it doesn't exist
if (!this.favorites[index].metadata) {
this.favorites[index].metadata = {};
}
// Update the title in metadata
this.favorites[index].metadata!.title = title;
await this.saveFavorites();
} else {
throw new Error(`Favorite with filename ${filename} not found`);
}
}
async clearFavorites(): Promise<void> { async clearFavorites(): Promise<void> {
this.favorites = []; this.favorites = [];
await this.saveFavorites(); await this.saveFavorites();

View File

@@ -186,6 +186,10 @@ export class MediaPlayer {
return this.favoritesStore.clearFavorites(); return this.favoritesStore.clearFavorites();
} }
public async updateFavoriteTitle(filename: string, title: string) {
return this.favoritesStore.updateFavoriteTitle(filename, title);
}
private async loadFile(url: string, mode: string) { private async loadFile(url: string, mode: string) {
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode])); this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
@@ -257,7 +261,7 @@ export class MediaPlayer {
} }
private handleEvent(event: string, data: any) { private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2)); console.log("Event [" + event + "]: ", data);
// Notify all subscribers // Notify all subscribers
this.eventSubscribers.forEach(subscriber => { this.eventSubscribers.forEach(subscriber => {

View File

@@ -158,6 +158,22 @@ apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));
})); }));
apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => {
const { filename } = req.params as { filename: string };
const { title } = req.body as { title: string };
if (!title) {
res.status(400).send(JSON.stringify({
success: false,
error: "Title is required"
}));
return;
}
await mediaPlayer.updateFavoriteTitle(filename, title);
res.send(JSON.stringify({ success: true }));
}));
// Serve static files for React app (after building) // Serve static files for React app (after building)
app.use(express.static(path.join(__dirname, "../dist/frontend"))); app.use(express.static(path.join(__dirname, "../dist/frontend")));

View File

@@ -158,5 +158,15 @@ export const API = {
async clearFavorites(): Promise<void> { async clearFavorites(): Promise<void> {
await fetch('/api/favorites', { method: 'DELETE' }); await fetch('/api/favorites', { method: 'DELETE' });
},
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
await fetch(`/api/favorites/${encodeURIComponent(filename)}/title`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
} }
}; };

View File

@@ -2,9 +2,10 @@ import React, { useState, useEffect, useCallback, ReactNode } from 'react';
import SongTable from './SongTable'; import SongTable from './SongTable';
import NowPlaying from './NowPlaying'; import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel'; import AddSongPanel from './AddSongPanel';
import RenameFavoriteModal from './RenameFavoriteModal';
import { TabView, Tab } from './TabView'; import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player'; import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus } from 'react-icons/fa'; import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket'; import useWebSocket from 'react-use-websocket';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -88,6 +89,8 @@ const App: React.FC = () => {
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]); const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
const [favorites, setFavorites] = useState<PlaylistItem[]>([]); const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist); const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
const fetchPlaylist = useCallback(async () => { const fetchPlaylist = useCallback(async () => {
const playlist = await API.getPlaylist(); const playlist = await API.getPlaylist();
@@ -256,22 +259,35 @@ const App: React.FC = () => {
const favoritesAuxControlProvider = (song: PlaylistItem) => { const favoritesAuxControlProvider = (song: PlaylistItem) => {
const isInPlaylist = playlist.some(p => p.filename === song.filename); const isInPlaylist = playlist.some(p => p.filename === song.filename);
return ( return (
<AuxButton <div className="flex">
className={classNames({ <AuxButton
"text-white/40": isInPlaylist, className="text-white hover:text-white"
"text-white": !isInPlaylist, title="Rename favorite"
})} onClick={() => {
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"} setFavoriteToRename(song);
onClick={() => { setIsRenameModalOpen(true);
if (isInPlaylist) { }}
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename)); >
} else { <FaEdit />
API.addToPlaylist(song.filename); </AuxButton>
}
}} <AuxButton
> className={classNames({
<FaPlus /> "text-white/40": isInPlaylist,
</AuxButton> "text-white": !isInPlaylist,
})}
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
onClick={() => {
if (isInPlaylist) {
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
} else {
API.addToPlaylist(song.filename);
}
}}
>
<FaPlus />
</AuxButton>
</div>
); );
}; };
@@ -312,6 +328,12 @@ const App: React.FC = () => {
</TabView> </TabView>
<AddSongPanel onAddURL={handleAddURL} /> <AddSongPanel onAddURL={handleAddURL} />
<RenameFavoriteModal
isOpen={isRenameModalOpen}
onClose={() => setIsRenameModalOpen(false)}
favorite={favoriteToRename}
/>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,97 @@
import React, { useState, KeyboardEvent, useEffect } from 'react';
import { FaTimes, FaCheck } from 'react-icons/fa';
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
interface RenameFavoriteModalProps {
isOpen: boolean;
onClose: () => void;
favorite: PlaylistItem | null;
}
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (favorite) {
setTitle(getDisplayTitle(favorite));
}
}, [favorite]);
const handleSave = async () => {
if (!favorite || !title.trim()) return;
setIsSaving(true);
try {
await API.updateFavoriteTitle(favorite.filename, title);
onClose();
} catch (error) {
console.error('Failed to rename favorite:', error);
} finally {
setIsSaving(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen || !favorite) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
<button onClick={onClose} className="text-white/60 hover:text-white">
<FaTimes size={24} />
</button>
</div>
<div className="mb-4">
<label className="text-white/80 block mb-2">Original filename:</label>
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
{favorite.filename}
</div>
<label className="text-white/80 block mb-2">Title:</label>
<input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter new title..."
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving || !title.trim()}
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
>
<FaCheck size={16} />
Save
</button>
</div>
</div>
</div>
);
};
export default RenameFavoriteModal;