commit 203c7098e62c00922ff8357c60b99ab3e05347a4 parent 5083fe684fd6ce8612d58c10ce22546533dff606 Author: brookjeynes <me@brookjeynes.dev> Date: Mon, 22 Jun 2026 13:26:44 +1000 feat: add search page Task-id: yrqxnpqlnuypknvx Signed-off-by: brookjeynes <me@brookjeynes.dev> Diffstat:
43 files changed, 1097 insertions(+), 259 deletions(-)
diff --git a/internal/db/artist.go b/internal/db/artist.go @@ -176,23 +176,35 @@ func UpdateArtist(e Execer, artist domain.Artist) error { return nil } -func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) { +func SearchArtist(e Execer, artistName string, limit, offset int64) (int, []domain.Artist, error) { artistName = strings.TrimSpace(artistName) if artistName == "" { - return nil, nil + return 0, nil, nil } wildcard := "%" + artistName + "%" + + var totalRows int + err := e.QueryRow(` + select count(*) + from artists + where name like ? collate nocase + or latin_name like ? collate nocase + `, wildcard, wildcard).Scan(&totalRows) + if err != nil { + return 0, nil, fmt.Errorf("failed to count total artists: %w", err) + } + rows, err := e.Query(` select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio from artists where name like ? collate nocase or latin_name like ? collate nocase order by name - limit 20 - `, wildcard, wildcard) + limit ? offset ? + `, wildcard, wildcard, limit, offset) if err != nil { - return nil, fmt.Errorf("failed to query artists: %w", err) + return 0, nil, fmt.Errorf("failed to query artists: %w", err) } defer func() { _ = rows.Close() @@ -202,10 +214,10 @@ func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) { for rows.Next() { var artist domain.Artist if err := rows.Scan(&artist.ID, &artist.MusicBrainzArtistID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio); err != nil { - return nil, fmt.Errorf("failed to scan artist row: %w", err) + return 0, nil, fmt.Errorf("failed to scan artist row: %w", err) } results = append(results, artist) } - return results, rows.Err() + return totalRows, results, rows.Err() } diff --git a/internal/db/db.go b/internal/db/db.go @@ -130,7 +130,8 @@ func New(ctx context.Context, dbPath string) (*DB, error) { role text not null, sort_order integer not null, - unique(song_id, sort_order, role), + unique(song_id, sort_order), + unique(song_id, role), foreign key (song_id) references songs(id) on delete cascade, foreign key (artist_id) references artists(id) on delete cascade, primary key (song_id, artist_id, role) diff --git a/internal/db/release.go b/internal/db/release.go @@ -7,6 +7,17 @@ import ( "time" ) +type releaseArtistRow struct { + artistID domain.ArtistID + role domain.ArtistRole + sortOrder int +} + +type releaseSongRow struct { + songID domain.SongID + trackNumber int +} + func Release(e Execer, id domain.ReleaseID) (domain.Release, error) { var release domain.Release var releaseDateStr string @@ -26,11 +37,6 @@ func Release(e Execer, id domain.ReleaseID) (domain.Release, error) { } // fetch release_artists rows - type releaseArtistRow struct { - artistID domain.ArtistID - role domain.ArtistRole - sortOrder int - } var artistRows []releaseArtistRow { @@ -105,10 +111,6 @@ func Release(e Execer, id domain.ReleaseID) (domain.Release, error) { } // fetch songs on this release ordered by track_number - type releaseSongRow struct { - songID domain.SongID - trackNumber int - } var songRows []releaseSongRow { rows, err := e.Query(` @@ -263,23 +265,36 @@ func InsertRelease(e Execer, release domain.NewRelease) (domain.ReleaseID, error return domain.ReleaseID(lastID), nil } -func SearchRelease(e Execer, releaseName string) ([]domain.Release, error) { +func SearchRelease(e Execer, releaseName string, limit, offset int64) (int, []domain.Release, error) { releaseName = strings.TrimSpace(releaseName) if releaseName == "" { - return nil, nil + return 0, nil, nil } wildcard := "%" + releaseName + "%" + + var totalRows int + err := e.QueryRow(` + select count(*) + from releases + where title like ? collate nocase + or latin_title like ? collate nocase + order by title + `, wildcard, wildcard).Scan(&totalRows) + if err != nil { + return 0, nil, fmt.Errorf("failed to count total releases: %w", err) + } + rows, err := e.Query(` select id, musicbrainz_release_id, title, latin_title, release_date, type from releases where title like ? collate nocase or latin_title like ? collate nocase order by title - limit 20 - `, wildcard, wildcard) + limit ? offset ? + `, wildcard, wildcard, limit, offset) if err != nil { - return nil, fmt.Errorf("failed to query releases: %w", err) + return 0, nil, fmt.Errorf("failed to query releases: %w", err) } defer func() { _ = rows.Close() @@ -291,17 +306,92 @@ func SearchRelease(e Execer, releaseName string) ([]domain.Release, error) { var releaseDateStr string if err := rows.Scan(&release.ID, &release.MusicBrainzReleaseID, &release.Title, &release.LatinTitle, &releaseDateStr, &release.Type); err != nil { - return nil, fmt.Errorf("failed to scan release row: %w", err) + return 0, nil, fmt.Errorf("failed to scan release row: %w", err) } releaseDate, err := time.Parse(time.RFC3339, releaseDateStr) if err != nil { - return nil, fmt.Errorf("failed to parse release date string '%s': %w", releaseDateStr, err) + return 0, nil, fmt.Errorf("failed to parse release date string '%s': %w", releaseDateStr, err) } release.ReleaseDate = releaseDate + + // fetch release_artists rows + var artistRows []releaseArtistRow + + { + rows, err := e.Query(` + select artist_id, role, sort_order + from release_artists + where release_id = ? + order by sort_order + `, release.ID) + if err != nil { + return 0, []domain.Release{}, fmt.Errorf("failed to query release artists: %w", err) + } + + for rows.Next() { + var r releaseArtistRow + if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil { + _ = rows.Close() + return 0, []domain.Release{}, fmt.Errorf("failed to scan release artist: %w", err) + } + artistRows = append(artistRows, r) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, []domain.Release{}, fmt.Errorf("failed to iterate release artists: %w", err) + } + _ = rows.Close() + } + + // fetch full artist records in one query + if len(artistRows) > 0 { + artistIDs := make([]any, len(artistRows)) + for i, r := range artistRows { + artistIDs[i] = r.artistID + } + placeholders := strings.Repeat("?,", len(artistIDs)) + placeholders = placeholders[:len(placeholders)-1] + + artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs)) + { + rows, err := e.Query(` + select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio + from artists + where id in (`+placeholders+`) + `, artistIDs...) + if err != nil { + return 0, []domain.Release{}, fmt.Errorf("failed to query artists: %w", err) + } + + for rows.Next() { + var a domain.Artist + if err := rows.Scan(&a.ID, &a.MusicBrainzArtistID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil { + _ = rows.Close() + return 0, []domain.Release{}, fmt.Errorf("failed to scan artist: %w", err) + } + artistMap[a.ID] = a + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, []domain.Release{}, fmt.Errorf("failed to iterate artists: %w", err) + } + _ = rows.Close() + } + + release.Artists = make([]domain.ReleaseArtist, len(artistRows)) + for i, r := range artistRows { + release.Artists[i] = domain.ReleaseArtist{ + Artist: artistMap[r.artistID], + Role: r.role, + SortOrder: r.sortOrder, + } + } + } + results = append(results, release) } - return results, rows.Err() + return totalRows, results, rows.Err() } diff --git a/internal/db/song.go b/internal/db/song.go @@ -25,6 +25,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) { return domain.Song{}, fmt.Errorf("failed to parse release_date: %w", err) } + // fetch artists { rows, err := e.Query(` select @@ -69,6 +70,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) { _ = rows.Close() } + // fetch releases { rows, err := e.Query(` select @@ -375,3 +377,102 @@ func InsertSong(e Execer, song domain.NewSong) error { return nil } + +func SearchSong(e Execer, songTitle string, limit, offset int64) (int, []domain.Song, error) { + songTitle = strings.TrimSpace(songTitle) + if songTitle == "" { + return 0, []domain.Song{}, nil + } + + wildcard := "%" + songTitle + "%" + + var totalRows int + err := e.QueryRow(` + select count(*) + from songs + where title like ? collate nocase + or latin_title like ? collate nocase + `, wildcard, wildcard).Scan(&totalRows) + if err != nil { + return 0, nil, fmt.Errorf("failed to count total songs: %w", err) + } + + rows, err := e.Query(` + select id, musicbrainz_recording_id, title, latin_title, lyrics, release_date + from songs + where title like ? collate nocase + or latin_title like ? collate nocase + order by title + limit ? offset ? + `, wildcard, wildcard, limit, offset) + if err != nil { + return 0, nil, fmt.Errorf("failed to query songs: %w", err) + } + defer func() { + _ = rows.Close() + }() + + var results []domain.Song + for rows.Next() { + var song domain.Song + var releaseDateStr string + + if err := rows.Scan(&song.ID, &song.MusicBrainzRecordingID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr); err != nil { + return 0, []domain.Song{}, fmt.Errorf("failed to get song: %w", err) + } + + song.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + return 0, []domain.Song{}, fmt.Errorf("failed to parse release_date: %w", err) + } + + // fetch artists + { + rows, err := e.Query(` + select + a.id, + a.musicbrainz_artist_id, + a.name, + a.latin_name, + a.disambiguation, + a.bio, + sa.role, + sa.sort_order + from song_artists sa + join artists a on a.id = sa.artist_id + where sa.song_id = ? + order by sa.sort_order + `, song.ID) + if err != nil { + return 0, []domain.Song{}, fmt.Errorf("failed to query song artists: %w", err) + } + + for rows.Next() { + var artist domain.SongArtist + if err := rows.Scan( + &artist.Artist.ID, + &artist.Artist.MusicBrainzArtistID, + &artist.Artist.Name, + &artist.Artist.LatinName, + &artist.Artist.Disambiguation, + &artist.Artist.Bio, + &artist.Role, + &artist.SortOrder, + ); err != nil { + _ = rows.Close() + return 0, []domain.Song{}, fmt.Errorf("failed to scan song artist: %w", err) + } + song.Artists = append(song.Artists, artist) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, []domain.Song{}, fmt.Errorf("failed to iterate song artists: %w", err) + } + _ = rows.Close() + + results = append(results, song) + } + } + + return totalRows, results, nil +} diff --git a/internal/domain/search.go b/internal/domain/search.go @@ -0,0 +1,9 @@ +package domain + +type SearchFormat string + +const ( + SearchFormatSimilar SearchFormat = "similar" + SearchFormatSuggest SearchFormat = "suggest" + SearchFormatTable SearchFormat = "table" +) diff --git a/internal/server/edit-release.go b/internal/server/edit-release.go @@ -30,20 +30,20 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { // type if form.Type == "" { - return release, fmt.Errorf("Type must not be empty") + return release, errors.New("Type must not be empty") } if _, ok := domain.ReleaseTypes[form.Type]; !ok { - return release, fmt.Errorf("Invalid type selected") + return release, errors.New("Invalid type selected") } // title if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { - return release, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace") + return release, errors.New("Title name must not be empty or have leading/trailing whitespace") } // artists if len(form.ArtistID) == 0 { - return release, fmt.Errorf("At least one artist is required") + return release, errors.New("At least one artist is required") } artists := make([]domain.NewReleaseArtist, 0, len(form.ArtistID)) @@ -51,10 +51,10 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { for i, id := range form.ArtistID { if id < 0 { - return release, fmt.Errorf("Invalid artist selection") + return release, errors.New("Invalid artist selection") } if _, exists := seenArtists[int64(id)]; exists { - return release, fmt.Errorf("An artist cannot be added more than once") + return release, errors.New("An artist cannot be added more than once") } seenArtists[int64(id)] = struct{}{} @@ -69,10 +69,10 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { var latinTitle *string if form.LatinTitle != "" { if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { - return release, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + return release, errors.New("Latin title must not be empty or have leading/trailing whitespace") } if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { - return release, fmt.Errorf("Latin title contains invalid characters") + return release, errors.New("Latin title contains invalid characters") } latinTitle = &form.LatinTitle } @@ -89,7 +89,7 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { } if len(form.SongID) != len(form.TrackNumber) { - return release, fmt.Errorf("Song ID and track number counts do not match", err) + return release, errors.New("Song ID and track number counts do not match") } songs := make([]domain.EditReleaseSong, 0, len(form.SongID)) @@ -101,19 +101,19 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { trackNumber := form.TrackNumber[i] if songID < 0 { - return release, fmt.Errorf("Invalid song selection") + return release, errors.New("Invalid song selection") } if trackNumber < 1 { - return release, fmt.Errorf("Track number can not be less that 1") + return release, errors.New("Track number can not be less that 1") } if _, exists := seenTracknumbers[trackNumber]; exists { - return release, fmt.Errorf("Songs may not share the same track number") + return release, errors.New("Songs may not share the same track number") } if _, exists := seenSongs[songID]; exists { - return release, fmt.Errorf("A song cannot be added more than once") + return release, errors.New("A song cannot be added more than once") } seenTracknumbers[trackNumber] = songID diff --git a/internal/server/router.go b/internal/server/router.go @@ -14,12 +14,15 @@ func (ss *ServerState) Router() http.Handler { router.Get("/", ss.Index) + router.Get("/search", ss.Search) + router.Route("/song", func(router chi.Router) { router.Route("/{id}", func(router chi.Router) { router.Get("/{slug}", ss.Song) }) router.Get("/new", ss.NewSong) router.Post("/new", ss.NewSong) + router.Get("/search", ss.SearchSong) }) router.Route("/release", func(router chi.Router) { diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go @@ -4,60 +4,116 @@ import ( "context" "errors" "lyrics/internal/db" + "lyrics/internal/domain" "lyrics/internal/server/htmx" - artistsearchresults "lyrics/internal/server/ui/partials/artist-search-results" - similarartistslist "lyrics/internal/server/ui/partials/similar-artists-list" + artistsearchpicker "lyrics/internal/server/ui/partials/artist-search-picker" + artistsearchsimilar "lyrics/internal/server/ui/partials/artist-search-similar" + artistsearchtable "lyrics/internal/server/ui/partials/artist-search-table" "net/http" -) - -type SearchArtistFormat string - -const ( - SearchArtistFormatSimilar SearchArtistFormat = "similar" - SearchArtistFormatSuggest SearchArtistFormat = "suggest" + "strconv" ) func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { logger := ss.logger.With("handler", "SearchArtist") - format := SearchArtistFormat(r.URL.Query().Get("format")) - if format != SearchArtistFormatSimilar && format != SearchArtistFormatSuggest { - logger.Error("invalid artist search format", "format", format) - http.Error(w, "invalid format param", http.StatusBadRequest) - return - } + format := domain.SearchFormat(r.URL.Query().Get("format")) - artist := r.URL.Query().Get("artist") - featuring := r.URL.Query().Get("featuring") - - search := artist - if search == "" { - search = featuring - } + var renderErr error + switch format { + case domain.SearchFormatSimilar: + artist := r.URL.Query().Get("artist") + featuring := r.URL.Query().Get("featuring") + search := artist + if search == "" { + search = featuring + } - results, err := db.SearchArtist(ss.db, search) - if err != nil { - logger.Error("failed to query db for artists", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to search artists.") - return - } + _, results, err := db.SearchArtist(ss.db, search, 5, 1) + if err != nil { + logger.Error("failed to query db for artists", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search artists.") + return + } - switch format { - case SearchArtistFormatSimilar: - err = similarartistslist.SimilarArtistsList(similarartistslist.SimilarArtistsListParams{ + renderErr = artistsearchsimilar.ArtistSearchSimilar(artistsearchsimilar.ArtistSearchSimilarParams{ Artists: results, }).Render(r.Context(), w) - case SearchArtistFormatSuggest: - err = artistsearchresults.ArtistSearchResults(artistsearchresults.ArtistSearchResultsParams{ + case domain.SearchFormatSuggest: + artist := r.URL.Query().Get("artist") + featuring := r.URL.Query().Get("featuring") + search := artist + if search == "" { + search = featuring + } + + _, results, err := db.SearchArtist(ss.db, search, 5, 1) + if err != nil { + logger.Error("failed to query db for artists", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search artists.") + return + } + + renderErr = artistsearchpicker.ArtistSearchPicker(artistsearchpicker.ArtistSearchPickerParams{ Artists: results, }).Render(r.Context(), w) + case domain.SearchFormatTable: + query := r.URL.Query().Get("query") + pageStr := r.URL.Query().Get("page") + limitStr := r.URL.Query().Get("limit") + + if pageStr == "" { + pageStr = "1" + } + + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil { + logger.Error("failed to parse page value", "err", err) + page = 1 + } + if page <= 0 { + page = 1 + } + + if limitStr == "" { + limitStr = "25" + } + + limit, err := strconv.ParseInt(limitStr, 10, 64) + if err != nil { + logger.Error("failed to parse limit value", "err", err) + limit = 25 + } + if limit < 1 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + + offset := (page - 1) * limit + + totalRows, results, err := db.SearchArtist(ss.db, query, limit, offset) + if err != nil { + logger.Error("failed to query db for artists", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search artists.") + return + } + totalPages := (int64(totalRows) + limit - 1) / limit + + renderErr = artistsearchtable.ArtistSearchTable(artistsearchtable.ArtistSearchTableParams{ + Artists: results, + TotalPages: int64(totalPages), + CurrentPage: page, + Query: query, + Limit: limit, + }).Render(r.Context(), w) } - if err != nil { - if errors.Is(err, context.Canceled) { + if renderErr != nil { + if errors.Is(renderErr, context.Canceled) { return } - logger.Error("failed to render search artist partial", "err", err) + logger.Error("failed to render search artist partial", "err", renderErr) htmx.HxError(w, http.StatusInternalServerError, "Failed to render response.") return } diff --git a/internal/server/search-release.go b/internal/server/search-release.go @@ -4,53 +4,106 @@ import ( "context" "errors" "lyrics/internal/db" + "lyrics/internal/domain" "lyrics/internal/server/htmx" - releasesearchresults "lyrics/internal/server/ui/partials/release-search-results" - similarreleaseslist "lyrics/internal/server/ui/partials/similar-releases-list" + releasesearchpicker "lyrics/internal/server/ui/partials/release-search-picker" + releasesearchsimilar "lyrics/internal/server/ui/partials/release-search-similar" + releasesearchtable "lyrics/internal/server/ui/partials/release-search-table" "net/http" -) - -type SearchReleaseFormat string - -const ( - SearchReleaseFormatSimilar SearchReleaseFormat = "similar" - SearchReleaseFormatSuggest SearchReleaseFormat = "suggest" + "strconv" ) func (ss *ServerState) SearchRelease(w http.ResponseWriter, r *http.Request) { logger := ss.logger.With("handler", "SearchRelease") - format := SearchReleaseFormat(r.URL.Query().Get("format")) - if format != SearchReleaseFormatSimilar && format != SearchReleaseFormatSuggest { - logger.Error("invalid release search format", "format", format) - http.Error(w, "invalid format param", http.StatusBadRequest) - return - } - - release := r.URL.Query().Get("release-title") - results, err := db.SearchRelease(ss.db, release) - if err != nil { - logger.Error("failed to query db for releases", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to search for release.") - return - } + format := domain.SearchFormat(r.URL.Query().Get("format")) + var renderErr error switch format { - case SearchReleaseFormatSimilar: - err = similarreleaseslist.SimilarReleasesList(similarreleaseslist.SimilarReleasesListParams{ + case domain.SearchFormatSimilar: + release := r.URL.Query().Get("release-title") + + _, results, err := db.SearchRelease(ss.db, release, 5, 1) + if err != nil { + logger.Error("failed to query db for releases", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search for release.") + return + } + + renderErr = releasesearchsimilar.ReleaseSearchSimilar(releasesearchsimilar.ReleaseSearchSimilarParams{ Releases: results, }).Render(r.Context(), w) - case SearchReleaseFormatSuggest: - err = releasesearchresults.ReleaseSearchResults(releasesearchresults.ReleaseSearchResultsParams{ + case domain.SearchFormatSuggest: + release := r.URL.Query().Get("release-title") + + _, results, err := db.SearchRelease(ss.db, release, 5, 1) + if err != nil { + logger.Error("failed to query db for releases", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search for release.") + return + } + + renderErr = releasesearchpicker.ReleaseSearchPicker(releasesearchpicker.ReleaseSearchPickerParams{ Releases: results, }).Render(r.Context(), w) + case domain.SearchFormatTable: + query := r.URL.Query().Get("query") + pageStr := r.URL.Query().Get("page") + limitStr := r.URL.Query().Get("limit") + + if pageStr == "" { + pageStr = "1" + } + + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil { + logger.Error("failed to parse page value", "err", err) + page = 1 + } + if page <= 0 { + page = 1 + } + + if limitStr == "" { + limitStr = "25" + } + + limit, err := strconv.ParseInt(limitStr, 10, 64) + if err != nil { + logger.Error("failed to parse limit value", "err", err) + limit = 25 + } + if limit < 1 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + + offset := (page - 1) * limit + + totalRows, results, err := db.SearchRelease(ss.db, query, limit, offset) + if err != nil { + logger.Error("failed to query db for releases", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search for release.") + return + } + totalPages := (int64(totalRows) + limit - 1) / limit + + renderErr = releasesearchtable.ReleaseSearchTable(releasesearchtable.ReleaseSearchTableParams{ + Releases: results, + TotalPages: int64(totalPages), + CurrentPage: page, + Query: query, + Limit: limit, + }).Render(r.Context(), w) } - if err != nil { - if errors.Is(err, context.Canceled) { + if renderErr != nil { + if errors.Is(renderErr, context.Canceled) { return } - logger.Error("failed to render search release partial", "err", err) + logger.Error("failed to render search release partial", "err", renderErr) htmx.HxError(w, http.StatusInternalServerError, "Failed to render response.") return } diff --git a/internal/server/search-song.go b/internal/server/search-song.go @@ -0,0 +1,83 @@ +package server + +import ( + "context" + "errors" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + songsearchtable "lyrics/internal/server/ui/partials/song-search-table" + "net/http" + "strconv" +) + +func (ss *ServerState) SearchSong(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "SearchSong") + + format := domain.SearchFormat(r.URL.Query().Get("format")) + query := r.URL.Query().Get("query") + + pageStr := r.URL.Query().Get("page") + limitStr := r.URL.Query().Get("limit") + + if pageStr == "" { + pageStr = "1" + } + + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil { + logger.Error("failed to parse page value", "err", err) + page = 1 + } + if page <= 0 { + page = 1 + } + + if limitStr == "" { + limitStr = "25" + } + + limit, err := strconv.ParseInt(limitStr, 10, 64) + if err != nil { + logger.Error("failed to parse limit value", "err", err) + limit = 25 + } + if limit < 1 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + + offset := (page - 1) * limit + + totalRows, results, err := db.SearchSong(ss.db, query, limit, offset) + if err != nil { + logger.Error("failed to query db for releases", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search for song.") + return + } + + totalPages := (int64(totalRows) + limit - 1) / limit + + var renderErr error + switch format { + case domain.SearchFormatTable: + renderErr = songsearchtable.SongSearchTable(songsearchtable.SongSearchTableParams{ + Songs: results, + TotalPages: int64(totalPages), + CurrentPage: page, + Query: query, + Limit: limit, + }).Render(r.Context(), w) + } + if renderErr != nil { + if errors.Is(renderErr, context.Canceled) { + return + } + + logger.Error("failed to render search song partial", "err", renderErr) + htmx.HxError(w, http.StatusInternalServerError, "Failed to render response.") + return + } +} diff --git a/internal/server/search.go b/internal/server/search.go @@ -0,0 +1,69 @@ +package server + +import ( + "context" + "errors" + "lyrics/internal/server/htmx" + "lyrics/internal/server/ui/views/search" + "net/http" + "strconv" +) + +func (ss *ServerState) Search(w http.ResponseWriter, r *http.Request) { + var err error + logger := ss.logger.With("handler", "Search") + + query := r.URL.Query().Get("query") + searchType := r.URL.Query().Get("type") + pageStr := r.URL.Query().Get("page") + limitStr := r.URL.Query().Get("limit") + + if limitStr == "" { + limitStr = "25" + } + + limit, err := strconv.ParseInt(limitStr, 10, 64) + if err != nil { + logger.Error("failed to parse limit value", "err", err) + limit = 25 + } + if limit < 1 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + + if pageStr == "" { + pageStr = "1" + } + + page, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil { + logger.Error("failed to parse page value", "err", err) + page = 1 + } + if page <= 0 { + page = 1 + } + + logger = logger.With("query", query, "type", searchType) + + htmx.HxRedirect(w, http.StatusOK, r.URL.String()) + err = search.Search(search.SearchParams{ + SearchType: searchType, + Query: query, + Limit: limit, + Page: page, + }).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render new song page", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + +} diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ @@ -1,7 +1,7 @@ package base templ Header() { - <header role="banner"> + <header class="header" role="banner"> <a href="/">lyrics</a> <nav aria-label="Primary navigation"> <ul> @@ -10,6 +10,14 @@ templ Header() { <li><a href="/artist/new">new artist</a></li> </ul> </nav> + <form hx-get="/search"> + <input name="query" type="text" placeholder="search"/> + <select name="type"> + <option value="artist">artist</option> + <option value="release">release</option> + <option value="song">song</option> + </select> + </form> </header> } @@ -35,6 +43,7 @@ templ Base(params BaseParams) { <link rel="stylesheet" href="/static/style.css" type="text/css"/> <link rel="stylesheet" href="/static/picker-list.css" type="text/css"/> <link rel="stylesheet" href="/static/toast.css" type="text/css"/> + <link rel="stylesheet" href="/static/table.css" type="text/css"/> for _, stylesheet := range params.PageStylesheets { <link rel="stylesheet" href={ "/static/" + stylesheet } type="text/css"/> } diff --git a/internal/server/ui/partials/artist-search-picker/artist-search-picker.go b/internal/server/ui/partials/artist-search-picker/artist-search-picker.go @@ -0,0 +1,7 @@ +package artistsearchpicker + +import "lyrics/internal/domain" + +type ArtistSearchPickerParams struct { + Artists []domain.Artist +} diff --git a/internal/server/ui/partials/artist-search-picker/artist-search-picker.templ b/internal/server/ui/partials/artist-search-picker/artist-search-picker.templ @@ -0,0 +1,39 @@ +package artistsearchpicker + +import "fmt" + +templ ArtistSearchPicker(params ArtistSearchPickerParams) { + if len(params.Artists) == 0 { + <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> + } else { + <fieldset class="picker-options" role="radiogroup"> + <legend>select artist</legend> + <ul> + for _, artist := range params.Artists { + {{ + label := artist.Name + if artist.LatinName != nil && artist.Disambiguation != nil { + label = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) + } else if artist.LatinName != nil { + label = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) + } else if artist.Disambiguation != nil { + label = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) + } + }} + <li> + <label> + <input + type="radio" + value={ fmt.Sprintf("%d", artist.ID) } + data-name={ artist.Name } + required + @change="selected = true; artistId = $event.target.value; artistName = $event.target.dataset.name" + /> + { label } + </label> + </li> + } + </ul> + </fieldset> + } +} diff --git a/internal/server/ui/partials/artist-search-results/artist-search-results.go b/internal/server/ui/partials/artist-search-results/artist-search-results.go @@ -1,7 +0,0 @@ -package artistsearchresults - -import "lyrics/internal/domain" - -type ArtistSearchResultsParams struct { - Artists []domain.Artist -} diff --git a/internal/server/ui/partials/artist-search-results/artist-search-results.templ b/internal/server/ui/partials/artist-search-results/artist-search-results.templ @@ -1,39 +0,0 @@ -package artistsearchresults - -import "fmt" - -templ ArtistSearchResults(params ArtistSearchResultsParams) { - if len(params.Artists) == 0 { - <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> - } else { - <fieldset class="picker-options" role="radiogroup"> - <legend>select artist</legend> - <ul> - for _, artist := range params.Artists { - {{ - label := artist.Name - if artist.LatinName != nil && artist.Disambiguation != nil { - label = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) - } else if artist.LatinName != nil { - label = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) - } else if artist.Disambiguation != nil { - label = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) - } - }} - <li> - <label> - <input - type="radio" - value={ fmt.Sprintf("%d", artist.ID) } - data-name={ artist.Name } - required - @change="selected = true; artistId = $event.target.value; artistName = $event.target.dataset.name" - /> - { label } - </label> - </li> - } - </ul> - </fieldset> - } -} diff --git a/internal/server/ui/partials/artist-search-similar/artist-search-similar.go b/internal/server/ui/partials/artist-search-similar/artist-search-similar.go @@ -0,0 +1,7 @@ +package artistsearchsimilar + +import "lyrics/internal/domain" + +type ArtistSearchSimilarParams struct { + Artists []domain.Artist +} diff --git a/internal/server/ui/partials/artist-search-similar/artist-search-similar.templ b/internal/server/ui/partials/artist-search-similar/artist-search-similar.templ @@ -0,0 +1,30 @@ +package artistsearchsimilar + +import ( + "fmt" + "lyrics/internal/server/routes" +) + +templ ArtistSearchSimilar(params ArtistSearchSimilarParams) { + if len(params.Artists) >= 1 { + <p class="section-label -muted">Similar artists</p> + <ul> + for _, artist := range params.Artists { + {{ + value := artist.Name + + if artist.LatinName != nil && artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) + } else if artist.LatinName != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) + } else if artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) + } + }} + <li title={ value }> + <a href={ routes.Artist(artist) }>{ value }</a> + </li> + } + </ul> + } +} diff --git a/internal/server/ui/partials/artist-search-table/artist-search-table.go b/internal/server/ui/partials/artist-search-table/artist-search-table.go @@ -0,0 +1,11 @@ +package artistsearchtable + +import "lyrics/internal/domain" + +type ArtistSearchTableParams struct { + Artists []domain.Artist + TotalPages int64 + CurrentPage int64 + Query string + Limit int64 +} diff --git a/internal/server/ui/partials/artist-search-table/artist-search-table.templ b/internal/server/ui/partials/artist-search-table/artist-search-table.templ @@ -0,0 +1,30 @@ +package artistsearchtable + +import ( + "fmt" + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/partials/table" +) + +templ ArtistSearchTable(params ArtistSearchTableParams) { + @table.Table(table.TableParams{ + Headers: []string{"name", "latin title"}, + TotalPages: params.TotalPages, + CurrentPage: params.CurrentPage, + GetURL: fmt.Sprintf("/artist/search?query=%s&type=song&limit=%d&format=table", params.Query, params.Limit), + PushURL: fmt.Sprintf("/search?query=%s&type=song&limit=%d", params.Query, params.Limit), + }) { + <tbody> + for _, artist := range params.Artists { + <tr> + <th><a href={ routes.Artist(artist) }>{ artist.Name }</a></th> + if artist.LatinName != nil { + <th>{ *artist.LatinName }</th> + } else { + <th></th> + } + </tr> + } + </tbody> + } +} diff --git a/internal/server/ui/partials/release-search-picker/release-search-picker.go b/internal/server/ui/partials/release-search-picker/release-search-picker.go @@ -0,0 +1,7 @@ +package releasesearchpicker + +import "lyrics/internal/domain" + +type ReleaseSearchPickerParams struct { + Releases []domain.Release +} diff --git a/internal/server/ui/partials/release-search-picker/release-search-picker.templ b/internal/server/ui/partials/release-search-picker/release-search-picker.templ @@ -0,0 +1,35 @@ +package releasesearchpicker + +import "fmt" + +templ ReleaseSearchPicker(params ReleaseSearchPickerParams) { + if len(params.Releases) == 0 { + <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> + } else { + <fieldset class="picker-options" role="radiogroup"> + <legend>select release</legend> + <ul> + for _, release := range params.Releases { + {{ + label := release.Title + if release.LatinTitle != nil { + label = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) + } + }} + <li> + <label> + <input + type="radio" + value={ fmt.Sprintf("%d", release.ID) } + data-name={ release.Title } + required + @change="item.selected = true; releaseId = $event.target.value; releaseTitle = $event.target.dataset.name" + /> + { label } + </label> + </li> + } + </ul> + </fieldset> + } +} diff --git a/internal/server/ui/partials/release-search-results/release-search-results.go b/internal/server/ui/partials/release-search-results/release-search-results.go @@ -1,7 +0,0 @@ -package releasesearchresults - -import "lyrics/internal/domain" - -type ReleaseSearchResultsParams struct { - Releases []domain.Release -} diff --git a/internal/server/ui/partials/release-search-results/release-search-results.templ b/internal/server/ui/partials/release-search-results/release-search-results.templ @@ -1,35 +0,0 @@ -package releasesearchresults - -import "fmt" - -templ ReleaseSearchResults(params ReleaseSearchResultsParams) { - if len(params.Releases) == 0 { - <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> - } else { - <fieldset class="picker-options" role="radiogroup"> - <legend>select release</legend> - <ul> - for _, release := range params.Releases { - {{ - label := release.Title - if release.LatinTitle != nil { - label = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) - } - }} - <li> - <label> - <input - type="radio" - value={ fmt.Sprintf("%d", release.ID) } - data-name={ release.Title } - required - @change="item.selected = true; releaseId = $event.target.value; releaseTitle = $event.target.dataset.name" - /> - { label } - </label> - </li> - } - </ul> - </fieldset> - } -} diff --git a/internal/server/ui/partials/release-search-similar/release-search-similar.go b/internal/server/ui/partials/release-search-similar/release-search-similar.go @@ -0,0 +1,7 @@ +package releasesearchsimilar + +import "lyrics/internal/domain" + +type ReleaseSearchSimilarParams struct { + Releases []domain.Release +} diff --git a/internal/server/ui/partials/release-search-similar/release-search-similar.templ b/internal/server/ui/partials/release-search-similar/release-search-similar.templ @@ -0,0 +1,26 @@ +package releasesearchsimilar + +import ( + "fmt" + "lyrics/internal/server/routes" +) + +templ ReleaseSearchSimilar(params ReleaseSearchSimilarParams) { + if len(params.Releases) >= 1 { + <p class="section-label -muted">Similar releases</p> + <ul> + for _, release := range params.Releases { + {{ + value := release.Title + + if release.LatinTitle != nil { + value = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) + } + }} + <li title={ value }> + <a href={ routes.Release(release) }>{ value }</a> + </li> + } + </ul> + } +} diff --git a/internal/server/ui/partials/release-search-table/release-search-table.go b/internal/server/ui/partials/release-search-table/release-search-table.go @@ -0,0 +1,11 @@ +package releasesearchtable + +import "lyrics/internal/domain" + +type ReleaseSearchTableParams struct { + Releases []domain.Release + TotalPages int64 + CurrentPage int64 + Query string + Limit int64 +} diff --git a/internal/server/ui/partials/release-search-table/release-search-table.templ b/internal/server/ui/partials/release-search-table/release-search-table.templ @@ -0,0 +1,48 @@ +package releasesearchtable + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/partials/table" +) + +templ ReleaseSearchTable(params ReleaseSearchTableParams) { + @table.Table(table.TableParams{ + Headers: []string{"title", "latin title", "artist", "type", "tracks"}, + TotalPages: params.TotalPages, + CurrentPage: params.CurrentPage, + GetURL: fmt.Sprintf("/release/search?format=table&query=%s&limit=%d", params.Query, params.Limit), + PushURL: fmt.Sprintf("/search?query=%s&type=release&limit=%d", params.Query, params.Limit), + }) { + <tbody> + for _, release := range params.Releases { + <tr> + <th><a href={ routes.Release(release) }>{ release.Title }</a></th> + if release.LatinTitle != nil { + <th>{ *release.LatinTitle }</th> + } else { + <th></th> + } + for idx, artist := range release.Artists { + <th> + <a href={ routes.Artist(artist.Artist) }> + if artist.Role == domain.RolePrimary { + { artist.Artist.Name } + if artist.Artist.LatinName != nil { + ({ *artist.Artist.LatinName }) + } + } + if idx < len(release.Artists) - 1 { + & + } + </a> + </th> + } + <th>{ release.Type }</th> + <th>{ release.ReleaseDate.Format("Jan 2, 2006") }</th> + </tr> + } + </tbody> + } +} diff --git a/internal/server/ui/partials/similar-artists-list/similar-artists-list.go b/internal/server/ui/partials/similar-artists-list/similar-artists-list.go @@ -1,7 +0,0 @@ -package similarartistslist - -import "lyrics/internal/domain" - -type SimilarArtistsListParams struct { - Artists []domain.Artist -} diff --git a/internal/server/ui/partials/similar-artists-list/similar-artists-list.templ b/internal/server/ui/partials/similar-artists-list/similar-artists-list.templ @@ -1,28 +0,0 @@ -package similarartistslist - -import "fmt" -import "lyrics/internal/server/routes" - -templ SimilarArtistsList(params SimilarArtistsListParams) { - if len(params.Artists) >= 1 { - <p class="section-label -muted">Similar artists</p> - <ul> - for _, artist := range params.Artists { - {{ - value := artist.Name - - if artist.LatinName != nil && artist.Disambiguation != nil { - value = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) - } else if artist.LatinName != nil { - value = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) - } else if artist.Disambiguation != nil { - value = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) - } - }} - <li title={ value }> - <a href={ routes.Artist(artist) }>{ value }</a> - </li> - } - </ul> - } -} diff --git a/internal/server/ui/partials/similar-releases-list/similar-releases-list.go b/internal/server/ui/partials/similar-releases-list/similar-releases-list.go @@ -1,7 +0,0 @@ -package similarreleaseslist - -import "lyrics/internal/domain" - -type SimilarReleasesListParams struct { - Releases []domain.Release -} diff --git a/internal/server/ui/partials/similar-releases-list/similar-releases-list.templ b/internal/server/ui/partials/similar-releases-list/similar-releases-list.templ @@ -1,24 +0,0 @@ -package similarreleaseslist - -import "fmt" -import "lyrics/internal/server/routes" - -templ SimilarReleasesList(params SimilarReleasesListParams) { - if len(params.Releases) >= 1 { - <p class="section-label -muted">Similar releases</p> - <ul> - for _, release := range params.Releases { - {{ - value := release.Title - - if release.LatinTitle != nil { - value = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) - } - }} - <li title={ value }> - <a href={ routes.Release(release) }>{ value }</a> - </li> - } - </ul> - } -} diff --git a/internal/server/ui/partials/song-search-table/song-search-table.go b/internal/server/ui/partials/song-search-table/song-search-table.go @@ -0,0 +1,11 @@ +package songsearchtable + +import "lyrics/internal/domain" + +type SongSearchTableParams struct { + Songs []domain.Song + TotalPages int64 + CurrentPage int64 + Query string + Limit int64 +} diff --git a/internal/server/ui/partials/song-search-table/song-search-table.templ b/internal/server/ui/partials/song-search-table/song-search-table.templ @@ -0,0 +1,45 @@ +package songsearchtable + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/partials/table" +) + +templ SongSearchTable(params SongSearchTableParams) { + @table.Table(table.TableParams{ + Headers: []string{"title", "latin title", "artist", "release"}, + TotalPages: params.TotalPages, + CurrentPage: params.CurrentPage, + GetURL: fmt.Sprintf("/song/search?query=%s&type=song&limit=%d&format=table", params.Query, params.Limit), + PushURL: fmt.Sprintf("/search?query=%s&type=song&limit=%d", params.Query, params.Limit), + }) { + for _, song := range params.Songs { + <tr> + <th><a href={ routes.Song(song) }>{ song.Title }</a></th> + if song.LatinTitle != nil { + <th>{ *song.LatinTitle }</th> + } else { + <th></th> + } + for idx, artist := range song.Artists { + <th> + <a href={ routes.Artist(artist.Artist) }> + if artist.Role == domain.RolePrimary { + { artist.Artist.Name } + if artist.Artist.LatinName != nil { + ({ *artist.Artist.LatinName }) + } + } + if idx < len(song.Artists) - 1 { + & + } + </a> + </th> + } + <th>{ song.ReleaseDate.Format("Jan 2, 2006") }</th> + </tr> + } + } +} diff --git a/internal/server/ui/partials/table/table.css b/internal/server/ui/partials/table/table.css @@ -0,0 +1,5 @@ +.table-pagination { + ul { + padding-left: 0; + } +} diff --git a/internal/server/ui/partials/table/table.go b/internal/server/ui/partials/table/table.go @@ -0,0 +1,9 @@ +package table + +type TableParams struct { + GetURL string + PushURL string + Headers []string + CurrentPage int64 + TotalPages int64 +} diff --git a/internal/server/ui/partials/table/table.templ b/internal/server/ui/partials/table/table.templ @@ -0,0 +1,101 @@ +package table + +import "fmt" + +templ Table(params TableParams) { + <div class="table-wrapper"> + <table> + <thead> + <tr> + for _, header := range params.Headers { + <th>{ header }</th> + } + </tr> + </thead> + { children... } + </table> + <nav class="table-pagination" aria-label="Pagination"> + <ul> + <li> + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, params.CurrentPage-1) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, params.CurrentPage-1) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + if params.CurrentPage <= 1 { + disabled + } + > + prev + </button> + </li> + if params.CurrentPage - 5 > 0 { + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, 1) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, 1) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + > + 1 + </button> + if params.CurrentPage - 5 != 1 { + <button disabled>...</button> + } + } + for i := params.CurrentPage - 4; i < params.CurrentPage; i++ { + if i > 0 { + <li> + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, i) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, i) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + > + { i } + </button> + </li> + } + } + <li><button class="-primary">{ params.CurrentPage }</button></li> + for i := params.CurrentPage + 1; i < params.CurrentPage + 5; i++ { + if i <= params.TotalPages { + <li> + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, i) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, i) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + > + { i } + </button> + </li> + } + } + if params.CurrentPage + 4 < params.TotalPages { + if params.CurrentPage + 5 != params.TotalPages { + <button disabled>...</button> + } + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, params.TotalPages) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, params.TotalPages) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + >{ params.TotalPages }</button> + } + <li> + <button + hx-get={ fmt.Sprintf("%s&page=%d", params.GetURL, params.CurrentPage+1) } + hx-push-url={ fmt.Sprintf("%s&page=%d", params.PushURL, params.CurrentPage+1) } + hx-target=".table-wrapper" + hx-swap="outerHTML" + if params.CurrentPage >= params.TotalPages { + disabled + } + > + next + </button> + </li> + </ul> + </nav> + </div> +} diff --git a/internal/server/ui/style.css b/internal/server/ui/style.css @@ -12,3 +12,7 @@ dl { } } } + +table { + width: 100%; +} diff --git a/internal/server/ui/views/search/search.go b/internal/server/ui/views/search/search.go @@ -0,0 +1,8 @@ +package search + +type SearchParams struct { + SearchType string + Query string + Limit int64 + Page int64 +} diff --git a/internal/server/ui/views/search/search.templ b/internal/server/ui/views/search/search.templ @@ -0,0 +1,37 @@ +package search + +import ( + "fmt" + "lyrics/internal/server/ui/layouts/base" + "lyrics/internal/server/ui/partials/table" +) + +templ Search(params SearchParams) { + {{ + var headers []string + switch params.SearchType { + case "song": + headers = append(headers, []string{"title", "latin title", "artist", "release"}...) + case "release": + headers = append(headers, []string{"title", "latin title", "artist", "type", "tracks"}...) + case "artist": + headers = append(headers, []string{"name", "latin title"}...) + } + }} + @base.Base(base.BaseParams{Title: "search"}) { + <div class="table-wrapper"> + @table.Table(table.TableParams{ + Headers: headers, + TotalPages: 0, + CurrentPage: params.Page, + }) { + <tbody + hx-get={ fmt.Sprintf("/%s/search?format=table&query=%s&limit=%d&page=%d", params.SearchType, params.Query, params.Limit, params.Page) } + hx-trigger="load" + hx-target=".table-wrapper" + hx-swap="outerHTML" + ></tbody> + } + </div> + } +} diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh @@ -17,6 +17,7 @@ minify internal/server/toast/toast.css -o static/files/toast.css minify internal/server/ui/views/song/song.css -o static/files/song.css minify internal/server/ui/views/artist/artist.css -o static/files/artist.css minify internal/server/ui/views/new-artist/new-artist.css -o static/files/new-artist.css +minify internal/server/ui/partials/table/table.css -o static/files/table.css minify internal/server/ui/views/release/release.css -o static/files/release.css minify internal/server/ui/views/new-release/new-release.css -o static/files/new-release.css minify internal/server/ui/views/new-song/new-song.css -o static/files/new-song.css diff --git a/static/minimal.css b/static/minimal.css @@ -25,6 +25,7 @@ PERFORMANCE OF THIS SOFTWARE. --primary: #4070a0; --text: #1a1a1a; --secondary: #808080; + --off-white: #f7f7f7; --light-gray: #e8e8e8; --dark-gray: #c0c0c0; --red: #a05050; @@ -208,6 +209,7 @@ label { header[role="banner"] { display: flex; + align-items: center; justify-content: space-between; gap: 15px; } @@ -242,3 +244,26 @@ nav { text-underline-offset: 3px; } } + +table { + border-collapse: separate; + border: 2px solid var(--dark-gray); + text-align: left; +} + +thead { + background-color: var(--off-white); + + th { + border: 1px solid var(--dark-gray); + } +} + +th, +td { + padding: 0.1rem 0.5rem; +} + +tbody>tr:nth-of-type(even) { + background-color: var(--light-gray); +} diff --git a/tasks/done/add_search_bar.txt b/tasks/done/add_search_bar.txt @@ -0,0 +1,2 @@ +add search bar @feat +id: yrqxnpqlnuypknvx