Commit 627f068e authored by Gemini's avatar Gemini Committed by Jakob Moser
Browse files

feat(FavoritesView): Improve UX for filtered favorites and add clear search link

This commit enhances the user experience in the favorites tab when a search filter is active:

- SongList.mjs:
    - Displays a specific message if a search term filters the list of favorites, indicating total vs. shown favorites.
    - Includes a link/button within this message to clear the current search term.
    - Receives 'onSearchTermChange' callback from AppLayout to trigger search clearing.
- AppLayout (app.mjs):
    - Passes 'onSearchTermChange' callback to SongList.
- style.css:
    - Added styling for the 'clear-search-link' to make it visually distinct and interactive.
parent 1b807bd5
Loading
Loading
Loading
Loading
+99 −67
Original line number Diff line number Diff line
// frontend/SongList.mjs
import SongItem from './SongItem.mjs';
// mockData.mjs wird nicht mehr für die Song-Daten benötigt

const FAVORITES_STORAGE_KEY = 'songAppFavorites';
const API_URL_SONGS = '/api/songs'; // URL zu deinem Flask-Backend
const API_URL_SONGS = '/api/songs';

const SongList = {
    allSongs: [], // Hier werden alle Songs vom Backend/Server gespeichert
    allSongs: [],
    isLoading: true,
    // Der 'searchTerm' wird als Attribut (vnode.attrs.searchTerm) von AppLayout übergeben

    _loadFavoriteIdsFromStorage: function() {
        try {
@@ -33,28 +31,24 @@ const SongList = {
    loadAllSongsFromServer: function() {
        this.isLoading = true;
        const favoriteIdsFromStorage = this._loadFavoriteIdsFromStorage();

        m.request({
            method: "GET",
            url: API_URL_SONGS,
        })
        .then(dataFromServer => {
            // Stelle sicher, dass dataFromServer ein Array ist. Flask gibt manchmal ein Objekt zurück.
            // Passe dies ggf. an die Struktur deiner Flask-Antwort an.
            const songsArray = Array.isArray(dataFromServer) ? dataFromServer : [];
            
            this.allSongs = songsArray.map(songFromServer => ({
                ...songFromServer, // Enthält id, title, artist, genre, year vom Server
                ...songFromServer,
                isFavorite: favoriteIdsFromStorage.has(songFromServer.id)
            }));
            this.isLoading = false;
            m.redraw(); // Stelle sicher, dass die UI nach dem Laden der Daten aktualisiert wird
            m.redraw();
        })
        .catch(error => {
            console.error("Fehler beim Laden der Songs vom Server:", error);
            this.allSongs = []; // Leere Liste bei Fehler, um Inkonsistenzen zu vermeiden
            this.allSongs = [];
            this.isLoading = false;
            m.redraw(); // Stelle sicher, dass die UI auch im Fehlerfall aktualisiert wird
            m.redraw();
        });
    },

@@ -62,78 +56,90 @@ const SongList = {
        this.loadAllSongsFromServer();
    },

    // onupdate ist nicht zwingend für die Suchfunktion hier nötig,
    // da getProcessedSongsForView bei jedem Render mit dem aktuellen searchTerm aufgerufen wird.

    getProcessedSongsForView: function(viewFilter, searchTerm) {
        let songsToProcess = [];
        let viewSpecificMessage = null;
        let initialSongsForView = []; // Songs nach initialer Tab-Filterung
        let messageFromTabFilter = null; // Nachricht, die sich nur aus dem Tab-Filter ergibt

        if (!this.isLoading && this.allSongs && this.allSongs.length > 0) {
            // 1. Basierend auf dem Tab (Alle, Favoriten, Beliebt) filtern
        // 1. Songs basierend auf dem aktiven Tab vorfiltern
        switch (viewFilter) {
            case 'favorites':
                    songsToProcess = this.allSongs.filter(song => song.isFavorite);
                    if (songsToProcess.length === 0) {
                        viewSpecificMessage = "Du hast noch keine Favoriten markiert.";
                initialSongsForView = this.allSongs.filter(song => song.isFavorite);
                if (initialSongsForView.length === 0) {
                    messageFromTabFilter = "Du hast noch keine Favoriten markiert.";
                }
                break;
            case 'popular':
                    songsToProcess = []; // Platzhalter für "Beliebt"-Funktion
                    viewSpecificMessage = "Die Liste der beliebten Songs ist bald verfügbar!";
                initialSongsForView = [];
                messageFromTabFilter = "Die Liste der beliebten Songs ist bald verfügbar!";
                break;
            case 'all':
            default:
                    songsToProcess = this.allSongs;
                    if (songsToProcess.length === 0) {
                        // Sollte nicht passieren, wenn isLoading false ist und allSongs > 0 war,
                        // aber als Fallback für leere Bibliothek nach erfolgreichem Laden.
                        viewSpecificMessage = "Keine Songs in der Bibliothek.";
                initialSongsForView = this.allSongs;
                if (initialSongsForView.length === 0 && !this.isLoading) {
                    messageFromTabFilter = "Keine Songs in der Bibliothek.";
                }
                break;
        }

            // 2. Basierend auf dem Suchbegriff filtern (TITLE und ARTIST)
        let songsToProcess = initialSongsForView.slice(); // Kopie für weitere Verarbeitung
        let finalMessage = messageFromTabFilter; // Standardnachricht ist die vom Tab-Filter

        // 2. Suchbegriff anwenden, falls vorhanden
        if (searchTerm && searchTerm.trim() !== '') {
            const lowerSearchTerm = searchTerm.trim().toLowerCase();
                // Filtere die bereits nach Tab vorselektierte Liste weiter
                songsToProcess = songsToProcess.filter(song =>
            const searchedSongs = initialSongsForView.filter(song => // Wichtig: auf initialSongsForView filtern
                (song.title && song.title.toLowerCase().includes(lowerSearchTerm)) ||
                (song.artist && song.artist.toLowerCase().includes(lowerSearchTerm))
            );
                // Nachricht anpassen, wenn durch Suche nichts gefunden wurde,
                // aber nur wenn nicht schon eine spezifischere Nachricht (z.B. "keine Favoriten") da ist.
                if (songsToProcess.length === 0 && !viewSpecificMessage) {
                     viewSpecificMessage = "Keine Songs für deine Suche gefunden.";

            // Spezielle Logik für Nachrichten im Favoriten-Tab bei aktiver Suche
            if (viewFilter === 'favorites') {
                const totalFavoritesInView = initialSongsForView.length; // Anzahl Favoriten *bevor* Suchfilter angewendet wurde
                if (totalFavoritesInView > 0 && searchedSongs.length < totalFavoritesInView) {
                    if (searchedSongs.length > 0) {
                        // Suche aktiv, einige Favoriten sind sichtbar, aber nicht alle
                        finalMessage = {
                            type: 'filtered_favorites',
                            total: totalFavoritesInView,
                            showing: searchedSongs.length
                        };
                    } else {
                        // Suche aktiv, aber KEINE Favoriten passen zur Suche (obwohl es welche gab)
                        finalMessage = "Deine Suche ergab keine Treffer unter deinen Favoriten.";
                    }
                }

        } else if (!this.isLoading && (!this.allSongs || this.allSongs.length === 0)) {
             // Fall, dass Laden abgeschlossen, aber keine Songs vorhanden sind
             viewSpecificMessage = "Keine Songs geladen oder die Bibliothek ist leer.";
                // Wenn totalFavoritesInView == 0, ist finalMessage bereits "Du hast noch keine Favoriten..."
                // Wenn searchedSongs.length == totalFavoritesInView, hat die Suche nichts ausgeblendet, keine spezielle Nachricht nötig.
            } else if (searchedSongs.length === 0 && initialSongsForView.length > 0) {
                // Suche auf 'Alle' oder 'Beliebt' ergab 0 Treffer, obwohl Songs zum Durchsuchen da waren
                finalMessage = "Keine Songs für deine Suche gefunden.";
            }
            songsToProcess = searchedSongs; // Ergebnis der Suche wird zur Liste der anzuzeigenden Songs
        }
        
        // Gruppieren und Sortieren der (gefilterten) songsToProcess
        // Gruppieren und Sortieren
        const groups = {};
        songsToProcess.forEach(song => {
            groups[song.artist] = groups[song.artist] || [];
            groups[song.artist].push(song);
        });

        const sortedArtistNames = Object.keys(groups).sort((a, b) => a.localeCompare(b));
        const finalGroupedSongs = {};
        sortedArtistNames.forEach(artist => {
            finalGroupedSongs[artist] = groups[artist].sort((a, b) => a.title.localeCompare(b.title));
        });

        return { groupedSongs: finalGroupedSongs, message: viewSpecificMessage, hasContent: songsToProcess.length > 0 };
        return {
            groupedSongs: finalGroupedSongs,
            message: finalMessage, // Kann String oder Objekt sein
            hasContent: songsToProcess.length > 0
        };
    },

    toggleFavorite: function(songId) {
        const song = this.allSongs.find(s => s.id === songId);
        if (song) {
            song.isFavorite = !song.isFavorite;

            const currentFavoriteIds = new Set();
            this.allSongs.forEach(s => {
                if (s.isFavorite) {
@@ -141,12 +147,11 @@ const SongList = {
                }
            });
            this._saveFavoriteIdsToStorage(currentFavoriteIds);
            // m.redraw() wird automatisch durch den Event-Handler ausgelöst
        }
    },

    view: function(vnode) {
        const { currentView, searchTerm } = vnode.attrs;
        const { currentView, searchTerm, onSearchTermChange } = vnode.attrs; // onSearchTermChange hier empfangen

        if (this.isLoading && (!this.allSongs || this.allSongs.length === 0)) {
            return m("div.song-list-outer-container", m("div.loading-placeholder", "Lade Songs vom Server..."));
@@ -155,12 +160,39 @@ const SongList = {
        const { groupedSongs, message, hasContent } = this.getProcessedSongsForView(currentView, searchTerm);
        const artists = Object.keys(groupedSongs);

        if (message && !hasContent) {
        // Spezielle Nachricht für gefilterte Favoriten
        if (message && typeof message === 'object' && message.type === 'filtered_favorites') {
            return m("div.song-list-outer-container",
                m("p.view-message", [
                    `Deine Suche zeigt gerade ${message.showing} von deinen insgesamt ${message.total} Favoriten. `,
                    `Suchst du etwas Bestimmtes oder möchtest du `,
                    m("a.clear-search-link", {
                        onclick: (e) => {
                            e.preventDefault(); // Verhindert Standard-Linkverhalten
                            if (onSearchTermChange) {
                                onSearchTermChange(''); // Rufe Callback auf, um Suche zu leeren
                            }
                        },
                        href: "#", // Für Link-Semantik und Tastaturfokus
                        role: "button", // Für Barrierefreiheit
                        tabindex: 0,    // Macht es fokussierbar
                        onkeypress: (e) => { // Für Tastaturbedienung (Enter/Space)
                            if ((e.key === 'Enter' || e.key === ' ') && onSearchTermChange) {
                                e.preventDefault();
                                onSearchTermChange('');
                            }
                        }
                    }, `alle ${message.total} Favoriten anzeigen`),
                    "?"
                ])
            );
        } else if (message && typeof message === 'string' && !hasContent) {
            // Standardnachrichten (String), wenn keine Songs angezeigt werden
            return m("div.song-list-outer-container", m("p.view-message", message));
        }
        
        if (artists.length === 0 && !message) { // Fall: Keine Künstlergruppen, aber auch keine spezielle Nachricht
             return m("div.song-list-outer-container", m("p.view-message", "Keine Songs entsprechen den Kriterien."));
        if (artists.length === 0 && !message) {
             return m("div.song-list-outer-container", m("p.view-message", "Keine Songs entsprechen den aktuellen Kriterien."));
        }

        return m("div.song-list-outer-container",
+18 −12
Original line number Diff line number Diff line
@@ -3,41 +3,47 @@ import SearchBar from './SearchBar.mjs';
import TabBar from './TabBar.mjs';
import SongList from './SongList.mjs';

// Die Haupt-Layout-Komponente der Anwendung.
const AppLayout = {
    currentView: 'all', // Mögliche Werte: 'all', 'favorites', 'popular'
    searchTerm: '',     // Neuer State für den Suchbegriff
    currentView: 'all', // Aktuell ausgewählter Tab (z.B. 'all', 'favorites', 'popular')
    searchTerm: '',     // Aktueller Suchbegriff aus der Suchleiste

    // Wird aufgerufen, wenn der Benutzer einen anderen Tab auswählt.
    onViewChange: function(view) {
        this.currentView = view;
        // m.redraw() wird von Mithril automatisch nach Event-Handlern aufgerufen
        this.currentView = view; // Aktualisiere den Zustand für den aktuellen Tab.
    },

    onSearchTermChange: function(term) { // Neue Methode zum Aktualisieren des Suchbegriffs
        this.searchTerm = term;
        // m.redraw() wird von Mithril automatisch nach Event-Handlern aufgerufen
    // Wird aufgerufen, wenn sich der Text in der Suchleiste ändert oder zurückgesetzt wird.
    onSearchTermChange: function(term) {
        this.searchTerm = term; // Aktualisiere den Zustand für den Suchbegriff.
    },

    // Die View-Methode, die das Layout der gesamten Anwendung rendert.
    view: function() {
        return m("div.app-layout", [
            m(SearchBar, {
                searchTerm: this.searchTerm, // Übergib aktuellen Suchbegriff
                onSearchTermChange: this.onSearchTermChange.bind(this) // Übergib Callback
                searchTerm: this.searchTerm,
                onSearchTermChange: this.onSearchTermChange.bind(this)
            }),
            m(TabBar, {
                currentView: this.currentView,
                onViewChange: this.onViewChange.bind(this)
            }),
            // Rendere die Song-Liste-Komponente.
            // Übergib den aktuellen Tab, den Suchbegriff UND den Callback zum Ändern des Suchbegriffs.
            m(SongList, {
                currentView: this.currentView,
                searchTerm: this.searchTerm // Übergib aktuellen Suchbegriff an SongList
                searchTerm: this.searchTerm,
                onSearchTermChange: this.onSearchTermChange.bind(this) // NEU: Callback hier übergeben
            })
        ]);
    }
};

// Finde das Root-Element im HTML, in das die Mithril-Anwendung gemountet werden soll.
const appRoot = document.getElementById("app");
if (appRoot) {
    m.mount(appRoot, AppLayout);
} else {
    console.error("Root-Element #app nicht gefunden!");
    console.error("Root-Element #app nicht gefunden! Die Anwendung kann nicht gestartet werden.");
}
+40 −6
Original line number Diff line number Diff line
@@ -91,14 +91,36 @@ body, html {

/* Song-Liste Container */
.song-list-outer-container {
    /* padding-top: calc(60px + 48px + 10px); /* Höhe Suchleiste + Höhe TabBar + kleiner Abstand */
    padding-top: calc(60px + 48px); /* Höhe Suchleiste + Höhe TabBar */
    padding-bottom: 20px;
}

/* Link zum Zurücksetzen der Suche in der .view-message */
.view-message a.clear-search-link,
.view-message .clear-search-link { /* Zweite Regel für den Fall, dass Mithril das 'a' nicht direkt rendert */
    color: #3F51B5; /* Material Indigo, passend zum Header */
    text-decoration: underline;
    cursor: pointer;
    font-weight: 500; /* Etwas hervorheben */
    background: none; /* Sicherstellen, dass keine Button-Styles übernommen werden */
    border: none;
    padding: 0;
    margin: 0;
    display: inline; /* Damit es sich wie ein normaler Link im Text verhält */
}

.view-message a.clear-search-link:hover,
.view-message a.clear-search-link:focus,
.view-message .clear-search-link:hover,
.view-message .clear-search-link:focus {
    color: #303F9F; /* Dunkleres Indigo für Hover/Focus */
    text-decoration: none; /* Optional: Unterstreichung bei Hover entfernen */
}


/* Restliche Styles für Artist-Group, Song-Item etc. bleiben wie zuvor */
.artist-group {
    margin: 12px 8px;
    margin: 12px 8px; /* Deine Anpassung */
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
@@ -106,7 +128,7 @@ body, html {
}

.artist-name {
    margin: 0;
    margin: 0; /* Deine Anpassung */
    padding: 12px 16px;
    background-color: #E8EAF6;
    color: #303F9F;
@@ -156,8 +178,8 @@ body, html {
}

.song-actions {
    margin-left: auto; /* Pusht das Icon nach rechts, falls song-info nicht flex-grow hätte */
    flex-shrink: 0; /* Verhindert, dass das Icon schrumpft */
    margin-left: auto; 
    flex-shrink: 0; 
}

.heart-icon {
@@ -175,5 +197,17 @@ body, html {

.heart-icon.is-favorite {
    color: #FF4081;
    font-variation-settings: 'FILL' 1;
    /* Die font-variation-settings werden über die globale .material-symbols-outlined gesteuert,
       oder spezifischer, falls du die CSS-Optimierung für Icons nicht übernommen hattest.
       Wenn das Füllen nicht klappt, stelle sicher, dass der Google Font Link die FILL-Achse enthält
       und die .material-symbols-outlined Regel 'FILL' 0 und .is-favorite 'FILL' 1 setzt. */
    font-variation-settings: 'FILL' 1; /* Falls du die explizite Regel hier bevorzugst/benötigst */
}

.material-symbols-outlined {
  font-variation-settings: /* Diese Regel ist wichtig, wenn du sie global setzen willst */
  'FILL' 0,
  'wght' 400,
  'GRAD' 0,
  'opsz' 24;
}