├── 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 |
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 |

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 |
47 |
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 | }
--------------------------------------------------------------------------------