├── Procfile ├── go.mod ├── config ├── port.go └── api.go ├── assets ├── static │ └── favicon.ico └── css │ ├── error.css │ ├── artist.css │ └── app.css ├── models ├── error.go ├── date.go ├── locations.go ├── relations.go ├── maps.go └── artist.go ├── data └── data.go ├── templates ├── error.html ├── artist.html └── index.html ├── utils ├── fetch_Locations.go ├── maps_utils.go ├── filter_utils.go └── fetch_utils.go ├── handlers ├── handle_errors.go ├── handle_assets.go ├── handle_templates.go ├── handle_maps.go ├── handle_Search.go └── handle_artists.go ├── main.go └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/groupie-tracker 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module groupie_tracker 2 | 3 | go 1.22.3 4 | -------------------------------------------------------------------------------- /config/port.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const Port = ":8080" 4 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/groupie-tracker/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Data struct to hold data for error 4 | type ErrorData struct { 5 | Error string 6 | Code int 7 | Message string 8 | } -------------------------------------------------------------------------------- /config/api.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | ARTISTS_API_URL = "https://groupietrackers.herokuapp.com/api" 5 | 6 | MAP_API_URL = "https://nominatim.openstreetmap.org/search.php" 7 | ) 8 | -------------------------------------------------------------------------------- /models/date.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Date struct { 4 | ID int `json:"id"` 5 | Dates []string `json:"dates"` 6 | } 7 | 8 | type Dates struct { 9 | Index []Date `json:"index"` 10 | } -------------------------------------------------------------------------------- /data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "groupie_tracker/models" 4 | 5 | var ( 6 | Artists []models.Artist 7 | Locations models.Locations 8 | Dates models.Dates 9 | Relations models.Relations 10 | CombinedData models.CombinedData 11 | ) 12 | -------------------------------------------------------------------------------- /models/locations.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Location struct { 4 | ID int `json:"id"` 5 | Locations []string `json:"locations"` 6 | DatesURL string `json:"dates"` 7 | } 8 | 9 | type Locations struct { 10 | Index []Location `json:"index"` 11 | } 12 | -------------------------------------------------------------------------------- /models/relations.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Relation struct { 4 | ID int `json:"id"` 5 | DatesLocations map[string][]string `json:"datesLocations"` 6 | } 7 | 8 | type Relations struct { 9 | Index []Relation `json:"index"` 10 | } 11 | -------------------------------------------------------------------------------- /models/maps.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type GeocodeResponse []struct { 4 | Lat string `json:"lat"` 5 | Lng string `json:"lon"` 6 | Name string `json:"name"` 7 | DisplayName string `json:"display_name"` 8 | } 9 | 10 | // Coordinates for geocoded locations 11 | type Coordinates struct { 12 | Lat float64 13 | Lng float64 14 | Name string 15 | LocationName string 16 | } 17 | 18 | type CoordinatesOfArtist struct { 19 | Coordinates []Coordinates 20 | } 21 | -------------------------------------------------------------------------------- /assets/css/error.css: -------------------------------------------------------------------------------- 1 | .error-card { 2 | backdrop-filter: blur(16px); 3 | background-color: rgba(17, 25, 40, 0.25); 4 | border-radius: 12px; 5 | border: 1px solid rgba(255, 255, 255, 0.125); 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | text-align: center; 11 | width: 230px; 12 | padding: 40px 20px; 13 | margin:250px auto 0; 14 | width: 50%; 15 | text-align: center; 16 | } 17 | 18 | .error-card h1 { 19 | font-size: 2rem; 20 | margin-bottom: 20px; 21 | } 22 | 23 | .error-card p { 24 | font-size: 1.2em; 25 | margin: 20px 0; 26 | } 27 | 28 | .error-card .btn { 29 | margin-top: 20px; 30 | } -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{.Error}} 11 | 12 | 13 | 14 |
15 |
16 |
17 |

{{.Error}}

18 |

{{.Message}}

19 | Back to Home 20 |
21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /models/artist.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Artist struct { 4 | ID int `json:"id"` 5 | Image string `json:"image"` 6 | Name string `json:"name"` 7 | Members []string `json:"members"` 8 | CreationDate int `json:"creationDate"` 9 | FirstAlbum string `json:"firstAlbum"` 10 | Type string 11 | Location Location 12 | Relation Relation 13 | Date Date 14 | } 15 | 16 | type ArtistsPageData struct { 17 | Artists []Artist 18 | } 19 | 20 | // CombinedData structure to hold all fetched data. 21 | type CombinedData struct { 22 | Artists []Artist 23 | Locations []Location 24 | Dates []Date 25 | Relations []Relation 26 | } 27 | 28 | type Output struct { 29 | To_displayed CombinedData 30 | For_search CombinedData 31 | } 32 | -------------------------------------------------------------------------------- /utils/fetch_Locations.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | "groupie_tracker/data" 7 | "groupie_tracker/models" 8 | ) 9 | 10 | func FetchLocations() []models.Location { 11 | var arr []string 12 | for _, v := range data.Locations.Index { 13 | for _, place := range v.Locations { 14 | place = strings.ReplaceAll(place, "-", ", ") 15 | if !CheckRepeatLocs(place, arr) { 16 | arr = append(arr, place) 17 | } else { 18 | continue 19 | } 20 | } 21 | } 22 | // fmt.Println("arr", arr) 23 | loca := models.Location{ 24 | ID: 0, 25 | Locations: arr, 26 | } 27 | var loats []models.Location 28 | loats = append(loats, loca) 29 | return loats 30 | } 31 | 32 | func CheckRepeatLocs(s string, arr []string) bool { 33 | for i := 0; i < len(arr); i++ { 34 | if arr[i] == s { 35 | return true 36 | } 37 | } 38 | return false 39 | } 40 | -------------------------------------------------------------------------------- /handlers/handle_errors.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "groupie_tracker/models" 8 | ) 9 | 10 | var Error500 bool 11 | 12 | // RenderError renders an error page with a specific status code and message. 13 | // It takes an HTTP ResponseWriter to write the response, an HTTP status code, 14 | // and a message to display on the error page. If rendering the template fails, 15 | // it falls back to a generic 500 Internal Server Error page. 16 | func RenderError(w http.ResponseWriter, statusCode int, message string) { 17 | 18 | err := RenderTemplate(w, "error.html", statusCode, models.ErrorData{ 19 | Error: http.StatusText(statusCode), 20 | Code: statusCode, 21 | Message: message, 22 | }) 23 | 24 | if err != nil { 25 | http.Error(w, "500 | Internal Server Error!", http.StatusInternalServerError) 26 | fmt.Println("Error parsing template:", err) 27 | return 28 | } 29 | } -------------------------------------------------------------------------------- /handlers/handle_assets.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // AssetsHandler handles requests for static assets. 10 | func AssetsHandler(w http.ResponseWriter, r *http.Request) { 11 | // Block direct access to the /assets/ directory 12 | if r.URL.Path == "/assets/" || strings.HasSuffix(r.URL.Path, "/") { 13 | RenderError(w, http.StatusForbidden, "403 | Access to this resource is forbidden !") 14 | return 15 | } 16 | 17 | // Serve the asset file 18 | filePath := "./assets" + strings.TrimPrefix(r.URL.Path, "/assets") 19 | if !isFileExists(filePath) { 20 | // If the requested file or path does not exist, render a styled 404 page 21 | RenderError(w, http.StatusNotFound, "404 | Page Not Found") 22 | return 23 | } 24 | 25 | // File exists, serve it 26 | http.ServeFile(w, r, filePath) 27 | } 28 | 29 | // isFileExists checks if a file exists at the given path 30 | func isFileExists(filePath string) bool { 31 | if filePath == "" { 32 | // Prevent checking an empty path 33 | return false 34 | } 35 | 36 | info, err := os.Stat(filePath) 37 | if err != nil || info.IsDir() { 38 | // Return false if the file doesn't exist or it's a directory 39 | return false 40 | } 41 | return true 42 | } 43 | -------------------------------------------------------------------------------- /handlers/handle_templates.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | var Templates *template.Template 11 | 12 | // ParseTemplates parses all HTML templates from the "templates" directory 13 | // and stores them in the global variable "Templates". 14 | // If parsing fails, it logs error. 15 | func ParseTemplates() { 16 | var err error 17 | Templates, err = template.ParseGlob("templates/*.html") 18 | if err != nil { 19 | log.Printf("Failed to parse templates: %v", err) 20 | } 21 | } 22 | 23 | // RenderTemplate renders a pre-parsed template with the provided data. 24 | // It takes an HTTP ResponseWriter to write the response, the name of the template to render (tmpl), 25 | // the HTTP status code, and the data to pass to the template. 26 | // If the template is not found, it returns a 500 error. Otherwise, it returns any error encountered during rendering. 27 | func RenderTemplate(w http.ResponseWriter, tmpl string, statusCode int, data any) error { 28 | t := Templates.Lookup(tmpl) 29 | if t == nil { 30 | http.Error(w, "500 | Internal Server Error", http.StatusInternalServerError) 31 | return fmt.Errorf("template %s not found", tmpl) 32 | } 33 | 34 | w.WriteHeader(statusCode) 35 | return t.Execute(w, data) 36 | } 37 | -------------------------------------------------------------------------------- /utils/maps_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | 11 | "groupie_tracker/config" 12 | "groupie_tracker/models" 13 | ) 14 | 15 | func Geocode(location string) (models.Coordinates, error) { 16 | var coordinates models.Coordinates 17 | u, _ := url.Parse(config.MAP_API_URL) 18 | q := u.Query() 19 | q.Set("q", location) 20 | q.Set("format", "jsonv2") 21 | u.RawQuery = q.Encode() 22 | 23 | resp, err := http.Get(u.String()) 24 | if err != nil { 25 | return coordinates, err 26 | } 27 | defer resp.Body.Close() 28 | 29 | body, err := io.ReadAll(resp.Body) 30 | if err != nil { 31 | return coordinates, err 32 | } 33 | 34 | var geocodeResponse models.GeocodeResponse 35 | err = json.Unmarshal(body, &geocodeResponse) 36 | if err != nil { 37 | return coordinates, err 38 | } 39 | 40 | if len(geocodeResponse) == 0 { 41 | return coordinates, fmt.Errorf("no results found for location: %s", location) 42 | } 43 | 44 | // Get the first result 45 | result := geocodeResponse[0] 46 | coordinates.Lat, err = strconv.ParseFloat(result.Lat, 64) 47 | if err != nil { 48 | return coordinates, fmt.Errorf("failed to parse latitude: %v", err) 49 | } 50 | coordinates.Lng, err = strconv.ParseFloat(result.Lng, 64) 51 | if err != nil { 52 | return coordinates, fmt.Errorf("failed to parse longitude: %v", err) 53 | } 54 | 55 | coordinates.Name = result.Name 56 | coordinates.LocationName = result.DisplayName 57 | 58 | return coordinates, nil 59 | } 60 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "groupie_tracker/config" 9 | "groupie_tracker/data" 10 | "groupie_tracker/handlers" 11 | "groupie_tracker/utils" 12 | ) 13 | 14 | func init() { 15 | // Initialize all data before starting the server 16 | var err error 17 | data.CombinedData, err = utils.FetchAllData() 18 | if err != nil { 19 | fmt.Printf("Failed to fetch data: %v", err) 20 | } 21 | } 22 | 23 | func main() { 24 | // If data is nil, attempt to fetch it again 25 | if len(data.CombinedData.Artists) == 0 { 26 | fmt.Println("Data was not successfully fetched during init. Retrying...") 27 | var err error 28 | data.CombinedData, err = utils.FetchAllData() 29 | if err != nil { 30 | log.Fatalf("Failed to fetch data: %v\n", err) 31 | } 32 | fmt.Println("Data fetched successfully after retry.") 33 | } 34 | 35 | // Handle requests for assets using the custom handler 36 | http.HandleFunc("/assets/", handlers.AssetsHandler) 37 | 38 | // Parse all HTML templates before starting the server 39 | handlers.ParseTemplates() 40 | 41 | // Route handlers 42 | http.HandleFunc("/", handlers.MainHandler) // Root route (home page) 43 | http.HandleFunc("/filter", handlers.MainHandler) 44 | http.HandleFunc("/artist/{id}", handlers.ArtistHandler) // Artist detail page 45 | http.HandleFunc("/search", handlers.Search) 46 | http.HandleFunc("/locations/{id}", handlers.GeocodeLocations) 47 | // Start the server 48 | serverPort := config.Port 49 | fmt.Println("Starting the server on http://localhost" + serverPort) 50 | log.Println(http.ListenAndServe(serverPort, nil)) 51 | } 52 | -------------------------------------------------------------------------------- /handlers/handle_maps.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "groupie_tracker/models" 10 | "groupie_tracker/utils" 11 | ) 12 | 13 | func GeocodeLocations(w http.ResponseWriter, r *http.Request) { 14 | id := r.PathValue("id") 15 | var coordinatesOfArtist models.CoordinatesOfArtist 16 | var locations models.Location 17 | 18 | // Fetch artist locations 19 | if err := utils.Fetch("/locations/"+id, &locations); err != nil { 20 | RenderError(w, http.StatusInternalServerError, "500 | Status Internal Server Error") 21 | return 22 | } 23 | 24 | for _, location := range locations.Locations { 25 | var coordinates models.Coordinates 26 | var err error 27 | 28 | // Attempt to fetch coordinates using the full location 29 | coordinates, err = utils.Geocode(location) 30 | if err != nil { 31 | // If an error occurs, trim the location to remove everything after the last hyphen 32 | if lastHyphen := strings.LastIndex(location, "-"); lastHyphen != -1 { 33 | location = location[:lastHyphen] 34 | // Try fetching coordinates again with the trimmed location 35 | coordinates, err = utils.Geocode(location) 36 | } 37 | } 38 | 39 | // If both attempts fail, continue to the next location 40 | if err != nil { 41 | fmt.Println("no data found for the given location:", location) 42 | continue 43 | } 44 | 45 | // Append the successfully geocoded location to the coordinatesOfArtist 46 | coordinatesOfArtist.Coordinates = append(coordinatesOfArtist.Coordinates, coordinates) 47 | } 48 | 49 | // Encode the coordinatesOfArtist to JSON and write to the response 50 | w.Header().Set("Content-Type", "application/json") 51 | if err := json.NewEncoder(w).Encode(coordinatesOfArtist); err != nil { 52 | RenderError(w, http.StatusInternalServerError, "500 | Status Internal Server Error") 53 | return 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /utils/filter_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "groupie_tracker/models" 8 | ) 9 | 10 | // FilterData applies filters to the artist data 11 | func FilterData( 12 | data models.CombinedData, 13 | creationDateMin, creationDateMax, 14 | firstAlbumMin, firstAlbumMax int, 15 | location string, 16 | members []int, 17 | ) []models.Artist { 18 | var filteredArtists []models.Artist 19 | id := []int{} 20 | for _, artist := range data.Artists { 21 | // Filter by creation date, only if the range is valid 22 | if creationDateMax >= creationDateMin { 23 | if artist.CreationDate < creationDateMin || artist.CreationDate > creationDateMax { 24 | continue 25 | } 26 | } 27 | 28 | // Filter by first album date, only if the range is valid 29 | firstAlbumYear, err := strconv.Atoi(strings.Split(artist.FirstAlbum, "-")[2]) 30 | if err == nil && firstAlbumMax >= firstAlbumMin { 31 | if firstAlbumYear < firstAlbumMin || firstAlbumYear > firstAlbumMax { 32 | continue 33 | } 34 | } 35 | // Filter by number of members 36 | if len(members) > 0 && !intInSlice(len(artist.Members), members) { 37 | continue 38 | } 39 | 40 | if !Exist(id, artist.ID) && Checklocation(artist.ID, location, id, data.Locations) { 41 | id = append(id, artist.ID) 42 | } else { 43 | continue 44 | } 45 | 46 | } 47 | // Filter locations 48 | for _, id2 := range id { 49 | artist, _ := FetchArtist(strconv.Itoa(id2)) 50 | filteredArtists = append(filteredArtists, artist) 51 | } 52 | // fmt.Println(filteredArtists) 53 | return filteredArtists 54 | } 55 | 56 | // intInSlice checks if an integer is in a slice 57 | func intInSlice(value int, list []int) bool { 58 | for _, v := range list { 59 | if v == value { 60 | return true 61 | } 62 | } 63 | return false 64 | } 65 | 66 | func Exist(ids []int, nb int) bool { 67 | for _, id := range ids { 68 | if id == nb { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | 75 | func Checklocation(id1 int, location string, ids []int, locations []models.Location) bool { 76 | if location == "" { 77 | return true 78 | } 79 | for _, artist := range locations { 80 | // for _, v := range artist.ID { 81 | if artist.ID == id1 { 82 | for _, place := range artist.Locations { 83 | if strings.Contains(strings.ToLower(place), strings.ToLower(location)) { 84 | if !Exist(ids, artist.ID) { 85 | return true 86 | } 87 | continue 88 | } 89 | } 90 | } 91 | // } 92 | } 93 | return false 94 | } 95 | 96 | // if artist.ID == id1 { 97 | 98 | // } 99 | -------------------------------------------------------------------------------- /handlers/handle_Search.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "groupie_tracker/data" 9 | "groupie_tracker/models" 10 | "groupie_tracker/utils" 11 | ) 12 | 13 | func Search(w http.ResponseWriter, r *http.Request) { 14 | if r.URL.Path != "/search" { 15 | RenderError(w, http.StatusNotFound, "404 | The page you are looking for does not exist.") 16 | return 17 | } 18 | 19 | if r.Method != http.MethodGet { 20 | RenderError(w, http.StatusMethodNotAllowed, "405 | Method Not Allowed: Use GET") 21 | return 22 | } 23 | id := []int{} 24 | Key := r.FormValue("Search") 25 | Key = strings.TrimSpace(Key) 26 | Key = strings.ToLower(Key) 27 | var Newdata models.CombinedData 28 | for _, l := range data.Artists { 29 | if (strings.Contains(strings.ToLower(l.Name), Key)) || strings.ToLower(l.FirstAlbum) == Key || strconv.Itoa(l.CreationDate) == Key { 30 | if !Exist(id, l.ID) { 31 | id = append(id, l.ID) 32 | } 33 | } 34 | for _, M := range l.Members { 35 | if strings.Contains(strings.ToLower(M), Key) && !Exist(id, l.ID) { 36 | id = append(id, l.ID) 37 | } 38 | } 39 | } 40 | 41 | for _, j := range data.Locations.Index { 42 | for _, J := range j.Locations { 43 | if strings.Contains(J, Key) { 44 | if !Exist(id, j.ID) { 45 | id = append(id, j.ID) 46 | } 47 | } 48 | } 49 | for _, j := range data.Dates.Index { 50 | for _, i := range j.Dates { 51 | if strings.ToLower(i) == Key { 52 | if !Exist(id, j.ID) { 53 | id = append(id, j.ID) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | if len(id) == 0 { 60 | RenderError(w, http.StatusAccepted, "NO artists") 61 | return 62 | } 63 | for _, ids := range id { 64 | new, err := utils.FetchArtist(strconv.Itoa(ids)) 65 | if err != nil { 66 | RenderError(w, http.StatusInternalServerError, "500 | Failed to render artist detail page.") 67 | } 68 | Newdata.Artists = append(Newdata.Artists, new) 69 | } 70 | Newdata.Locations = utils.FetchLocations() 71 | type Output struct { 72 | To_displayed models.CombinedData 73 | For_search models.CombinedData 74 | } 75 | 76 | // Create a variable of type Output and initialize it 77 | affiche := Output{ 78 | To_displayed: Newdata, // Ensure Newdata is of type models.CombinedData 79 | For_search: data.CombinedData, 80 | // Ensure data.CombinedData is of type models.CombinedData 81 | } 82 | 83 | if err := RenderTemplate(w, "index.html", http.StatusOK, affiche); err != nil { 84 | RenderError(w, http.StatusInternalServerError, "500 | Failed to render the page.") 85 | return 86 | } 87 | } 88 | 89 | func Exist(ids []int, nb int) bool { 90 | for _, id := range ids { 91 | if id == nb { 92 | return true 93 | } 94 | } 95 | return false 96 | } 97 | -------------------------------------------------------------------------------- /assets/css/artist.css: -------------------------------------------------------------------------------- 1 | .detail-container { 2 | max-width: 900px; 3 | margin: 0 auto; 4 | backdrop-filter: blur(16px); 5 | background-color: rgba(17, 25, 40, 0.7); 6 | border-radius: 15px; 7 | border: 1px solid rgba(255, 255, 255, 0.2); 8 | padding: 30px 40px; 9 | text-align: center; 10 | color: white; 11 | min-height: 100vh; 12 | } 13 | 14 | .detail-container h1 { 15 | font-size: 2.5rem; 16 | margin-bottom: 20px; 17 | } 18 | 19 | .detail-container h2 { 20 | text-align: center; 21 | font-size: 1.7rem; 22 | 23 | } 24 | 25 | .detail-container img { 26 | max-width: 400px; 27 | border-radius: 15px; 28 | margin: 20px 0; 29 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); 30 | } 31 | 32 | .detail-section { 33 | margin-bottom: 30px; 34 | text-align: left; 35 | } 36 | 37 | .detail-line { 38 | margin: 10px 0 20px; 39 | } 40 | 41 | .details-title { 42 | font-size: 1.3rem; 43 | font-weight: bold; 44 | } 45 | 46 | .details-title-2 { 47 | font-size: 1.3rem; 48 | font-weight: bold; 49 | margin-top: 20px; 50 | display: inline-block; 51 | } 52 | 53 | .ship-container { 54 | display: flex; 55 | flex-wrap: wrap; 56 | gap: 10px; 57 | margin-top: 10px; 58 | } 59 | 60 | .ship { 61 | background-color: rgba(255, 255, 255, 0.15); 62 | padding: 8px 15px; 63 | border-radius: 8px; 64 | text-align: center; 65 | font-size: 1rem; 66 | color: white; 67 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 68 | font-weight: 600; 69 | margin: 5px 0; 70 | text-transform: capitalize; 71 | } 72 | 73 | .ship-grid { 74 | margin-top: 15px; 75 | display: flex; 76 | gap: 10px; 77 | flex-wrap: wrap; 78 | } 79 | 80 | #map { 81 | background-color: white; 82 | height: 400px; 83 | border-radius: 20px; 84 | margin: 20px auto; 85 | } 86 | 87 | /* Responsive Styles */ 88 | @media (max-width: 970px) { 89 | .detail-container { 90 | padding: 20px; 91 | } 92 | 93 | .detail-container h1 { 94 | font-size: 2.5rem; 95 | margin: 10px 0 20px; 96 | } 97 | 98 | .detail-container img { 99 | max-width: 300px; 100 | } 101 | 102 | .details-title, 103 | .details-title-2 { 104 | font-size: 1.2rem; 105 | } 106 | 107 | .ship { 108 | font-size: 0.9rem; 109 | } 110 | 111 | .ship-grid, 112 | .location-dates-grid { 113 | justify-content: center; 114 | } 115 | } 116 | 117 | @media (max-width: 480px) { 118 | .detail-container { 119 | padding: 15px; 120 | } 121 | 122 | .detail-container h1 { 123 | font-size: 1.5rem; 124 | } 125 | 126 | .detail-container h2 { 127 | font-size: 1.2rem; 128 | } 129 | 130 | .detail-container img { 131 | max-width: 100%; 132 | } 133 | 134 | .details-title, 135 | .details-title-2 { 136 | font-size: 1rem; 137 | } 138 | 139 | .ship { 140 | font-size: 0.8rem; 141 | } 142 | 143 | 144 | } -------------------------------------------------------------------------------- /utils/fetch_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "sync" 10 | 11 | "groupie_tracker/config" 12 | "groupie_tracker/data" 13 | "groupie_tracker/models" 14 | ) 15 | 16 | // FetchArtist fetches the artist details, locations, relations, and dates concurrently. 17 | func FetchAllData() (models.CombinedData, error) { 18 | var ( 19 | wg sync.WaitGroup 20 | mu sync.Mutex 21 | err error 22 | ) 23 | 24 | // Define a helper function to fetch data concurrently and handle errors. 25 | fetchData := func(endpoint string, dest interface{}) { 26 | defer wg.Done() 27 | if fetchErr := Fetch(endpoint, dest); fetchErr != nil { 28 | mu.Lock() 29 | err = fmt.Errorf("error fetching data from %s: %v", endpoint, fetchErr) 30 | mu.Unlock() 31 | } 32 | } 33 | 34 | // Fetch related data concurrently 35 | wg.Add(4) 36 | go fetchData("/artists", &data.Artists) 37 | go fetchData("/dates", &data.Dates) 38 | go fetchData("/locations", &data.Locations) 39 | go fetchData("/relation", &data.Relations) 40 | wg.Wait() 41 | 42 | // Check if any errors occurred during concurrent fetching 43 | if err != nil { 44 | return models.CombinedData{}, err 45 | } 46 | 47 | return models.CombinedData{ 48 | Artists: data.Artists, 49 | Dates: data.Dates.Index, 50 | Locations: data.Locations.Index, 51 | Relations: data.Relations.Index, 52 | }, nil 53 | } 54 | 55 | // FetchArtist fetches the artist details, locations, relations, and dates concurrently. 56 | func FetchArtist(id string) (models.Artist, error) { 57 | newid, err := strconv.Atoi(id) 58 | if err != nil { 59 | return models.Artist{}, errors.New("404") 60 | } 61 | var artist models.Artist 62 | for _, v := range data.Artists { 63 | if v.ID == newid { 64 | artist.ID = v.ID 65 | artist.CreationDate = v.CreationDate 66 | artist.FirstAlbum = v.FirstAlbum 67 | artist.Image = v.Image 68 | artist.Members = v.Members 69 | artist.Name = v.Name 70 | artist.Type = v.Type 71 | } 72 | } 73 | 74 | var loca models.Location 75 | for _, loc := range data.Locations.Index { 76 | if loc.ID == newid { 77 | loca.Locations = loc.Locations 78 | } 79 | } 80 | var date models.Date 81 | for _, dat := range data.Dates.Index { 82 | if dat.ID == newid { 83 | date.Dates = dat.Dates 84 | } 85 | } 86 | var rel models.Relation 87 | for _, rela := range data.Relations.Index { 88 | if rela.ID == newid { 89 | rel.DatesLocations = rela.DatesLocations 90 | } 91 | } 92 | artist.Location = loca 93 | artist.Date = date 94 | artist.Relation = rel 95 | if artist.ID == 0 { 96 | return artist, errors.New("There's no Artist/Band to display !") 97 | } 98 | 99 | return artist, nil 100 | } 101 | 102 | // Fetch fetches data from the API based on the provided endpoint and unmarshals it into the given destination. 103 | // It takes an API endpoint as a string and a destination to unmarshal the JSON response into. 104 | // The function returns an error if the request fails or if the API responds with a non-200 status code. 105 | func Fetch(endpoint string, dest interface{}) error { 106 | resp, err := http.Get(config.ARTISTS_API_URL + endpoint) 107 | if err != nil { 108 | return err 109 | } 110 | defer resp.Body.Close() 111 | 112 | if resp.StatusCode != http.StatusOK { 113 | return fmt.Errorf("API returned status code %d", resp.StatusCode) 114 | } 115 | 116 | return json.NewDecoder(resp.Body).Decode(dest) 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Groupie Tracker 2 | 3 | ## Overview 4 | 5 | Groupie Tracker is a web application designed to display information about artists, their concert dates, and locations using data from a RESTful API. The application is built with a backend written in Go and a frontend using HTML and CSS, offering a user-friendly interface for exploring and visualizing artist information, concert schedules, and locations. 6 | 7 | ## Objectives 8 | 9 | - **Data Manipulation**: Retrieve and process data from a provided API that includes information about artists, concert dates, locations, and their relations. 10 | - **Visualization**: Present the data through various visualizations such as cards, tables, and graphics to enhance user experience. 11 | - **Client-Server Interaction**: Implement client-server communication to fetch data on user actions, ensuring a responsive and interactive web application. 12 | 13 | ## Components 14 | 15 | The application is divided into four main parts based on the API provided: 16 | 17 | 1. **Artists**: Contains information about bands and artists, including names, images, the start year of their activity, their first album release date, and members. 18 | 2. **Locations**: Lists the last and upcoming concert locations, with locations normalized to ensure readability. 19 | 3. **Dates**: Provides details on the last and upcoming concert dates, displayed in a visually appealing format. 20 | 4. **Relation**: Connects artists, dates, and locations, forming the link between the other components, allowing for a comprehensive view of artist activities. 21 | 22 | ## Features 23 | 24 | - **Artist Information**: View details about artists, including images, biographical information, and members. The index page lists all artists with limited information, while detailed views are available for each artist. 25 | - **Concert Dates and Locations**: Display upcoming and past concert dates for each artist, along with the venues where the concerts are held. Dates are presented in a visually distinctive format. 26 | - **Interactive Elements**: Engage with various UI components to fetch and view data dynamically, such as clicking on an artist card to view more details. 27 | - **Styling**: Consistent and professional styling throughout the application, including a polished look for the detail pages and normalization of location names (e.g., converting "florida-usa" to "Florida USA"). 28 | 29 | ## Setup and Installation 30 | 31 | 1. **Clone the Repository** 32 | ```bash 33 | git clone https://github.com/hamzamaach/groupie-tracker.git 34 | ``` 35 | 36 | 2. **Navigate to the Project Directory** 37 | ```bash 38 | cd groupie-tracker 39 | ``` 40 | 41 | 3. **Configure the Application** 42 | - Update the `config` package to set the API URL and port number as required. 43 | 44 | 4. **Run the Backend** 45 | - Ensure you have Go installed. 46 | - Run the server: 47 | ```bash 48 | go run main.go 49 | ``` 50 | 51 | 5. **Open the Application** 52 | - Open a browser and navigate to `http://localhost:` to view the application. 53 | 54 | ## Packages and Structure 55 | 56 | - **`handlers`**: Manages HTTP requests and routes, including fetching data from the API and rendering templates. 57 | - **`utils`**: Contains utility functions for tasks such as normalizing location names and handling common logic. 58 | - **`models`**: Defines the data structures used in the application, including separate structs for index and detail views. 59 | - **`config`**: Holds configuration data, including API URLs and server port numbers. 60 | 61 | ## Contributing 62 | 63 | Feel free to contribute to the project by opening issues or submitting pull requests. Ensure that any new code adheres to the project's coding standards and includes relevant tests. 64 | 65 | ## Contact 66 | 67 | For any questions or support, please contact [hamzamaach56@gmail.com](mailto:hamzamaach56@gmail.com). 68 | 69 | --- 70 | 71 | Thank you for using Groupie Tracker! We hope you enjoy exploring artist information and concert details. -------------------------------------------------------------------------------- /handlers/handle_artists.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "groupie_tracker/data" 9 | "groupie_tracker/models" 10 | "groupie_tracker/utils" 11 | ) 12 | 13 | // ArtistHandler handles requests to view details of a specific artist. 14 | // It takes an HTTP ResponseWriter to write the response and an HTTP Request. 15 | // It fetches the artist details, locations, relations, and concert dates from the API. 16 | // If successful, it renders the artist details page; otherwise, it renders an appropriate error page. 17 | func ArtistHandler(w http.ResponseWriter, r *http.Request) { 18 | if r.Method != http.MethodGet { 19 | RenderError(w, http.StatusMethodNotAllowed, "405 | Method Not Allowed: Use GET") 20 | return 21 | } 22 | id := r.PathValue("id") 23 | 24 | var artist models.Artist 25 | 26 | artist, err := utils.FetchArtist(id) 27 | if err != nil { 28 | if err.Error() == "404" { 29 | RenderError(w, http.StatusNotFound, "404 | Artist Not Found") 30 | } else { 31 | RenderError(w, http.StatusInternalServerError, "500 | Status Internal Server Error") 32 | } 33 | return 34 | } 35 | 36 | // Render the artist details template 37 | if err := RenderTemplate(w, "artist.html", http.StatusOK, artist); err != nil { 38 | RenderError(w, http.StatusInternalServerError, "500 | Failed to render artist detail page.") 39 | } 40 | } 41 | 42 | // MainHandler handles requests to the root URL and displays the list of artists. 43 | // It takes an HTTP ResponseWriter to write the response and an HTTP Request. 44 | // It fetches a summary list of all artists, assigns a type based on the number of members, 45 | // and renders the index page. If any errors occur, it renders the appropriate error page. 46 | func MainHandler(w http.ResponseWriter, r *http.Request) { 47 | if r.URL.Path != "/" && r.URL.Path != "/filter" { 48 | RenderError(w, http.StatusNotFound, "404 | The page you are looking for does not exist.") 49 | return 50 | } 51 | 52 | if r.Method != http.MethodGet { 53 | RenderError(w, http.StatusMethodNotAllowed, "405 | Method Not Allowed: Use GET") 54 | return 55 | } 56 | 57 | var allData models.Output 58 | allData.For_search = data.CombinedData 59 | 60 | if r.URL.Path == "/filter" { 61 | // Parse query parameters for range filters with error handling 62 | creationDate1, err := strconv.Atoi(r.URL.Query().Get("creation-date-1")) 63 | if err != nil { 64 | creationDate1 = 1950 65 | } 66 | 67 | creationDate2, err := strconv.Atoi(r.URL.Query().Get("creation-date-2")) 68 | if err != nil { 69 | creationDate2 = 2024 70 | } 71 | 72 | firstAlbum1, err := strconv.Atoi(r.URL.Query().Get("first-album-1")) 73 | if err != nil { 74 | firstAlbum1 = 1950 75 | } 76 | 77 | firstAlbum2, err := strconv.Atoi(r.URL.Query().Get("first-album-2")) 78 | if err != nil { 79 | firstAlbum2 = 2024 80 | } 81 | if creationDate1 > creationDate2 { 82 | creationDate1, creationDate2 = creationDate2, creationDate1 83 | } 84 | if firstAlbum1 > firstAlbum2 { 85 | firstAlbum1, firstAlbum2 = firstAlbum2, firstAlbum1 86 | } 87 | 88 | // Get members filter 89 | membersStr := r.URL.Query()["members"] 90 | var members []int 91 | for _, ms := range membersStr { 92 | memberVal, err := strconv.Atoi(ms) 93 | if err == nil { 94 | members = append(members, memberVal) 95 | } 96 | } 97 | 98 | location := r.URL.Query().Get("location") 99 | location = strings.ReplaceAll(location, ", ", "-") 100 | // Filter the data using the provided criteria 101 | filteredData := utils.FilterData(data.CombinedData, creationDate1, creationDate2, firstAlbum1, firstAlbum2, location, members) 102 | 103 | // Create a new CombinedData structure for To_displayed 104 | allData.To_displayed = models.CombinedData{ 105 | Artists: filteredData, // Set filtered artists 106 | Locations: utils.FetchLocations(), // Keep original locations, dates, relations 107 | Dates: data.CombinedData.Dates, 108 | Relations: data.CombinedData.Relations, 109 | } 110 | } else { 111 | allData.To_displayed = data.CombinedData 112 | } 113 | 114 | // Set the Type field based on the number of members 115 | for i := range data.Artists { 116 | if len(data.Artists[i].Members) == 1 { 117 | data.Artists[i].Type = "Artist" 118 | } else { 119 | data.Artists[i].Type = "Group of " + strconv.Itoa(len(data.Artists[i].Members)) 120 | } 121 | } 122 | 123 | if err := RenderTemplate(w, "index.html", http.StatusOK, allData); err != nil { 124 | RenderError(w, http.StatusInternalServerError, "500 | Failed to render the page.") 125 | return 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /templates/artist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | {{.Name}} - Artist Details 14 | 15 | 16 | 17 |
18 |

{{.Name}}

19 | {{.Name}} 20 | 21 |
22 |
23 | Creation Date: 24 | {{.CreationDate}} 25 |
26 |
27 | First Album: 28 | {{.FirstAlbum}} 29 |
30 |
31 |
32 |

Members

33 |
34 | {{range .Members}}{{.}}{{end}} 35 |
36 |
37 |
38 |
39 | 40 |
41 |

Concerts

42 | {{range $key, $value := .Relation.DatesLocations}} 43 |
44 | {{$key}} : 45 |
46 | {{range $value}}{{.}}{{end}} 47 |
48 |
49 | {{end}} 50 |
51 | 52 |
53 |

Locations

54 |
55 | {{range .Location.Locations}} 56 | {{.}} 57 | {{end}} 58 |
59 | 60 |
61 |
62 | 63 |
64 |

Concert Dates

65 |
66 | {{range .Date.Dates}}{{.}}{{end}} 67 |
68 |
69 | Back to Home 70 |
71 | 73 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | Groupie Tracker 11 | 12 | 13 | 14 |
15 |
16 | Groupie Tracker 17 |
18 | 19 |
20 |
21 | 28 | 29 | {{range .For_search.Artists}} 30 | 31 | 32 | 33 | {{range .Members}} 34 | 35 | {{end}} 36 | {{end}} 37 | {{range .For_search.Locations}} 38 | {{range .Locations}} 39 | 40 | {{end}} 41 | {{end}} 42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 53 |
54 | 57 | 60 |
61 |
62 |
63 | 65 |
66 | 70 | 74 |
75 |
76 |
77 | 79 | 88 |
89 |
90 | 92 |
93 | 95 | 97 | 99 | 101 | 103 | 105 | 107 | 109 | 110 |
111 |
112 |
113 | 115 |
116 |
117 | {{range .To_displayed.Artists}} 118 | 119 |
120 | 122 |
123 |

{{.Name}}

124 |

{{.Type}}

125 |
126 |
127 |
128 | {{end}} 129 | 130 |
131 |
132 | 133 | 134 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | background: rgb(0, 212, 255); 8 | background: linear-gradient(45deg, rgba(0, 212, 255, 1) 0%, rgba(11, 3, 45, 1) 100%); 9 | background-image: url(https://images.unsplash.com/photo-1619204715997-1367fe5812f1?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1889&q=80); 10 | background-size: cover; 11 | background-position: center; 12 | background-attachment: fixed; 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | font-weight: 800; 18 | } 19 | 20 | header { 21 | display: flex; 22 | margin: 0 30px 10px; 23 | justify-content: space-between; 24 | margin-right: 335px; 25 | } 26 | 27 | .title-container { 28 | width: 320px; 29 | } 30 | 31 | .title { 32 | font-family: 'Righteous', sans-serif; 33 | font-size: 2rem; 34 | color: #fff; 35 | font-weight: bolder; 36 | display: block; 37 | width: 100%; 38 | text-align: center; 39 | margin: 30px auto 30px; 40 | } 41 | 42 | .search-container { 43 | text-align: center; 44 | margin: 20px auto; 45 | margin-right: 340px; 46 | width: 100%; 47 | } 48 | 49 | .search-bar { 50 | width: 60%; 51 | padding: 12px 20px; 52 | margin: 8px 0; 53 | box-sizing: border-box; 54 | border-radius: 24px; 55 | border: 1px solid rgba(255, 255, 255, 0.5); 56 | background-color: rgba(255, 255, 255, 0.1); 57 | color: #fff; 58 | font-size: 16px; 59 | 60 | } 61 | 62 | .search-bar::placeholder { 63 | font-weight: bold; 64 | opacity: 0.5; 65 | color: #d1d0d0; 66 | } 67 | 68 | .search-bar:focus { 69 | outline: none; 70 | border-color: rgba(0, 212, 255, 1); 71 | background-color: rgba(255, 255, 255, 0.2); 72 | } 73 | 74 | .container { 75 | display: flex; 76 | } 77 | 78 | .cards { 79 | display: flex; 80 | align-items: center; 81 | justify-content: center; 82 | flex-wrap: wrap; 83 | gap: 10px; 84 | margin-right: 320px; 85 | } 86 | 87 | .card { 88 | backdrop-filter: blur(16px); 89 | background-color: rgba(17, 25, 40, 0.25); 90 | border-radius: 12px; 91 | border: 1px solid rgba(255, 255, 255, 0.125); 92 | padding: 20px 10px; 93 | display: flex; 94 | flex-direction: column; 95 | align-items: center; 96 | justify-content: center; 97 | text-align: center; 98 | width: 230px; 99 | min-height: 280px; 100 | transition: transform 0.3s ease, box-shadow 0.3s ease; 101 | cursor: pointer; 102 | } 103 | 104 | .card:hover { 105 | transform: translateY(-5px); 106 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); 107 | } 108 | 109 | .wrapper { 110 | width: 100%; 111 | height: 100%; 112 | 113 | } 114 | 115 | .banner-image { 116 | background-position: center; 117 | background-size: cover; 118 | height: 200px; 119 | width: 200px; 120 | border-radius: 12px; 121 | border: 1px solid rgba(255, 255, 255, 0.255) 122 | } 123 | 124 | h1 { 125 | font-family: 'Righteous', sans-serif; 126 | color: rgba(255, 255, 255, 0.98); 127 | font-size: 1.3rem; 128 | text-align: center; 129 | } 130 | 131 | p { 132 | color: #fff; 133 | font-size: 0.85rem; 134 | line-height: 150%; 135 | letter-spacing: 2px; 136 | } 137 | 138 | 139 | .btn { 140 | background: rgba(0, 212, 255, 0.9); 141 | color: rgba(255, 255, 255, 0.95); 142 | font-weight: bold; 143 | border: none; 144 | padding: 12px 24px; 145 | border-radius: 24px; 146 | font-size: 0.8rem; 147 | letter-spacing: 2px; 148 | cursor: pointer; 149 | } 150 | 151 | .search-btn { 152 | border-radius: 0 24px 24px 0; 153 | margin: 8px 0; 154 | } 155 | 156 | .search-container { 157 | text-align: center; 158 | margin: 20px auto; 159 | width: 100%; 160 | } 161 | 162 | .search { 163 | display: flex; 164 | margin: 8px 0 0 0; 165 | } 166 | 167 | .no-data { 168 | margin: 20px auto; 169 | text-align: center; 170 | min-height: 280px; 171 | font-size: 1.3rem; 172 | color: #fff; 173 | } 174 | 175 | .search-bar { 176 | min-width: 300px; 177 | padding: 12px 20px; 178 | box-sizing: border-box; 179 | border-radius: 24px 0 0 24px; 180 | border: 1px solid rgba(255, 255, 255, 0.5); 181 | background-color: rgba(255, 255, 255, 0.1); 182 | color: #fff; 183 | font-size: 16px; 184 | } 185 | 186 | .search-bar::placeholder { 187 | font-weight: bold; 188 | opacity: 0.5; 189 | color: #d1d0d0; 190 | } 191 | 192 | .search-bar:focus { 193 | outline: none; 194 | border-color: rgba(0, 212, 255, 1); 195 | background-color: rgba(255, 255, 255, 0.2); 196 | } 197 | 198 | .filter-container { 199 | border: 1px solid rgba(255, 255, 255, 0.5); 200 | height: fit-content; 201 | display: flex; 202 | flex-direction: column; 203 | max-width: 240px; 204 | gap: 20px; 205 | padding: 20px; 206 | background-color: rgba(255, 255, 255, 0.1); 207 | border-radius: 12px; 208 | backdrop-filter: blur(12px); 209 | position: fixed; 210 | right: 0; 211 | top: 0; 212 | margin-right: 30px; 213 | padding: 40px 20px; 214 | min-height: 300px; 215 | margin-top: 20px; 216 | color: #fff; 217 | font-weight: 700; 218 | text-align: center; 219 | } 220 | 221 | .filter-group { 222 | display: flex; 223 | flex-direction: column; 224 | gap: 20px; 225 | } 226 | 227 | .filter-dates { 228 | display: flex; 229 | justify-content: center; 230 | gap: 10px; 231 | } 232 | 233 | /* .filter-container input[type="number"] { 234 | width: 30% !important; 235 | 236 | } */ 237 | 238 | option { 239 | color: #000; 240 | border-radius: 5px; 241 | } 242 | 243 | .filter-container input[type="number"], 244 | .filter-container input[type="text"], 245 | select { 246 | width: 90%; 247 | padding: 10px; 248 | border-radius: 24px; 249 | border: 1px solid rgba(255, 255, 255, 0.5); 250 | background-color: rgba(255, 255, 255, 0.1); 251 | color: #fff; 252 | font-size: 16px; 253 | text-align: center; 254 | } 255 | 256 | .filter-container input::placeholder { 257 | font-weight: bold; 258 | opacity: 0.5; 259 | color: #d1d0d0; 260 | } 261 | 262 | .filter-container input:focus { 263 | outline: none; 264 | border-color: rgba(0, 212, 255, 1); 265 | background-color: rgba(255, 255, 255, 0.2); 266 | } 267 | 268 | .filter-label { 269 | margin-bottom: 10px; 270 | font-size: 1.1rem; 271 | display: block; 272 | } 273 | 274 | .members-checkboxes label { 275 | color: #fff; 276 | font-size: 1rem; 277 | margin-right: 15px; 278 | } 279 | 280 | .checkbox-group { 281 | display: flex; 282 | flex-wrap: wrap; 283 | gap: 10px; 284 | } 285 | 286 | .checkbox-group label { 287 | display: inline-flex; 288 | align-items: center; 289 | padding: 5px; 290 | background-color: rgba(255, 255, 255, 0.1); 291 | border-radius: 24px; 292 | cursor: pointer; 293 | transition: background-color 0.3s ease; 294 | } 295 | 296 | .checkbox-group input[type="checkbox"] { 297 | margin-right: 8px; 298 | cursor: pointer; 299 | } 300 | 301 | .checkbox-group label:hover { 302 | background-color: rgba(0, 212, 255, 0.1); 303 | } 304 | 305 | 306 | .btn:hover { 307 | background: rgba(0, 180, 220, 1); 308 | } 309 | 310 | 311 | 312 | 313 | @media (max-width: 915px) { 314 | header { 315 | flex-direction: column; 316 | align-items: center; 317 | } 318 | 319 | .title { 320 | margin-bottom: 5px; 321 | } 322 | 323 | } 324 | 325 | @media (max-width: 840px) { 326 | header { 327 | flex-direction: column; 328 | align-items: center; 329 | width: fit-content; 330 | } 331 | 332 | .title { 333 | margin-bottom: 5px; 334 | } 335 | 336 | .search-bar { 337 | border-radius: 24px 0 0 24px; 338 | border: 1px solid rgba(255, 255, 255, 0.5); 339 | background-color: rgba(255, 255, 255, 0.1); 340 | color: #fff; 341 | font-size: 12px; 342 | min-width: 200px; 343 | } 344 | 345 | .cards { 346 | gap: 5px; 347 | margin-right: 250px; 348 | } 349 | 350 | .filter-dates { 351 | flex-direction: column; 352 | gap: 5px; 353 | font-size: 0.8rem 354 | } 355 | 356 | .card { 357 | padding: 10px 5px; 358 | width: 130px; 359 | min-height: 225px; 360 | } 361 | 362 | .banner-image { 363 | height: 130px; 364 | width: 130px; 365 | } 366 | 367 | /* Place the filter form at the top */ 368 | .filter-container { 369 | /* position: relative; */ 370 | margin-right: 0; 371 | max-width: 200px; 372 | margin: 20px auto; 373 | padding: 20px; 374 | flex-direction: row; 375 | display: flex; 376 | flex-wrap: wrap; 377 | gap: 10px; 378 | } 379 | 380 | .filter-group { 381 | width: 100%; 382 | } 383 | } 384 | 385 | 386 | /* Responsive Styling */ 387 | @media (max-width: 600px) { 388 | .container { 389 | display: flex; 390 | flex-direction: column; 391 | justify-content: center; 392 | width: 100%; 393 | } 394 | 395 | .cards { 396 | gap: 5px; 397 | margin-right: 0px; 398 | } 399 | 400 | .filter-group { 401 | flex-direction: column; 402 | gap: 15px; 403 | } 404 | 405 | .filter-container input[type="number"], 406 | .filter-container input[type="text"] { 407 | max-width: 100%; 408 | } 409 | 410 | /* Make filter form fully responsive */ 411 | .filter-container { 412 | position: relative; 413 | max-width: 80%; 414 | width: 80%; 415 | margin: 0 auto 20px; 416 | padding: 15px; 417 | display: flex; 418 | flex-direction: column; 419 | gap: 15px; 420 | } 421 | 422 | .filter-container input[type="number"], 423 | .filter-container input[type="text"] { 424 | width: 100%; 425 | } 426 | 427 | .filter-group { 428 | flex-direction: column; 429 | width: 100%; 430 | } 431 | } 432 | 433 | @media (max-width: 430px) { 434 | 435 | .search-container { 436 | text-align: center; 437 | margin: 10px auto; 438 | width: 100%; 439 | } 440 | 441 | .search { 442 | display: flex; 443 | margin: 8px 0 0 0; 444 | } 445 | 446 | .cards { 447 | gap: 5px; 448 | margin-right: 0px; 449 | } 450 | 451 | .search-bar { 452 | min-width: 200px; 453 | padding: 5px 8px; 454 | box-sizing: border-box; 455 | border-radius: 24px 0 0 24px; 456 | border: 1px solid rgba(255, 255, 255, 0.5); 457 | background-color: rgba(255, 255, 255, 0.1); 458 | color: #fff; 459 | font-size: 14px; 460 | } 461 | 462 | header { 463 | margin: 0; 464 | } 465 | 466 | /* More compact view for small screens */ 467 | .filter-container { 468 | /* position: static; */ 469 | /* max-width: 100%; */ 470 | /* width: 100%; */ 471 | margin: 10px 0; 472 | padding: 10px; 473 | display: flex; 474 | flex-direction: column; 475 | gap: 10px; 476 | margin: 0 auto 10px; 477 | } 478 | 479 | .filter-group { 480 | flex-direction: column; 481 | width: 100%; 482 | gap: 10px; 483 | } 484 | 485 | .filter-container input[type="number"], 486 | .filter-container input[type="text"] { 487 | width: 80%; 488 | } 489 | 490 | .filter-dates { 491 | display: flex; 492 | justify-content: center; 493 | align-items: center; 494 | gap: 10px; 495 | } 496 | } --------------------------------------------------------------------------------