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

feat: Implement pagination for song list

Adds pagination to the song list to improve performance with large datasets.
- Introduces a new PaginationControls component.
- Updates SongList to handle pagination logic and state.
- Resets pagination to first page when filters or search terms change.
- Pagination controls are hidden if all items fit on a single page.
- Includes styling for the pagination controls.
parent 7eb720cd
Loading
Loading
Loading
Loading
+92 −0
Original line number Diff line number Diff line
// frontend/PaginationControls.mjs
const PaginationControls = {
    view: function(vnode) {
        const { currentPage, totalPages, onPageChange } = vnode.attrs;

        if (totalPages <= 1) {
            return null; // Keine Steuerelemente anzeigen, wenn nicht benötigt
        }

        const pages = [];
        const delta = 2; // Anzahl der Seiten, die um die aktuelle Seite herum angezeigt werden

        // Hilfsfunktion zum Hinzufügen einer Seitenzahl
        const addPage = (pageNumber) => {
            pages.push(m("span.page-number", {
                class: pageNumber === currentPage ? 'active' : 'clickable',
                onclick: () => pageNumber !== currentPage ? onPageChange(pageNumber) : null,
                role: "button",
                tabindex: 0, // Für Tastaturnavigation
                onkeypress: (e) => { // Für Tastaturnavigation (Enter/Space)
                    if ((e.key === 'Enter' || e.key === ' ') && pageNumber !== currentPage) {
                        e.preventDefault();
                        onPageChange(pageNumber);
                    }
                },
                "aria-label": `Gehe zu Seite ${pageNumber}`,
                "aria-current": pageNumber === currentPage ? "page" : undefined
            }, pageNumber));
        };

        // "Zurück"-Button
        const prevButton = m("button.pagination-button", {
            onclick: () => onPageChange(currentPage - 1),
            disabled: currentPage === 1,
            "aria-label": "Vorherige Seite"
        }, "Zurück");

        // "Weiter"-Button
        const nextButton = m("button.pagination-button", {
            onclick: () => onPageChange(currentPage + 1),
            disabled: currentPage === totalPages,
            "aria-label": "Nächste Seite"
        }, "Weiter");

        // Logik zur Seitenzahlanzeige
        if (totalPages <= 7) { // Alle Seiten anzeigen, wenn es 7 oder weniger sind
            for (let i = 1; i <= totalPages; i++) {
                addPage(i);
            }
        } else {
            addPage(1); // Immer die erste Seite anzeigen

            if (currentPage > delta + 2) { // Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Anfang entfernt ist
                pages.push(m("span.dots", "..."));
            }

            // Bereich der Seiten um die aktuelle Seite bestimmen
            let start = Math.max(2, currentPage - delta);
            let end = Math.min(totalPages - 1, currentPage + delta);

            if (currentPage <= delta + 1) { // Aktuelle Seite ist nahe am Anfang
                start = 2;
                end = Math.min(totalPages - 1, 2 * delta + 1);
            } else if (currentPage >= totalPages - delta) { // Aktuelle Seite ist nahe am Ende
                start = Math.max(2, totalPages - (2 * delta));
                end = totalPages - 1;
            }
            
            // Korrektur für den Fall, dass start und end sich überschneiden oder ungültig werden
            if (start > end && currentPage < totalPages / 2) { // Wenn start > end und wir sind eher am Anfang
                 end = start; // Zeige zumindest die Startseite
            } else if (start > end && currentPage > totalPages / 2) { // Wenn start > end und wir sind eher am Ende
                 start = end; // Zeige zumindest die Endseite
            }


            for (let i = start; i <= end; i++) {
                if (i > 0) addPage(i); // Sicherstellen, dass i positiv ist
            }

            if (currentPage < totalPages - delta - 1 && end < totalPages -1) { // Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Ende entfernt ist
                pages.push(m("span.dots", "..."));
            }
            
            if (totalPages > 1) addPage(totalPages); // Immer die letzte Seite anzeigen (wenn nicht 1)
        }

        return m("div.pagination-controls", [prevButton, ...pages, nextButton]);
    }
};

export default PaginationControls;
+112 −68
Original line number Diff line number Diff line
// frontend/SongList.mjs
import SongItem from './SongItem.mjs';
import PaginationControls from './PaginationControls.mjs'; // NEU: Import für Paginierungs-Komponente

const FAVORITES_STORAGE_KEY = 'songAppFavorites';
const API_URL_SONGS = '/api/songs';
@@ -7,6 +8,9 @@ const API_URL_SONGS = '/api/songs';
const SongList = {
    allSongs: [],
    isLoading: true,
    currentPage: 1,
    itemsPerPage: 5, // Du kannst dies auf 30 ändern, wie gewünscht. Für Testzwecke mit Mock-Daten ist 5 besser.
    totalPagesComputed: 1, // Wird dynamisch berechnet

    _loadFavoriteIdsFromStorage: function() {
        try {
@@ -30,6 +34,7 @@ const SongList = {

    loadAllSongsFromServer: function() {
        this.isLoading = true;
        this.currentPage = 1; // Reset current page on full load
        const favoriteIdsFromStorage = this._loadFavoriteIdsFromStorage();
        m.request({
            method: "GET",
@@ -56,14 +61,33 @@ const SongList = {
        this.loadAllSongsFromServer();
    },

    // Wird aufgerufen, bevor die Komponente aktualisiert wird.
    // Setzt die aktuelle Seite zurück, wenn sich Filter ändern.
    onbeforeupdate: function(vnode, old) {
        if (vnode.attrs.currentView !== old.attrs.currentView ||
            vnode.attrs.searchTerm !== old.attrs.searchTerm) {
            this.currentPage = 1;
        }
        return true;
    },

    // Wird von PaginationControls aufgerufen, um die Seite zu wechseln.
    onPageChange: function(newPage) {
        if (newPage >= 1 && newPage <= this.totalPagesComputed) {
            this.currentPage = newPage;
            // m.redraw(); // Mithril handhabt das Redraw normalerweise automatisch
        }
    },

    getProcessedSongsForView: function(viewFilter, searchTerm) {
        let initialSongsForView = [];
        let messageFromTabFilter = null;

        // 1. Songs basierend auf dem aktuellen Tab (View) filtern
        switch (viewFilter) {
            case 'favorites':
                initialSongsForView = this.allSongs.filter(song => song.isFavorite);
                if (initialSongsForView.length === 0) {
                if (initialSongsForView.length === 0 && !searchTerm) { // Nachricht nur, wenn kein Suchbegriff aktiv ist
                    messageFromTabFilter = "Du hast noch keine Favoriten markiert.";
                }
                break;
@@ -74,59 +98,77 @@ const SongList = {
            case 'all':
            default:
                initialSongsForView = this.allSongs;
                if (initialSongsForView.length === 0 && !this.isLoading) {
                if (initialSongsForView.length === 0 && !this.isLoading && !searchTerm) {
                    messageFromTabFilter = "Keine Songs in der Bibliothek.";
                }
                break;
        }

        let songsToProcess = initialSongsForView.slice();
        let finalMessage = messageFromTabFilter;
        let songsAfterSearch = initialSongsForView.slice();
        let composedMessage = null; // Endgültige Nachricht für den Benutzer

        // 2. Suchbegriff anwenden
        if (searchTerm && searchTerm.trim() !== '') {
            const lowerSearchTerm = searchTerm.trim().toLowerCase();
            const searchedSongsResult = initialSongsForView.filter(song =>
            songsAfterSearch = initialSongsForView.filter(song =>
                (song.title && song.title.toLowerCase().includes(lowerSearchTerm)) ||
                (song.artist && song.artist.toLowerCase().includes(lowerSearchTerm))
            );

            if (viewFilter === 'favorites') {
                const totalFavoritesOverall = initialSongsForView.length; // Alle Favoriten, bevor Suchfilter angewendet wird
                
                if (totalFavoritesOverall > 0) { // Nur wenn es überhaupt Favoriten gibt
                    if (searchedSongsResult.length < totalFavoritesOverall) {
                        // Suche hat Favoriten reduziert ODER alle Favoriten ausgeblendet
                        finalMessage = {
                            type: 'filtered_favorites',
                            total: totalFavoritesOverall,
                            showing: searchedSongsResult.length // Kann 0 sein
                        };
            if (songsAfterSearch.length === 0) {
                if (viewFilter === 'favorites' && initialSongsForView.length > 0) {
                    // Suchbegriff hat keine Favoriten gefunden, obwohl welche da sind
                    composedMessage = `Deine Suche ergab keine Treffer unter deinen ${initialSongsForView.length} Favoriten.`;
                } else {
                    composedMessage = "Keine Songs für deine Suche gefunden.";
                }
            }
                    // Wenn searchedSongsResult.length == totalFavoritesOverall, hat die Suche nichts ausgeblendet,
                    // also keine spezielle Nachricht nötig, außer der Standard-Tab-Filter-Nachricht.
        } else {
            // Kein Suchbegriff aktiv, verwende die Nachricht vom Tab-Filter, falls vorhanden
            composedMessage = messageFromTabFilter;
        }
                // Wenn totalFavoritesOverall == 0, ist finalMessage bereits "Du hast noch keine Favoriten..."
            } else if (searchedSongsResult.length === 0 && initialSongsForView.length > 0) {
                finalMessage = "Keine Songs für deine Suche gefunden.";

        const totalFilteredItems = songsAfterSearch.length;

        // 3. Paginierungslogik
        const totalPages = Math.ceil(totalFilteredItems / this.itemsPerPage) || 1;
        this.totalPagesComputed = totalPages; // Speichern für onPageChange

        // Sicherstellen, dass currentPage innerhalb gültiger Grenzen liegt
        if (this.currentPage > totalPages) {
            this.currentPage = totalPages;
        }
            songsToProcess = searchedSongsResult;
        if (this.currentPage < 1) {
            this.currentPage = 1;
        }

        const startIndex = (this.currentPage - 1) * this.itemsPerPage;
        const paginatedSongs = songsAfterSearch.slice(startIndex, startIndex + this.itemsPerPage);

        // 4. Songs für die aktuelle Seite nach Künstler gruppieren
        const groups = {};
        songsToProcess.forEach(song => {
        paginatedSongs.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 = {};
        const finalGroupedSongsOnPage = {};
        sortedArtistNames.forEach(artist => {
            finalGroupedSongs[artist] = groups[artist].sort((a, b) => a.title.localeCompare(b.title));
            finalGroupedSongsOnPage[artist] = groups[artist].sort((a, b) => a.title.localeCompare(b.title));
        });
        
        // 5. Endgültige Nachricht, falls nach Filterung/Suche gar keine Items da sind
        if (totalFilteredItems === 0 && !composedMessage && !this.isLoading) {
             composedMessage = "Keine Songs entsprechen den aktuellen Kriterien.";
        }


        return {
            groupedSongs: finalGroupedSongs,
            message: finalMessage,
            hasContent: songsToProcess.length > 0
            groupedSongs: finalGroupedSongsOnPage,
            message: composedMessage, // Dies ist jetzt eine einfache Zeichenkette oder null
            hasContentOnPage: paginatedSongs.length > 0,
            currentPage: this.currentPage,
            totalPages: this.totalPagesComputed
        };
    },

@@ -141,6 +183,7 @@ const SongList = {
                }
            });
            this._saveFavoriteIdsToStorage(currentFavoriteIds);
            // Kein m.redraw() hier, da die Ansicht sowieso aktualisiert wird, wenn nötig.
        }
    },

@@ -151,53 +194,49 @@ const SongList = {
            return m("div.song-list-outer-container", m("div.loading-placeholder", "Lade Songs vom Server..."));
        }

        const { groupedSongs, message, hasContent } = this.getProcessedSongsForView(currentView, searchTerm);
        const artists = Object.keys(groupedSongs);
        const { groupedSongs, message, hasContentOnPage, currentPage, totalPages } =
            this.getProcessedSongsForView(currentView, searchTerm);
        
        let messageElement = null; // Variable für das Nachrichten-Element
        const artists = Object.keys(groupedSongs);
        let messageElement = null;

        // Logik für die Nachrichtenanzeige
        if (message && typeof message === 'object' && message.type === 'filtered_favorites') {
            let messageTextIntro = `Deine Suche zeigt gerade ${message.showing} von deinen insgesamt ${message.total} Favoriten. `;
            if (message.showing === 0) {
                messageTextIntro = `Deine Suche ergab keine Treffer unter deinen ${message.total} Favoriten. `;
        // Logik für "Suche zurücksetzen"-Link
        const onClearSearch = (e) => {
            e.preventDefault();
            if (onSearchTermChange) {
                onSearchTermChange(''); // Dies löst onbeforeupdate aus und setzt currentPage zurück
            }
        };
        
            messageElement = m("p.view-message", [
                messageTextIntro,
                message.total > 0 ? `Möchtest du ` : '', // Nur anzeigen, wenn es Favoriten gibt
                message.total > 0 ? m("a.clear-search-link", {
                    onclick: (e) => {
                        e.preventDefault();
                        if (onSearchTermChange) { onSearchTermChange(''); }
                    },
        if (!hasContentOnPage && message) {
            let messageContent = [message];
            // Füge "Suche zurücksetzen"-Link hinzu, wenn die Nachricht aufgrund der Suche im Favoriten-Tab angezeigt wird
            // und es überhaupt Favoriten gibt.
            const totalFavoritesOverall = this.allSongs.filter(s => s.isFavorite).length;
            if (searchTerm && currentView === 'favorites' && totalFavoritesOverall > 0 && message.toLowerCase().includes("suche")) {
                 messageContent.push(` Möchtest du `);
                 messageContent.push(m("a.clear-search-link", {
                    onclick: onClearSearch,
                    href: "#", role: "button", tabindex:0,
                    onkeypress: (e) => {
                        if ((e.key === 'Enter' || e.key === ' ') && onSearchTermChange) {
                            e.preventDefault(); onSearchTermChange('');
                        }
                    }
                }, `alle ${message.total} Favoriten anzeigen`) : '',
                message.total > 0 ? "?" : ''
            ]);
        } else if (message && typeof message === 'string' && !hasContent) {
            messageElement = m("p.view-message", message);
        } else if (!message && !hasContent && artists.length === 0 ) {
            // Fall: Keine spezielle Nachricht, aber auch keine Inhalte und keine Künstlergruppen
            // Dies kann passieren, wenn die Filterung (z.B. Suche auf "Alle") alle Songs entfernt hat.
                    onkeypress: (e) => { if(e.key === 'Enter' || e.key === ' ') onClearSearch(e); }
                 }, `alle ${totalFavoritesOverall} Favoriten anzeigen`));
                 messageContent.push(`?`);
            }
            messageElement = m("p.view-message", messageContent);
        } else if (!hasContentOnPage && artists.length === 0 && !this.isLoading && !message) {
            // Fallback-Nachricht, wenn keine andere Nachricht gesetzt wurde und keine Inhalte da sind.
            messageElement = m("p.view-message", "Keine Songs entsprechen den aktuellen Kriterien.");
        }

        // Erstelle die Liste der zu rendernden Elemente
        const elementsToRender = [];
        if (messageElement) {
            elementsToRender.push(messageElement);
        }

        if (hasContent) {
        if (hasContentOnPage) {
            elementsToRender.push(
                artists.map(artist =>
                    m("div.artist-group", { key: `${currentView}-${searchTerm || 'all'}-${artist}` }, [
                    m("div.artist-group", { key: `${currentView}-${searchTerm || 'all'}-${artist}-${currentPage}` }, [ // currentPage in key
                        m("h2.artist-name", artist),
                        groupedSongs[artist].map(s =>
                            m(SongItem, {
@@ -211,9 +250,14 @@ const SongList = {
            );
        }
        
        // Wenn es nur eine Nachricht gibt und keine Inhalte, und diese Nachricht schon oben behandelt wurde,
        // dann muss hier nichts mehr für den Fall "!hasContent && !messageElement" getan werden,
        // da der Container dann leer bleibt, was korrekt ist.
        // Füge Paginierungs-Steuerelemente hinzu, wenn mehr als eine Seite vorhanden ist
        if (totalPages > 1) {
            elementsToRender.push(m(PaginationControls, {
                currentPage: currentPage,
                totalPages: totalPages,
                onPageChange: this.onPageChange.bind(this)
            }));
        }

        return m("div.song-list-outer-container", elementsToRender);
    }
+62 −10
Original line number Diff line number Diff line
@@ -92,7 +92,7 @@ body, html {
/* Song-Liste Container */
.song-list-outer-container {
    padding-top: calc(60px + 48px); /* Höhe Suchleiste + Höhe TabBar */
    padding-bottom: 20px;
    padding-bottom: 20px; /* Platz für Paginierung, falls am Ende */
}

/* Link zum Zurücksetzen der Suche in der .view-message */
@@ -105,7 +105,7 @@ body, html {
    background: none; /* Sicherstellen, dass keine Button-Styles übernommen werden */
    border: none;
    padding: 0;
    margin: 0;
    margin: 0 4px; /* Kleiner Abstand zum umgebenden Text */
    display: inline; /* Damit es sich wie ein normaler Link im Text verhält */
}

@@ -120,7 +120,7 @@ body, html {

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

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

.heart-icon.is-favorite {
    color: #FF4081;
    /* 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 */
    font-variation-settings: 'FILL' 1;
}

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

/* NEU: Pagination Controls Styles */
.pagination-controls {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 20px 0; /* Mehr Padding für bessere Sichtbarkeit */
    margin-top: 10px;
    user-select: none;
    flex-wrap: wrap; /* Erlaubt Umbruch bei vielen Seitenzahlen auf kleinen Bildschirmen */
}

.pagination-controls button.pagination-button, /* Klasse für Buttons hinzugefügt */
.pagination-controls span.page-number {
    margin: 4px; /* Etwas mehr Margin für Umbruch */
    padding: 8px 12px;
    border: 1px solid #ddd;
    background-color: #fff;
    color: #3F51B5; /* Material Indigo */
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.2s, color 0.2s, border-color 0.2s;
    font-size: 0.9em;
    min-width: 36px; /* Mindestbreite für bessere Klickbarkeit */
    text-align: center;
    box-sizing: border-box;
}

.pagination-controls button.pagination-button:hover:not(:disabled),
.pagination-controls span.page-number.clickable:hover {
    background-color: #E8EAF6; /* Heller Indigo-Ton für Hover */
    border-color: #C5CAE9;
}

.pagination-controls button.pagination-button:disabled {
    color: #aaa;
    cursor: not-allowed;
    background-color: #f9f9f9;
    border-color: #eee;
}

.pagination-controls span.page-number.active {
    background-color: #3F51B5;
    color: #fff;
    border-color: #3F51B5;
    font-weight: bold;
    cursor: default;
}

.pagination-controls span.dots {
    padding: 8px 6px;
    color: #777;
    margin: 4px;
    font-size: 0.9em;
}