Commit 4917298a authored by Gemini's avatar Gemini Committed by Jakob Moser
Browse files

feat(Frontend): Integrate backend data and implement client-side search

This commit refactors the frontend to:
- Fetch song data from the '/api/songs' backend endpoint instead of using mock data.
- Implement client-side search functionality:
    - AppLayout now manages 'searchTerm' state.
    - SearchBar is a controlled component, passing search terms to AppLayout.
    - SongList receives 'searchTerm' and filters songs by title and artist in addition to the active tab filter.
- Update SongList to handle loading states and display messages accordingly.
parent 3238afb2
Loading
Loading
Loading
Loading
+13 −7
Original line number Diff line number Diff line
// SearchBar.mjs
// frontend/SearchBar.mjs
const SearchBar = {
    view: function() {
    view: function(vnode) {
        // Empfange den aktuellen Suchbegriff und den Callback als Attribute
        const { searchTerm, onSearchTermChange } = vnode.attrs;

        return m("div.search-bar-container", [
            m("input[type=search].search-input", { // type=search für bessere mobile Tastaturen
            m("input[type=search].search-input", {
                placeholder: "Songs oder Interpreten suchen...",
                value: searchTerm, // Binde den Wert des Input-Feldes an den searchTerm-State
                oninput: (e) => {
                    // Hier kommt später deine Suchlogik rein.
                    // console.log("Suchbegriff:", e.target.value);
                    // Für jetzt: Mache nichts.
                }
                    onSearchTermChange(e.target.value); // Rufe den Callback bei jeder Eingabe auf
                },
                // Optional: onsearch für explizite Suchaktionen (z.B. bei Enter oder Klick auf X)
                // onsearch: (e) => { 
                //    if (e.target.value === '') { onSearchTermChange(''); }
                // }
            })
        ]);
    }
+71 −47
Original line number Diff line number Diff line
// frontend/SongList.mjs
import SongItem from './SongItem.mjs';
import initialSongs from './mockData.mjs';
// mockData.mjs wird nicht mehr für die Song-Daten benötigt

const FAVORITES_STORAGE_KEY = 'songAppFavorites'; // Schlüssel für localStorage
const FAVORITES_STORAGE_KEY = 'songAppFavorites';
const API_URL_SONGS = '/api/songs'; // URL zu deinem Flask-Backend

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

    // Helferfunktion zum Laden der Favoriten-IDs aus localStorage
    _loadFavoriteIdsFromStorage: function() {
        try {
            const storedFavorites = localStorage.getItem(FAVORITES_STORAGE_KEY);
            if (storedFavorites) {
                // Parse den JSON-String und gib ein Set für schnelle Lookups zurück
                return new Set(JSON.parse(storedFavorites));
            }
        } catch (e) {
            console.error("Fehler beim Laden der Favoriten aus localStorage:", e);
            // Bei Fehlern oder wenn nichts gespeichert ist, leeres Set zurückgeben
        }
        return new Set();
    },

    // Helferfunktion zum Speichern der Favoriten-IDs in localStorage
    _saveFavoriteIdsToStorage: function(favoriteIdsSet) {
        try {
            // Konvertiere das Set in ein Array und dann in einen JSON-String
            localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(Array.from(favoriteIdsSet)));
        } catch (e) {
            console.error("Fehler beim Speichern der Favoriten in localStorage:", e);
        }
    },

    loadAllSongs: function() {
    loadAllSongsFromServer: function() {
        this.isLoading = true;
        // Lade zuerst die Favoriten-IDs aus dem Speicher
        const favoriteIdsFromStorage = this._loadFavoriteIdsFromStorage();

        // Simulierter API-Call (lädt Songs aus mockData)
        new Promise(resolve => setTimeout(() => resolve(initialSongs), 200))
            .then(data => {
                // Mappe die geladenen Songs und setze 'isFavorite' basierend auf den gespeicherten IDs
                this.allSongs = data.map(songFromMock => ({
                    ...songFromMock, // Alle Eigenschaften des Songs aus mockData
                    isFavorite: favoriteIdsFromStorage.has(songFromMock.id) // Überschreibe/Setze isFavorite
        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
                isFavorite: favoriteIdsFromStorage.has(songFromServer.id)
            }));
            this.isLoading = false;
                m.redraw();
            }).catch(error => {
                console.error("Fehler beim Laden der initialen Songdaten:", error);
            m.redraw(); // Stelle sicher, dass die UI nach dem Laden der Daten aktualisiert wird
        })
        .catch(error => {
            console.error("Fehler beim Laden der Songs vom Server:", error);
            this.allSongs = []; // Leere Liste bei Fehler, um Inkonsistenzen zu vermeiden
            this.isLoading = false;
                m.redraw();
            m.redraw(); // Stelle sicher, dass die UI auch im Fehlerfall aktualisiert wird
        });
    },

    oninit: function() {
        this.loadAllSongs();
        this.loadAllSongsFromServer();
    },
    
    getProcessedSongsForView: function(viewFilter) {
        // Diese Funktion bleibt im Kern gleich, sie operiert auf this.allSongs,
        // welches jetzt den korrekten isFavorite-Status hat.
    // 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;

        if (this.allSongs && this.allSongs.length > 0) {
        if (!this.isLoading && this.allSongs && this.allSongs.length > 0) {
            // 1. Basierend auf dem Tab (Alle, Favoriten, Beliebt) filtern
            switch (viewFilter) {
                case 'favorites':
                    songsToProcess = this.allSongs.filter(song => song.isFavorite);
@@ -74,21 +79,41 @@ const SongList = {
                    }
                    break;
                case 'popular':
                    songsToProcess = [];
                    songsToProcess = []; // Platzhalter für "Beliebt"-Funktion
                    viewSpecificMessage = "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.";
                    }
                    break;
            }
        } else if (!this.isLoading) {
             viewSpecificMessage = "Keine Songs geladen oder Bibliothek ist leer.";

            // 2. Basierend auf dem Suchbegriff filtern (TITLE und ARTIST)
            if (searchTerm && searchTerm.trim() !== '') {
                const lowerSearchTerm = searchTerm.trim().toLowerCase();
                // Filtere die bereits nach Tab vorselektierte Liste weiter
                songsToProcess = songsToProcess.filter(song =>
                    (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.";
                }
            }

        } 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.";
        }
        
        // Gruppieren und Sortieren der (gefilterten) songsToProcess
        const groups = {};
        songsToProcess.forEach(song => {
            groups[song.artist] = groups[song.artist] || [];
@@ -107,9 +132,8 @@ const SongList = {
    toggleFavorite: function(songId) {
        const song = this.allSongs.find(s => s.id === songId);
        if (song) {
            song.isFavorite = !song.isFavorite; // In-Memory Status aktualisieren
            song.isFavorite = !song.isFavorite;

            // Aktualisiere die Liste der Favoriten-IDs im localStorage
            const currentFavoriteIds = new Set();
            this.allSongs.forEach(s => {
                if (s.isFavorite) {
@@ -117,33 +141,33 @@ const SongList = {
                }
            });
            this._saveFavoriteIdsToStorage(currentFavoriteIds);

            // Der vorherige API Call Platzhalter wird hier nicht mehr benötigt,
            // da die Speicherung nun clientseitig erfolgt.
            // m.redraw() wird von Mithril nach dem Klick-Event automatisch ausgelöst.
            // m.redraw() wird automatisch durch den Event-Handler ausgelöst
        }
    },

    view: function(vnode) {
        // Die View-Logik bleibt unverändert
        const { currentView } = vnode.attrs;
        const { currentView, searchTerm } = vnode.attrs;

        if (this.isLoading) {
            return m("div.song-list-outer-container", m("div.loading-placeholder", "Lade Songs..."));
        if (this.isLoading && (!this.allSongs || this.allSongs.length === 0)) {
            return m("div.song-list-outer-container", m("div.loading-placeholder", "Lade Songs vom Server..."));
        }

        const { groupedSongs, message, hasContent } = this.getProcessedSongsForView(currentView);
        const { groupedSongs, message, hasContent } = this.getProcessedSongsForView(currentView, searchTerm);
        const artists = Object.keys(groupedSongs);

        if (message && !hasContent) {
            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."));
        }

        return m("div.song-list-outer-container",
            artists.map(artist =>
                m("div.artist-group", { key: `${currentView}-${artist}` }, [
                m("div.artist-group", { key: `${currentView}-${searchTerm || 'all'}-${artist}` }, [
                    m("h2.artist-name", artist),
                    groupedSongs[artist].map(s => // Geändert von 'song' zu 's' um Namenskonflikt zu vermeiden
                    groupedSongs[artist].map(s =>
                        m(SongItem, {
                            key: s.id,
                            song: s,
+17 −5
Original line number Diff line number Diff line
// app.mjs
// frontend/app.mjs
import SearchBar from './SearchBar.mjs';
import TabBar from './TabBar.mjs'; // Neu importiert
import TabBar from './TabBar.mjs';
import SongList from './SongList.mjs';

const AppLayout = {
    currentView: 'all', // Mögliche Werte: 'all', 'favorites', 'popular'
    searchTerm: '',     // Neuer State für den Suchbegriff

    onViewChange: function(view) {
        this.currentView = view;
        // m.redraw() wird von Mithril automatisch nach Event-Handlern aufgerufen
    },

    onSearchTermChange: function(term) { // Neue Methode zum Aktualisieren des Suchbegriffs
        this.searchTerm = term;
        // m.redraw() wird von Mithril automatisch nach Event-Handlern aufgerufen
    },

    view: function() {
        return m("div.app-layout", [
            m(SearchBar),
            m(SearchBar, {
                searchTerm: this.searchTerm, // Übergib aktuellen Suchbegriff
                onSearchTermChange: this.onSearchTermChange.bind(this) // Übergib Callback
            }),
            m(TabBar, {
                currentView: this.currentView,
                onViewChange: this.onViewChange.bind(this) // .bind(this) ist wichtig
                onViewChange: this.onViewChange.bind(this)
            }),
            m(SongList, { currentView: this.currentView }) // Übergabe des aktuellen Views
            m(SongList, {
                currentView: this.currentView,
                searchTerm: this.searchTerm // Übergib aktuellen Suchbegriff an SongList
            })
        ]);
    }
};