aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile2
-rw-r--r--api/dto/auth.go4
-rw-r--r--api/dto/conference.go36
-rw-r--r--api/dto/favourites.go10
-rw-r--r--api/dto/schedule.go10
-rw-r--r--api/dto/util.go6
-rw-r--r--api/handlers/auth.go11
-rw-r--r--api/handlers/conference.go103
-rw-r--r--api/handlers/favourites.go19
-rw-r--r--api/handlers/schedule.go26
-rw-r--r--api/middleware/admin.go27
-rw-r--r--api/middleware/auth.go38
-rw-r--r--api/router.go14
-rw-r--r--main.go8
-rw-r--r--pkg/conference/model.go (renamed from pkg/schedule/model.go)2
-rw-r--r--pkg/conference/parse.go (renamed from pkg/schedule/parse.go)2
-rw-r--r--pkg/conference/service.go218
-rw-r--r--pkg/database/migrations/0003_multi_conference.sql13
-rw-r--r--pkg/database/migrations/0004_user_admins.sql2
-rw-r--r--pkg/database/query/conferences.sql21
-rw-r--r--pkg/database/query/favourites.sql10
-rw-r--r--pkg/database/sqlc/conferences.sql.go119
-rw-r--r--pkg/database/sqlc/favourites.sql.go76
-rw-r--r--pkg/database/sqlc/models.go18
-rw-r--r--pkg/database/sqlc/users.sql.go36
-rw-r--r--pkg/favourites/service.go63
-rw-r--r--pkg/ical/service.go17
-rw-r--r--pkg/schedule/service.go116
-rw-r--r--pkg/session/memory.go3
-rw-r--r--pkg/session/service.go3
-rw-r--r--web/assets/css/main.css44
-rw-r--r--web/components/AddConference.vue54
-rw-r--r--web/components/Button.vue2
-rw-r--r--web/components/Dialog.vue2
-rw-r--r--web/components/EventDetail.vue2
-rw-r--r--web/components/EventListing.vue10
-rw-r--r--web/components/Input.vue2
-rw-r--r--web/components/Panel.vue15
-rw-r--r--web/components/Sidebar.vue45
-rw-r--r--web/composables/api.ts14
-rw-r--r--web/composables/expire-auth.ts8
-rw-r--r--web/composables/fetch-favourites.ts36
-rw-r--r--web/composables/fetch-login.ts6
-rw-r--r--web/composables/fetch-schedule.ts57
-rw-r--r--web/composables/logout.ts15
-rw-r--r--web/layouts/default.vue47
-rw-r--r--web/middleware/conference-selected.ts15
-rw-r--r--web/middleware/logged-in.ts19
-rw-r--r--web/nuxt.config.ts2
-rw-r--r--web/package-lock.json41
-rw-r--r--web/package.json1
-rw-r--r--web/pages/agenda.vue52
-rw-r--r--web/pages/conferences.vue124
-rw-r--r--web/pages/events.vue11
-rw-r--r--web/pages/index.vue9
-rw-r--r--web/pages/live.vue9
-rw-r--r--web/pages/login/[[provider]].vue98
-rw-r--r--web/pages/tracks/[slug].vue9
-rw-r--r--web/pages/tracks/index.vue12
-rw-r--r--web/stores/auth.ts12
-rw-r--r--web/stores/conference.ts18
-rw-r--r--web/stores/favourites.ts6
-rw-r--r--web/stores/login-options.ts10
-rw-r--r--web/stores/schedule.ts8
64 files changed, 1384 insertions, 464 deletions
diff --git a/Makefile b/Makefile
index 32f4e10..a986975 100644
--- a/Makefile
+++ b/Makefile
@@ -12,4 +12,4 @@ web:
.PHONY: sqlc
sqlc:
- sqlc compile
+ sqlc generate
diff --git a/api/dto/auth.go b/api/dto/auth.go
index 0379f21..734bb31 100644
--- a/api/dto/auth.go
+++ b/api/dto/auth.go
@@ -11,12 +11,14 @@ type LoginOAuthCallbackRequest struct {
}
type LoginOAuthOutboundResponse struct {
- URL string `json:"url" validate:"required"`
+ URL string `json:"url"`
}
type LoginResponse struct {
ID int32 `json:"id"`
+ Token string `json:"token"`
Username string `json:"username"`
+ Admin bool `json:"admin"`
}
type LoginOptionsResponse struct {
diff --git a/api/dto/conference.go b/api/dto/conference.go
new file mode 100644
index 0000000..99e6c08
--- /dev/null
+++ b/api/dto/conference.go
@@ -0,0 +1,36 @@
+package dto
+
+import (
+ "time"
+
+ "github.com/LMBishop/confplanner/pkg/database/sqlc"
+)
+
+type ConferenceResponse struct {
+ ID int32 `json:"id"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Venue string `json:"venue"`
+ City string `json:"city"`
+}
+
+func (dst *ConferenceResponse) Scan(src sqlc.Conference) {
+ dst.ID = src.ID
+ dst.Title = src.Title.String
+ dst.URL = src.Url
+ dst.Venue = src.Venue.String
+ dst.City = src.City.String
+}
+
+type GetScheduleResponse struct {
+ Schedule interface{} `json:"schedule"`
+ LastUpdated time.Time `json:"lastUpdated"`
+}
+
+type CreateConferenceRequest struct {
+ URL string `json:"url" validate:"required"`
+}
+
+type DeleteConferenceRequest struct {
+ ID int32 `json:"id"`
+}
diff --git a/api/dto/favourites.go b/api/dto/favourites.go
index 0f8021f..44c68a2 100644
--- a/api/dto/favourites.go
+++ b/api/dto/favourites.go
@@ -3,8 +3,9 @@ package dto
import "github.com/LMBishop/confplanner/pkg/database/sqlc"
type CreateFavouritesRequest struct {
- GUID *string `json:"eventGuid"`
- ID *int32 `json:"eventId"`
+ ConferenceID int32 `json:"conferenceID" validate:"required"`
+ GUID *string `json:"eventGuid"`
+ EventID *int32 `json:"eventId"`
}
type CreateFavouritesResponse struct {
@@ -29,6 +30,7 @@ func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) {
}
type DeleteFavouritesRequest struct {
- GUID *string `json:"eventGuid"`
- ID *int32 `json:"eventId"`
+ ConferenceID int32 `json:"conferenceID" validate:"required"`
+ GUID *string `json:"eventGuid"`
+ EventID *int32 `json:"eventId"`
}
diff --git a/api/dto/schedule.go b/api/dto/schedule.go
deleted file mode 100644
index 0d652aa..0000000
--- a/api/dto/schedule.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package dto
-
-import (
- "time"
-)
-
-type GetScheduleResponse struct {
- Schedule interface{} `json:"schedule"`
- LastUpdated time.Time `json:"lastUpdated"`
-}
diff --git a/api/dto/util.go b/api/dto/util.go
index cb3ea52..651f026 100644
--- a/api/dto/util.go
+++ b/api/dto/util.go
@@ -36,8 +36,8 @@ func WrapResponseFunc(dtoFunc func(http.ResponseWriter, *http.Request) error) ht
}
}
-func WriteDto(w http.ResponseWriter, r *http.Request, o error) {
- if o, ok := o.(Response); ok {
+func WriteDto(w http.ResponseWriter, r *http.Request, err error) {
+ if o, ok := err.(Response); ok {
data, err := json.Marshal(o)
if err != nil {
w.WriteHeader(500)
@@ -49,6 +49,6 @@ func WriteDto(w http.ResponseWriter, r *http.Request, o error) {
w.Write(data)
} else {
w.WriteHeader(500)
- slog.Error("internal server error handling request", "error", o)
+ slog.Error("internal server error handling request", "error", err)
}
}
diff --git a/api/handlers/auth.go b/api/handlers/auth.go
index c19fc3a..7b0a4c8 100644
--- a/api/handlers/auth.go
+++ b/api/handlers/auth.go
@@ -34,23 +34,18 @@ func Login(authService auth.Service, store session.Service) http.HandlerFunc {
}
// TODO X-Forwarded-For
- session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent())
+ session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent(), user.Admin)
if err != nil {
return err
}
- cookie := &http.Cookie{
- Name: "confplanner_session",
- Value: session.Token,
- Path: "/api",
- }
- http.SetCookie(w, cookie)
-
return &dto.OkResponse{
Code: http.StatusOK,
Data: &dto.LoginResponse{
ID: user.ID,
+ Token: session.Token,
Username: user.Username,
+ Admin: session.Admin,
},
}
})
diff --git a/api/handlers/conference.go b/api/handlers/conference.go
new file mode 100644
index 0000000..42cbfc1
--- /dev/null
+++ b/api/handlers/conference.go
@@ -0,0 +1,103 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/conference"
+ "github.com/golang-cz/nilslice"
+)
+
+func GetSchedule(service conference.Service) http.HandlerFunc {
+ return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
+ conferenceID, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ return &dto.ErrorResponse{
+ Code: http.StatusBadRequest,
+ Message: "Bad conference ID",
+ }
+ }
+
+ schedule, lastUpdated, err := service.GetSchedule(int32(conferenceID))
+ if err != nil {
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: http.StatusOK,
+ Data: &dto.GetScheduleResponse{
+ Schedule: nilslice.Initialize(*schedule),
+ LastUpdated: lastUpdated,
+ },
+ }
+ })
+}
+
+func GetConferences(service conference.Service) http.HandlerFunc {
+ return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
+ conferences, err := service.GetConferences()
+ if err != nil {
+ return err
+ }
+
+ var conferencesResponse []*dto.ConferenceResponse
+ for _, c := range conferences {
+ conference := &dto.ConferenceResponse{}
+ conference.Scan(c)
+ conferencesResponse = append(conferencesResponse, conference)
+ }
+
+ return &dto.OkResponse{
+ Code: http.StatusOK,
+ Data: conferencesResponse,
+ }
+ })
+}
+
+func CreateConference(service conference.Service) http.HandlerFunc {
+ return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
+ var request dto.CreateConferenceRequest
+ if err := dto.ReadDto(r, &request); err != nil {
+ return err
+ }
+
+ createdConference, err := service.CreateConference(request.URL)
+ if err != nil {
+ if errors.Is(err, conference.ErrScheduleFetch) {
+ return &dto.ErrorResponse{
+ Code: http.StatusBadRequest,
+ Message: "Could not fetch schedule from URL (is it a valid pentabarf XML file?)",
+ }
+ }
+ return err
+ }
+
+ var response dto.ConferenceResponse
+ response.Scan(*createdConference)
+ return &dto.OkResponse{
+ Code: http.StatusCreated,
+ Data: response,
+ }
+ })
+}
+
+func DeleteConference(service conference.Service) http.HandlerFunc {
+ return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
+ var request dto.DeleteConferenceRequest
+ if err := dto.ReadDto(r, &request); err != nil {
+ return err
+ }
+
+ err := service.DeleteConference(request.ID)
+ if err != nil {
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: http.StatusOK,
+ Data: nil,
+ }
+ })
+}
diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go
index 502cb43..d5fe8c3 100644
--- a/api/handlers/favourites.go
+++ b/api/handlers/favourites.go
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
+ "strconv"
"github.com/LMBishop/confplanner/api/dto"
"github.com/LMBishop/confplanner/pkg/favourites"
@@ -16,7 +17,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc {
return err
}
- if request.GUID == nil && request.ID == nil {
+ if request.GUID == nil && request.EventID == nil {
return &dto.ErrorResponse{
Code: http.StatusBadRequest,
Message: "One of event GUID or event ID must be specified",
@@ -34,7 +35,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc {
}
}
- createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.ID)
+ createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.EventID, request.ConferenceID)
if err != nil {
return err
}
@@ -50,9 +51,17 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc {
func GetFavourites(service favourites.Service) http.HandlerFunc {
return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
+ conferenceID, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ return &dto.ErrorResponse{
+ Code: http.StatusBadRequest,
+ Message: "Bad conference ID",
+ }
+ }
+
session := r.Context().Value("session").(*session.UserSession)
- favourites, err := service.GetFavouritesForUser(session.UserID)
+ favourites, err := service.GetFavouritesForUserConference(session.UserID, int32(conferenceID))
if err != nil {
return err
}
@@ -79,7 +88,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc {
return err
}
- if request.GUID == nil && request.ID == nil {
+ if request.GUID == nil && request.EventID == nil {
return &dto.ErrorResponse{
Code: http.StatusBadRequest,
Message: "One of event GUID or event ID must be specified",
@@ -96,7 +105,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc {
}
}
- err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.ID)
+ err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.EventID, request.ConferenceID)
if err != nil {
if err == favourites.ErrNotFound {
return &dto.ErrorResponse{
diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go
deleted file mode 100644
index 061e6f9..0000000
--- a/api/handlers/schedule.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "github.com/LMBishop/confplanner/api/dto"
- "github.com/LMBishop/confplanner/pkg/schedule"
- "github.com/golang-cz/nilslice"
-)
-
-func GetSchedule(service schedule.Service) http.HandlerFunc {
- return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error {
- schedule, lastUpdated, err := service.GetSchedule()
- if err != nil {
- return err
- }
-
- return &dto.OkResponse{
- Code: http.StatusOK,
- Data: &dto.GetScheduleResponse{
- Schedule: nilslice.Initialize(*schedule),
- LastUpdated: lastUpdated,
- },
- }
- })
-}
diff --git a/api/middleware/admin.go b/api/middleware/admin.go
new file mode 100644
index 0000000..fd43cd6
--- /dev/null
+++ b/api/middleware/admin.go
@@ -0,0 +1,27 @@
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/session"
+ "github.com/LMBishop/confplanner/pkg/user"
+)
+
+func MustAuthoriseAdmin(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc {
+ return func(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*session.UserSession)
+
+ if !session.Admin {
+ dto.WriteDto(w, r, &dto.ErrorResponse{
+ Code: http.StatusForbidden,
+ Message: "Forbidden",
+ })
+ return
+ }
+
+ next(w, r)
+ }
+ }
+}
diff --git a/api/middleware/auth.go b/api/middleware/auth.go
index eb362b0..438a8a1 100644
--- a/api/middleware/auth.go
+++ b/api/middleware/auth.go
@@ -3,7 +3,9 @@ package middleware
import (
"context"
"errors"
+ "fmt"
"net/http"
+ "strings"
"github.com/LMBishop/confplanner/api/dto"
"github.com/LMBishop/confplanner/pkg/session"
@@ -13,15 +15,17 @@ import (
func MustAuthenticate(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var sessionToken string
- for _, cookie := range r.Cookies() {
- if cookie.Name == "confplanner_session" {
- sessionToken = cookie.Value
- break
- }
+ authHeader := r.Header.Get("Authorization")
+ token, err := extractBearerToken(authHeader)
+ if err != nil {
+ dto.WriteDto(w, r, &dto.ErrorResponse{
+ Code: http.StatusUnauthorized,
+ Message: "Unauthorized",
+ })
+ return
}
- s := store.GetByToken(sessionToken)
+ s := store.GetByToken(token)
if s == nil {
dto.WriteDto(w, r, &dto.ErrorResponse{
Code: http.StatusUnauthorized,
@@ -30,7 +34,7 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han
return
}
- _, err := service.GetUserByID(s.UserID)
+ u, err := service.GetUserByID(s.UserID)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
store.Destroy(s.SessionID)
@@ -44,9 +48,27 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han
return
}
+ s.Username = u.Username
+ s.Admin = u.Admin
+
ctx := context.WithValue(r.Context(), "session", s)
next(w, r.WithContext(ctx))
}
}
}
+
+func extractBearerToken(header string) (string, error) {
+ const prefix = "Bearer "
+ if header == "" {
+ return "", fmt.Errorf("authorization header missing")
+ }
+ if !strings.HasPrefix(header, prefix) {
+ return "", fmt.Errorf("invalid authorization scheme")
+ }
+ token := strings.TrimSpace(header[len(prefix):])
+ if token == "" {
+ return "", fmt.Errorf("token is empty")
+ }
+ return token, nil
+}
diff --git a/api/router.go b/api/router.go
index 38e67e6..af52785 100644
--- a/api/router.go
+++ b/api/router.go
@@ -7,9 +7,9 @@ import (
"github.com/LMBishop/confplanner/api/middleware"
"github.com/LMBishop/confplanner/pkg/auth"
"github.com/LMBishop/confplanner/pkg/calendar"
+ "github.com/LMBishop/confplanner/pkg/conference"
"github.com/LMBishop/confplanner/pkg/favourites"
"github.com/LMBishop/confplanner/pkg/ical"
- "github.com/LMBishop/confplanner/pkg/schedule"
"github.com/LMBishop/confplanner/pkg/session"
"github.com/LMBishop/confplanner/pkg/user"
)
@@ -17,7 +17,7 @@ import (
type ApiServices struct {
UserService user.Service
FavouritesService favourites.Service
- ScheduleService schedule.Service
+ ConferenceService conference.Service
CalendarService calendar.Service
IcalService ical.Service
SessionService session.Service
@@ -26,6 +26,7 @@ type ApiServices struct {
func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux {
mustAuthenticate := middleware.MustAuthenticate(apiServices.UserService, apiServices.SessionService)
+ admin := middleware.MustAuthoriseAdmin(apiServices.UserService, apiServices.SessionService)
mux := http.NewServeMux()
@@ -34,12 +35,15 @@ func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux {
mux.HandleFunc("POST /login/{provider}", handlers.Login(apiServices.AuthService, apiServices.SessionService))
mux.HandleFunc("POST /logout", mustAuthenticate(handlers.Logout(apiServices.SessionService)))
- mux.HandleFunc("GET /favourites", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService)))
+ mux.HandleFunc("GET /conference", mustAuthenticate(handlers.GetConferences(apiServices.ConferenceService)))
+ mux.HandleFunc("GET /conference/{id}", mustAuthenticate(handlers.GetSchedule(apiServices.ConferenceService)))
+ mux.HandleFunc("POST /conference", mustAuthenticate(admin(handlers.CreateConference(apiServices.ConferenceService))))
+ mux.HandleFunc("DELETE /conference", mustAuthenticate(admin(handlers.DeleteConference(apiServices.ConferenceService))))
+
+ mux.HandleFunc("GET /favourites/{id}", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService)))
mux.HandleFunc("POST /favourites", mustAuthenticate(handlers.CreateFavourite(apiServices.FavouritesService)))
mux.HandleFunc("DELETE /favourites", mustAuthenticate(handlers.DeleteFavourite(apiServices.FavouritesService)))
- mux.HandleFunc("GET /schedule", mustAuthenticate(handlers.GetSchedule(apiServices.ScheduleService)))
-
mux.HandleFunc("GET /calendar", mustAuthenticate(handlers.GetCalendar(apiServices.CalendarService, baseURL)))
mux.HandleFunc("POST /calendar", mustAuthenticate(handlers.CreateCalendar(apiServices.CalendarService, baseURL)))
mux.HandleFunc("DELETE /calendar", mustAuthenticate(handlers.DeleteCalendar(apiServices.CalendarService)))
diff --git a/main.go b/main.go
index 545ac82..48f2e74 100644
--- a/main.go
+++ b/main.go
@@ -10,10 +10,10 @@ import (
"github.com/LMBishop/confplanner/internal/config"
"github.com/LMBishop/confplanner/pkg/auth"
"github.com/LMBishop/confplanner/pkg/calendar"
+ "github.com/LMBishop/confplanner/pkg/conference"
"github.com/LMBishop/confplanner/pkg/database"
"github.com/LMBishop/confplanner/pkg/favourites"
"github.com/LMBishop/confplanner/pkg/ical"
- "github.com/LMBishop/confplanner/pkg/schedule"
"github.com/LMBishop/confplanner/pkg/session"
"github.com/LMBishop/confplanner/pkg/user"
"github.com/LMBishop/confplanner/web"
@@ -44,12 +44,12 @@ func run() error {
userService := user.NewService(pool, c.AcceptRegistrations)
favouritesService := favourites.NewService(pool)
- scheduleService, err := schedule.NewService(c.Conference.ScheduleURL)
+ conferenceService, err := conference.NewService(pool)
if err != nil {
return fmt.Errorf("failed to create schedule service: %w", err)
}
calendarService := calendar.NewService(pool)
- icalService := ical.NewService(favouritesService, scheduleService)
+ icalService := ical.NewService(favouritesService, conferenceService)
sessionService := session.NewMemoryStore()
authService := auth.NewService()
@@ -82,7 +82,7 @@ func run() error {
api := api.NewServer(api.ApiServices{
UserService: userService,
FavouritesService: favouritesService,
- ScheduleService: scheduleService,
+ ConferenceService: conferenceService,
CalendarService: calendarService,
IcalService: icalService,
SessionService: sessionService,
diff --git a/pkg/schedule/model.go b/pkg/conference/model.go
index fcf39a5..343271f 100644
--- a/pkg/schedule/model.go
+++ b/pkg/conference/model.go
@@ -1,4 +1,4 @@
-package schedule
+package conference
import "time"
diff --git a/pkg/schedule/parse.go b/pkg/conference/parse.go
index b0ed8df..b7f9dba 100644
--- a/pkg/schedule/parse.go
+++ b/pkg/conference/parse.go
@@ -1,4 +1,4 @@
-package schedule
+package conference
import (
"encoding/xml"
diff --git a/pkg/conference/service.go b/pkg/conference/service.go
new file mode 100644
index 0000000..59c2c8c
--- /dev/null
+++ b/pkg/conference/service.go
@@ -0,0 +1,218 @@
+package conference
+
+import (
+ "bufio"
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/LMBishop/confplanner/pkg/database/sqlc"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Service interface {
+ CreateConference(url string) (*sqlc.Conference, error)
+ DeleteConference(id int32) error
+ GetConferences() ([]sqlc.Conference, error)
+ GetSchedule(id int32) (*Schedule, time.Time, error)
+ GetEventByID(conferenceID, eventID int32) (*Event, error)
+}
+
+type loadedConference struct {
+ pentabarfUrl string
+ schedule *Schedule
+ eventsById map[int32]Event
+ lastUpdated time.Time
+ lock sync.RWMutex
+}
+
+var (
+ ErrConferenceNotFound = errors.New("conference not found")
+ ErrScheduleFetch = errors.New("could not fetch schedule")
+)
+
+type service struct {
+ conferences map[int32]*loadedConference
+ lock sync.RWMutex
+ pool *pgxpool.Pool
+}
+
+// TODO: Create a service implementation that persists to DB
+// and isn't in memory
+func NewService(pool *pgxpool.Pool) (Service, error) {
+ service := &service{
+ pool: pool,
+ conferences: make(map[int32]*loadedConference),
+ }
+
+ queries := sqlc.New(pool)
+ conferences, err := queries.GetConferences(context.Background())
+ if err != nil {
+ return nil, err
+ }
+
+ for _, conference := range conferences {
+ c := &loadedConference{
+ pentabarfUrl: conference.Url,
+ lastUpdated: time.Unix(0, 0),
+ }
+ service.conferences[conference.ID] = c
+ }
+
+ return service, nil
+}
+
+func (s *service) CreateConference(url string) (*sqlc.Conference, error) {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ c := &loadedConference{
+ pentabarfUrl: url,
+ lastUpdated: time.Unix(0, 0),
+ }
+ err := c.updateSchedule()
+ if err != nil {
+ return nil, errors.Join(ErrScheduleFetch, err)
+ }
+
+ queries := sqlc.New(s.pool)
+
+ conference, err := queries.CreateConference(context.Background(), sqlc.CreateConferenceParams{
+ Url: url,
+ Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true},
+ Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true},
+ City: pgtype.Text{String: c.schedule.Conference.City, Valid: true},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("could not create conference: %w", err)
+ }
+
+ s.conferences[conference.ID] = c
+
+ return &conference, nil
+}
+
+func (s *service) DeleteConference(id int32) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ queries := sqlc.New(s.pool)
+ err := queries.DeleteConference(context.Background(), id)
+ if err != nil {
+ return fmt.Errorf("could not delete conference: %w", err)
+ }
+
+ delete(s.conferences, id)
+ return nil
+}
+
+func (s *service) GetConferences() ([]sqlc.Conference, error) {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ queries := sqlc.New(s.pool)
+ return queries.GetConferences(context.Background())
+}
+
+func (s *service) GetSchedule(id int32) (*Schedule, time.Time, error) {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ c, ok := s.conferences[id]
+ if !ok {
+ return nil, time.Time{}, ErrConferenceNotFound
+ }
+
+ if err := c.updateSchedule(); err != nil {
+ return nil, time.Time{}, err
+ }
+
+ c.lock.RLock()
+ defer c.lock.RUnlock()
+
+ queries := sqlc.New(s.pool)
+ if _, err := queries.UpdateConferenceDetails(context.Background(), sqlc.UpdateConferenceDetailsParams{
+ ID: id,
+ Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true},
+ Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true},
+ City: pgtype.Text{String: c.schedule.Conference.City, Valid: true},
+ }); err != nil {
+ return nil, time.Time{}, fmt.Errorf("failed to update cached conference details: %w", err)
+ }
+
+ return c.schedule, c.lastUpdated, nil
+}
+
+func (s *service) GetEventByID(conferenceID, eventID int32) (*Event, error) {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+
+ c, ok := s.conferences[conferenceID]
+ if !ok {
+ return nil, ErrConferenceNotFound
+ }
+
+ c.lock.RLock()
+ defer c.lock.RUnlock()
+
+ event := c.eventsById[eventID]
+
+ return &event, nil
+}
+
+func (c *loadedConference) hasScheduleExpired() bool {
+ expire := c.lastUpdated.Add(15 * time.Minute)
+ return time.Now().After(expire)
+}
+
+func (c *loadedConference) updateSchedule() error {
+ if !c.hasScheduleExpired() {
+ return nil
+ }
+
+ if !c.lock.TryLock() {
+ // don't block if another goroutine is already fetching
+ return nil
+ }
+ defer c.lock.Unlock()
+
+ res, err := http.Get(c.pentabarfUrl)
+ if err != nil {
+ return err
+ }
+
+ reader := bufio.NewReader(res.Body)
+
+ var schedule schedule
+
+ decoder := xml.NewDecoder(reader)
+ if err := decoder.Decode(&schedule); err != nil {
+ return fmt.Errorf("failed to decode XML: %w", err)
+ }
+
+ var newSchedule Schedule
+ err = newSchedule.Scan(schedule)
+ if err != nil {
+ return fmt.Errorf("failed to scan schedule: %w", err)
+ }
+
+ c.schedule = &newSchedule
+ c.lastUpdated = time.Now()
+
+ c.eventsById = make(map[int32]Event)
+
+ for _, day := range newSchedule.Days {
+ for _, room := range day.Rooms {
+ for _, event := range room.Events {
+ c.eventsById[event.ID] = event
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/database/migrations/0003_multi_conference.sql b/pkg/database/migrations/0003_multi_conference.sql
new file mode 100644
index 0000000..31a1f58
--- /dev/null
+++ b/pkg/database/migrations/0003_multi_conference.sql
@@ -0,0 +1,13 @@
+-- +goose Up
+CREATE TABLE conferences (
+ id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ url text NOT NULL,
+ title text,
+ venue text,
+ city text
+);
+
+TRUNCATE TABLE favourites CONTINUE IDENTITY;
+ALTER TABLE favourites ADD conference_id int NOT NULL REFERENCES conferences(id) ON DELETE CASCADE;
+ALTER TABLE favourites DROP CONSTRAINT favourites_user_id_event_guid_event_id_key;
+ALTER TABLE favourites ADD UNIQUE(user_id, event_guid, event_id, conference_id);
diff --git a/pkg/database/migrations/0004_user_admins.sql b/pkg/database/migrations/0004_user_admins.sql
new file mode 100644
index 0000000..5c4efb3
--- /dev/null
+++ b/pkg/database/migrations/0004_user_admins.sql
@@ -0,0 +1,2 @@
+-- +goose Up
+ALTER TABLE users ADD admin boolean NOT NULL DEFAULT false;
diff --git a/pkg/database/query/conferences.sql b/pkg/database/query/conferences.sql
new file mode 100644
index 0000000..7acac3f
--- /dev/null
+++ b/pkg/database/query/conferences.sql
@@ -0,0 +1,21 @@
+-- name: CreateConference :one
+INSERT INTO conferences (
+ url, title, venue, city
+) VALUES (
+ $1, $2, $3, $4
+)
+RETURNING *;
+
+-- name: UpdateConferenceDetails :one
+UPDATE conferences SET (
+ title, venue, city
+) = ($2, $3, $4)
+WHERE id = $1
+RETURNING *;
+
+-- name: GetConferences :many
+SELECT * FROM conferences;
+
+-- name: DeleteConference :exec
+DELETE FROM conferences
+WHERE id = $1;
diff --git a/pkg/database/query/favourites.sql b/pkg/database/query/favourites.sql
index 94e914c..a903ac7 100644
--- a/pkg/database/query/favourites.sql
+++ b/pkg/database/query/favourites.sql
@@ -1,12 +1,16 @@
+-- name: GetFavouritesForUserConference :many
+SELECT * FROM favourites
+WHERE user_id = $1 AND conference_id = $2;
+
-- name: GetFavouritesForUser :many
SELECT * FROM favourites
WHERE user_id = $1;
-- name: CreateFavourite :one
INSERT INTO favourites (
- user_id, event_guid, event_id
+ user_id, event_guid, event_id, conference_id
) VALUES (
- $1, $2, $3
+ $1, $2, $3, $4
)
RETURNING *;
@@ -16,4 +20,4 @@ WHERE id = $1;
-- name: DeleteFavouriteByEventDetails :execrows
DELETE FROM favourites
-WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3;
+WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 AND conference_id = $4;
diff --git a/pkg/database/sqlc/conferences.sql.go b/pkg/database/sqlc/conferences.sql.go
new file mode 100644
index 0000000..1345185
--- /dev/null
+++ b/pkg/database/sqlc/conferences.sql.go
@@ -0,0 +1,119 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: conferences.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const createConference = `-- name: CreateConference :one
+INSERT INTO conferences (
+ url, title, venue, city
+) VALUES (
+ $1, $2, $3, $4
+)
+RETURNING id, url, title, venue, city
+`
+
+type CreateConferenceParams struct {
+ Url string `json:"url"`
+ Title pgtype.Text `json:"title"`
+ Venue pgtype.Text `json:"venue"`
+ City pgtype.Text `json:"city"`
+}
+
+func (q *Queries) CreateConference(ctx context.Context, arg CreateConferenceParams) (Conference, error) {
+ row := q.db.QueryRow(ctx, createConference,
+ arg.Url,
+ arg.Title,
+ arg.Venue,
+ arg.City,
+ )
+ var i Conference
+ err := row.Scan(
+ &i.ID,
+ &i.Url,
+ &i.Title,
+ &i.Venue,
+ &i.City,
+ )
+ return i, err
+}
+
+const deleteConference = `-- name: DeleteConference :exec
+DELETE FROM conferences
+WHERE id = $1
+`
+
+func (q *Queries) DeleteConference(ctx context.Context, id int32) error {
+ _, err := q.db.Exec(ctx, deleteConference, id)
+ return err
+}
+
+const getConferences = `-- name: GetConferences :many
+SELECT id, url, title, venue, city FROM conferences
+`
+
+func (q *Queries) GetConferences(ctx context.Context) ([]Conference, error) {
+ rows, err := q.db.Query(ctx, getConferences)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Conference
+ for rows.Next() {
+ var i Conference
+ if err := rows.Scan(
+ &i.ID,
+ &i.Url,
+ &i.Title,
+ &i.Venue,
+ &i.City,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateConferenceDetails = `-- name: UpdateConferenceDetails :one
+UPDATE conferences SET (
+ title, venue, city
+) = ($2, $3, $4)
+WHERE id = $1
+RETURNING id, url, title, venue, city
+`
+
+type UpdateConferenceDetailsParams struct {
+ ID int32 `json:"id"`
+ Title pgtype.Text `json:"title"`
+ Venue pgtype.Text `json:"venue"`
+ City pgtype.Text `json:"city"`
+}
+
+func (q *Queries) UpdateConferenceDetails(ctx context.Context, arg UpdateConferenceDetailsParams) (Conference, error) {
+ row := q.db.QueryRow(ctx, updateConferenceDetails,
+ arg.ID,
+ arg.Title,
+ arg.Venue,
+ arg.City,
+ )
+ var i Conference
+ err := row.Scan(
+ &i.ID,
+ &i.Url,
+ &i.Title,
+ &i.Venue,
+ &i.City,
+ )
+ return i, err
+}
diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go
index b13261f..d28470d 100644
--- a/pkg/database/sqlc/favourites.sql.go
+++ b/pkg/database/sqlc/favourites.sql.go
@@ -13,27 +13,34 @@ import (
const createFavourite = `-- name: CreateFavourite :one
INSERT INTO favourites (
- user_id, event_guid, event_id
+ user_id, event_guid, event_id, conference_id
) VALUES (
- $1, $2, $3
+ $1, $2, $3, $4
)
-RETURNING id, user_id, event_guid, event_id
+RETURNING id, user_id, event_guid, event_id, conference_id
`
type CreateFavouriteParams struct {
- UserID int32 `json:"user_id"`
- EventGuid pgtype.UUID `json:"event_guid"`
- EventID pgtype.Int4 `json:"event_id"`
+ UserID int32 `json:"user_id"`
+ EventGuid pgtype.UUID `json:"event_guid"`
+ EventID pgtype.Int4 `json:"event_id"`
+ ConferenceID int32 `json:"conference_id"`
}
func (q *Queries) CreateFavourite(ctx context.Context, arg CreateFavouriteParams) (Favourite, error) {
- row := q.db.QueryRow(ctx, createFavourite, arg.UserID, arg.EventGuid, arg.EventID)
+ row := q.db.QueryRow(ctx, createFavourite,
+ arg.UserID,
+ arg.EventGuid,
+ arg.EventID,
+ arg.ConferenceID,
+ )
var i Favourite
err := row.Scan(
&i.ID,
&i.UserID,
&i.EventGuid,
&i.EventID,
+ &i.ConferenceID,
)
return i, err
}
@@ -50,17 +57,23 @@ func (q *Queries) DeleteFavourite(ctx context.Context, id int32) error {
const deleteFavouriteByEventDetails = `-- name: DeleteFavouriteByEventDetails :execrows
DELETE FROM favourites
-WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3
+WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 AND conference_id = $4
`
type DeleteFavouriteByEventDetailsParams struct {
- EventGuid pgtype.UUID `json:"event_guid"`
- EventID pgtype.Int4 `json:"event_id"`
- UserID int32 `json:"user_id"`
+ EventGuid pgtype.UUID `json:"event_guid"`
+ EventID pgtype.Int4 `json:"event_id"`
+ UserID int32 `json:"user_id"`
+ ConferenceID int32 `json:"conference_id"`
}
func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteFavouriteByEventDetailsParams) (int64, error) {
- result, err := q.db.Exec(ctx, deleteFavouriteByEventDetails, arg.EventGuid, arg.EventID, arg.UserID)
+ result, err := q.db.Exec(ctx, deleteFavouriteByEventDetails,
+ arg.EventGuid,
+ arg.EventID,
+ arg.UserID,
+ arg.ConferenceID,
+ )
if err != nil {
return 0, err
}
@@ -68,7 +81,7 @@ func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteF
}
const getFavouritesForUser = `-- name: GetFavouritesForUser :many
-SELECT id, user_id, event_guid, event_id FROM favourites
+SELECT id, user_id, event_guid, event_id, conference_id FROM favourites
WHERE user_id = $1
`
@@ -86,6 +99,43 @@ func (q *Queries) GetFavouritesForUser(ctx context.Context, userID int32) ([]Fav
&i.UserID,
&i.EventGuid,
&i.EventID,
+ &i.ConferenceID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getFavouritesForUserConference = `-- name: GetFavouritesForUserConference :many
+SELECT id, user_id, event_guid, event_id, conference_id FROM favourites
+WHERE user_id = $1 AND conference_id = $2
+`
+
+type GetFavouritesForUserConferenceParams struct {
+ UserID int32 `json:"user_id"`
+ ConferenceID int32 `json:"conference_id"`
+}
+
+func (q *Queries) GetFavouritesForUserConference(ctx context.Context, arg GetFavouritesForUserConferenceParams) ([]Favourite, error) {
+ rows, err := q.db.Query(ctx, getFavouritesForUserConference, arg.UserID, arg.ConferenceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Favourite
+ for rows.Next() {
+ var i Favourite
+ if err := rows.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.EventGuid,
+ &i.EventID,
+ &i.ConferenceID,
); err != nil {
return nil, err
}
diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go
index 57fd082..b7dfc19 100644
--- a/pkg/database/sqlc/models.go
+++ b/pkg/database/sqlc/models.go
@@ -15,15 +15,25 @@ type Calendar struct {
Key string `json:"key"`
}
+type Conference struct {
+ ID int32 `json:"id"`
+ Url string `json:"url"`
+ Title pgtype.Text `json:"title"`
+ Venue pgtype.Text `json:"venue"`
+ City pgtype.Text `json:"city"`
+}
+
type Favourite struct {
- ID int32 `json:"id"`
- UserID int32 `json:"user_id"`
- EventGuid pgtype.UUID `json:"event_guid"`
- EventID pgtype.Int4 `json:"event_id"`
+ ID int32 `json:"id"`
+ UserID int32 `json:"user_id"`
+ EventGuid pgtype.UUID `json:"event_guid"`
+ EventID pgtype.Int4 `json:"event_id"`
+ ConferenceID int32 `json:"conference_id"`
}
type User struct {
ID int32 `json:"id"`
Username string `json:"username"`
Password pgtype.Text `json:"password"`
+ Admin bool `json:"admin"`
}
diff --git a/pkg/database/sqlc/users.sql.go b/pkg/database/sqlc/users.sql.go
index cf0aeb9..45ae019 100644
--- a/pkg/database/sqlc/users.sql.go
+++ b/pkg/database/sqlc/users.sql.go
@@ -17,7 +17,7 @@ INSERT INTO users (
) VALUES (
$1, $2
)
-RETURNING id, username, password
+RETURNING id, username, password, admin
`
type CreateUserParams struct {
@@ -28,7 +28,12 @@ type CreateUserParams struct {
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Username, arg.Password)
var i User
- err := row.Scan(&i.ID, &i.Username, &i.Password)
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.Password,
+ &i.Admin,
+ )
return i, err
}
@@ -43,31 +48,41 @@ func (q *Queries) DeleteUser(ctx context.Context, id int32) error {
}
const getUserByID = `-- name: GetUserByID :one
-SELECT id, username, password FROM users
+SELECT id, username, password, admin FROM users
WHERE id = $1 LIMIT 1
`
func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
- err := row.Scan(&i.ID, &i.Username, &i.Password)
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.Password,
+ &i.Admin,
+ )
return i, err
}
const getUserByName = `-- name: GetUserByName :one
-SELECT id, username, password FROM users
+SELECT id, username, password, admin FROM users
WHERE username = $1 LIMIT 1
`
func (q *Queries) GetUserByName(ctx context.Context, username string) (User, error) {
row := q.db.QueryRow(ctx, getUserByName, username)
var i User
- err := row.Scan(&i.ID, &i.Username, &i.Password)
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.Password,
+ &i.Admin,
+ )
return i, err
}
const listUsers = `-- name: ListUsers :many
-SELECT id, username, password FROM users
+SELECT id, username, password, admin FROM users
ORDER BY username
`
@@ -80,7 +95,12 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
var items []User
for rows.Next() {
var i User
- if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil {
+ if err := rows.Scan(
+ &i.ID,
+ &i.Username,
+ &i.Password,
+ &i.Admin,
+ ); err != nil {
return nil, err
}
items = append(items, i)
diff --git a/pkg/favourites/service.go b/pkg/favourites/service.go
index cba249e..f73f400 100644
--- a/pkg/favourites/service.go
+++ b/pkg/favourites/service.go
@@ -12,9 +12,10 @@ import (
)
type Service interface {
- GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error)
- CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventId *int32) (*sqlc.Favourite, error)
- DeleteFavouriteForUserByEventDetails(id int32, eventGuid pgtype.UUID, eventId *int32) error
+ GetAllFavouritesForUser(id int32) (*[]sqlc.Favourite, error)
+ GetFavouritesForUserConference(id int32, conference int32) (*[]sqlc.Favourite, error)
+ CreateFavouriteForUser(id int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) (*sqlc.Favourite, error)
+ DeleteFavouriteForUserByEventDetails(id int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) error
}
var (
@@ -32,21 +33,22 @@ func NewService(pool *pgxpool.Pool) Service {
}
}
-func (s *service) CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventId *int32) (*sqlc.Favourite, error) {
+func (s *service) CreateFavouriteForUser(userID int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) (*sqlc.Favourite, error) {
queries := sqlc.New(s.pool)
- var pgEventId pgtype.Int4
- if eventId != nil {
- pgEventId = pgtype.Int4{
- Int32: *eventId,
+ var pgEventID pgtype.Int4
+ if eventID != nil {
+ pgEventID = pgtype.Int4{
+ Int32: *eventID,
Valid: true,
}
}
favourite, err := queries.CreateFavourite(context.Background(), sqlc.CreateFavouriteParams{
- UserID: id,
- EventGuid: eventGuid,
- EventID: pgEventId,
+ UserID: userID,
+ EventGuid: eventGUID,
+ EventID: pgEventID,
+ ConferenceID: conferenceID,
})
if err != nil {
return nil, fmt.Errorf("could not create favourite: %w", err)
@@ -55,10 +57,10 @@ func (s *service) CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventI
return &favourite, nil
}
-func (s *service) GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) {
+func (s *service) GetAllFavouritesForUser(userID int32) (*[]sqlc.Favourite, error) {
queries := sqlc.New(s.pool)
- favourites, err := queries.GetFavouritesForUser(context.Background(), id)
+ favourites, err := queries.GetFavouritesForUser(context.Background(), userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
empty := make([]sqlc.Favourite, 0)
@@ -70,20 +72,39 @@ func (s *service) GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) {
return &favourites, nil
}
-func (s *service) DeleteFavouriteForUserByEventDetails(id int32, eventGuid pgtype.UUID, eventId *int32) error {
+func (s *service) GetFavouritesForUserConference(userID int32, conferenceID int32) (*[]sqlc.Favourite, error) {
queries := sqlc.New(s.pool)
- var pgEventId pgtype.Int4
- if eventId != nil {
- pgEventId = pgtype.Int4{
- Int32: *eventId,
+ favourites, err := queries.GetFavouritesForUserConference(context.Background(), sqlc.GetFavouritesForUserConferenceParams{
+ UserID: userID,
+ ConferenceID: conferenceID,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ empty := make([]sqlc.Favourite, 0)
+ return &empty, nil
+ }
+ return nil, fmt.Errorf("could not fetch user: %w", err)
+ }
+
+ return &favourites, nil
+}
+
+func (s *service) DeleteFavouriteForUserByEventDetails(id int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) error {
+ queries := sqlc.New(s.pool)
+
+ var pgEventID pgtype.Int4
+ if eventID != nil {
+ pgEventID = pgtype.Int4{
+ Int32: *eventID,
Valid: true,
}
}
rowsAffected, err := queries.DeleteFavouriteByEventDetails(context.Background(), sqlc.DeleteFavouriteByEventDetailsParams{
- EventGuid: eventGuid,
- EventID: pgEventId,
- UserID: id,
+ EventGuid: eventGUID,
+ EventID: pgEventID,
+ UserID: id,
+ ConferenceID: conferenceID,
})
if err != nil {
return fmt.Errorf("could not delete favourite: %w", err)
diff --git a/pkg/ical/service.go b/pkg/ical/service.go
index d93c846..bc82248 100644
--- a/pkg/ical/service.go
+++ b/pkg/ical/service.go
@@ -6,9 +6,9 @@ import (
"strings"
"time"
+ "github.com/LMBishop/confplanner/pkg/conference"
"github.com/LMBishop/confplanner/pkg/database/sqlc"
"github.com/LMBishop/confplanner/pkg/favourites"
- "github.com/LMBishop/confplanner/pkg/schedule"
"github.com/microcosm-cc/bluemonday"
)
@@ -23,28 +23,31 @@ var (
type service struct {
favouritesService favourites.Service
- scheduleService schedule.Service
+ conferenceService conference.Service
}
func NewService(
favouritesService favourites.Service,
- scheduleService schedule.Service,
+ conferenceService conference.Service,
) Service {
return &service{
favouritesService: favouritesService,
- scheduleService: scheduleService,
+ conferenceService: conferenceService,
}
}
func (s *service) GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) {
- favourites, err := s.favouritesService.GetFavouritesForUser(calendar.UserID)
+ favourites, err := s.favouritesService.GetAllFavouritesForUser(calendar.UserID)
if err != nil {
return "", err
}
- events := make([]schedule.Event, 0)
+ events := make([]conference.Event, 0)
for _, favourite := range *favourites {
- event := s.scheduleService.GetEventByID(favourite.EventID.Int32)
+ event, err := s.conferenceService.GetEventByID(favourite.ConferenceID, favourite.EventID.Int32)
+ if err != nil {
+ continue
+ }
events = append(events, *event)
}
diff --git a/pkg/schedule/service.go b/pkg/schedule/service.go
deleted file mode 100644
index a73a469..0000000
--- a/pkg/schedule/service.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package schedule
-
-import (
- "bufio"
- "encoding/xml"
- "fmt"
- "net/http"
- "sync"
- "time"
-)
-
-type Service interface {
- GetSchedule() (*Schedule, time.Time, error)
- GetEventByID(id int32) *Event
-}
-
-type service struct {
- pentabarfUrl string
-
- schedule *Schedule
- eventsById map[int32]Event
- lastUpdated time.Time
- accessLock sync.RWMutex
- updateLock sync.Mutex
-}
-
-// TODO: Create a service implementation that persists to DB
-// and isn't in memory
-func NewService(pentabarfUrl string) (Service, error) {
- service := &service{
- pentabarfUrl: pentabarfUrl,
- lastUpdated: time.Unix(0, 0),
- }
-
- err := service.updateSchedule()
- if err != nil {
- return nil, fmt.Errorf("could not read schedule from '%s' (is it a valid pentabarf XML file?): %w", pentabarfUrl, err)
- }
- return service, nil
-}
-
-func (s *service) GetSchedule() (*Schedule, time.Time, error) {
- err := s.updateSchedule()
- if err != nil {
- return nil, time.Time{}, err
- }
-
- s.accessLock.RLock()
- defer s.accessLock.RUnlock()
-
- return s.schedule, s.lastUpdated, nil
-}
-
-func (s *service) GetEventByID(id int32) *Event {
- s.accessLock.RLock()
- defer s.accessLock.RUnlock()
-
- event := s.eventsById[id]
-
- return &event
-}
-
-func (s *service) hasScheduleExpired() bool {
- expire := s.lastUpdated.Add(15 * time.Minute)
- return time.Now().After(expire)
-}
-
-func (s *service) updateSchedule() error {
- if !s.hasScheduleExpired() {
- return nil
- }
-
- if !s.updateLock.TryLock() {
- // don't block if another goroutine is already fetching
- return nil
- }
- defer s.updateLock.Unlock()
-
- res, err := http.Get(s.pentabarfUrl)
- if err != nil {
- return err
- }
-
- reader := bufio.NewReader(res.Body)
-
- var schedule schedule
-
- decoder := xml.NewDecoder(reader)
- if err := decoder.Decode(&schedule); err != nil {
- return fmt.Errorf("failed to decode XML: %w", err)
- }
-
- var newSchedule Schedule
- err = newSchedule.Scan(schedule)
- if err != nil {
- return fmt.Errorf("failed to scan schedule: %w", err)
- }
-
- s.accessLock.Lock()
- defer s.accessLock.Unlock()
-
- s.schedule = &newSchedule
- s.lastUpdated = time.Now()
-
- s.eventsById = make(map[int32]Event)
-
- for _, day := range newSchedule.Days {
- for _, room := range day.Rooms {
- for _, event := range room.Events {
- s.eventsById[event.ID] = event
- }
- }
- }
-
- return nil
-}
diff --git a/pkg/session/memory.go b/pkg/session/memory.go
index f02b792..8fa6076 100644
--- a/pkg/session/memory.go
+++ b/pkg/session/memory.go
@@ -40,7 +40,7 @@ func (s *memoryStore) GetBySID(sid uint) *UserSession {
return s.sessionsBySID[sid]
}
-func (s *memoryStore) Create(uid int32, username string, ip string, ua string) (*UserSession, error) {
+func (s *memoryStore) Create(uid int32, username string, ip string, ua string, admin bool) (*UserSession, error) {
token := generateSessionToken()
s.lock.Lock()
@@ -65,6 +65,7 @@ func (s *memoryStore) Create(uid int32, username string, ip string, ua string) (
IP: ip,
UserAgent: ua,
LoginTime: time.Now(),
+ Admin: admin,
}
s.sessionsByToken[token] = session
s.sessionsBySID[sessionId] = session
diff --git a/pkg/session/service.go b/pkg/session/service.go
index 83009bc..a693fe8 100644
--- a/pkg/session/service.go
+++ b/pkg/session/service.go
@@ -5,7 +5,7 @@ import "time"
type Service interface {
GetByToken(token string) *UserSession
GetBySID(sid uint) *UserSession
- Create(uid int32, username string, ip string, ua string) (*UserSession, error)
+ Create(uid int32, username string, ip string, ua string, admin bool) (*UserSession, error)
Destroy(sid uint) error
}
@@ -17,4 +17,5 @@ type UserSession struct {
IP string
LoginTime time.Time
UserAgent string
+ Admin bool
}
diff --git a/web/assets/css/main.css b/web/assets/css/main.css
index 320bedb..5b88e8c 100644
--- a/web/assets/css/main.css
+++ b/web/assets/css/main.css
@@ -99,4 +99,48 @@ span.text-icon {
display: flex;
align-items: center;
gap: 0.4em;
+}
+
+form {
+ display: grid;
+ gap: 1.5rem;
+}
+
+div.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+label.form-label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+div.form-input-container {
+ margin-top: 0.25rem;
+}
+
+div.form-submit {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.loading-text {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: var(--text-normal);
+ color: var(--color-text-muted);
+}
+
+table {
+ width: 100%;
+}
+
+th {
+ font-weight: bold;
+ text-align: left;
} \ No newline at end of file
diff --git a/web/components/AddConference.vue b/web/components/AddConference.vue
new file mode 100644
index 0000000..95154d4
--- /dev/null
+++ b/web/components/AddConference.vue
@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { format } from 'date-fns';
+import { type Event as ScheduledEvent } from '~/stores/schedule';
+
+const errorStore = useErrorStore();
+const config = useRuntimeConfig();
+const loading = ref(false)
+
+const emit = defineEmits(['update']);
+
+const addConference = async (e: Event) => {
+ const target = e.target as HTMLFormElement;
+ const formData = new FormData(target);
+ loading.value = true
+
+ $api(config.public.baseURL + '/conference', {
+ method: 'POST',
+ body: JSON.stringify(Object.fromEntries(formData)),
+ onResponse: ({ response }) => {
+ loading.value = false
+ if (!response.ok) {
+ errorStore.setError(response._data?.message || 'An unknown error occurred');
+ return
+ }
+ emit('update')
+ },
+ });
+}
+
+</script>
+
+<template>
+ <div>
+ <form @submit.prevent="(e) => addConference(e)">
+ <div class="form-group">
+ <label for="url" class="form-label">
+ Schedule data URL
+ </label>
+ <div class="form-input-container">
+ <Input id="url" name="url" required />
+ </div>
+ </div>
+
+ <div class="form-submit">
+ <Button type="submit" :loading="loading">
+ Add
+ </Button>
+ </div>
+ </form>
+ </div>
+</template>
+
+<style scoped>
+</style> \ No newline at end of file
diff --git a/web/components/Button.vue b/web/components/Button.vue
index ce9eefc..27cfa4e 100644
--- a/web/components/Button.vue
+++ b/web/components/Button.vue
@@ -48,7 +48,7 @@ button {
gap: 0.4rem;
padding: 0.5rem 1rem;
border: 1px solid transparent;
- border-radius: 0.375rem;
+ border-radius: 2px;
font-size: var(--text-small);
font-family: var(--font-family);
font-weight: 500;
diff --git a/web/components/Dialog.vue b/web/components/Dialog.vue
index 7772f23..04e1461 100644
--- a/web/components/Dialog.vue
+++ b/web/components/Dialog.vue
@@ -72,7 +72,7 @@ const onDialogClick = (e: MouseEvent) => {
<style scoped>
dialog {
outline: none;
- border-radius: 0.5rem;
+ border-radius: 2px;
padding: 1rem;
width: 1000px;
margin: 0;
diff --git a/web/components/EventDetail.vue b/web/components/EventDetail.vue
index b4f7bd9..8fd5b03 100644
--- a/web/components/EventDetail.vue
+++ b/web/components/EventDetail.vue
@@ -43,7 +43,7 @@ const getHostname = (url: string) => new URL(url).hostname;
</div>
<div>
- <span class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> &bull;</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a>
+ <span v-if="event.track" class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> &bull;</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a>
</div>
</div>
</template>
diff --git a/web/components/EventListing.vue b/web/components/EventListing.vue
index 5c04189..0cc546c 100644
--- a/web/components/EventListing.vue
+++ b/web/components/EventListing.vue
@@ -10,6 +10,7 @@ const { event, showRelativeTime } = defineProps<{
}>();
const selectedEventStore = useSelectedEventStore();
+const conferenceStore = useConferenceStore();
const favouritesStore = useFavouritesStore();
const errorStore = useErrorStore();
const config = useRuntimeConfig();
@@ -43,9 +44,10 @@ const addFavourite = async () => {
addingToFavourite.value = true;
try {
- const res = await $fetch(config.public.baseURL + '/favourites', {
+ const res = await $api(config.public.baseURL + '/favourites', {
method: 'POST',
body: JSON.stringify({
+ conferenceId: conferenceStore.id,
eventGuid: event.guid,
eventId: event.id,
}),
@@ -70,7 +72,7 @@ const removeFavourite = async () => {
addingToFavourite.value = true;
try {
- await $fetch(config.public.baseURL + '/favourites', {
+ await $api(config.public.baseURL + '/favourites', {
method: 'DELETE',
body: JSON.stringify({
eventGuid: event.guid,
@@ -102,9 +104,9 @@ const removeFavourite = async () => {
</span>
<span class="event-title">{{ event.title }}</span>
<span class="event-speaker">{{ event.persons.map(p => p.name).join(", ") }}</span>
- <span class="event-track">{{ event.track.name }}</span>
+ <span class="event-track">{{ event.track?.name }}</span>
</div>
- <template v-if="!addingToFavourite" class="event-button">
+ <template v-if="!addingToFavourite && favouritesStore.status !== 'pending'" class="event-button">
<StarIcon v-if="favouritesStore.isFavourite(event)" color="var(--color-favourite)" fill="var(--color-favourite)" class="event-button" @click="removeFavourite" />
<StarIcon v-else color="var(--color-text-muted)" class="event-button" @click="addFavourite" />
</template>
diff --git a/web/components/Input.vue b/web/components/Input.vue
index b541566..aebece6 100644
--- a/web/components/Input.vue
+++ b/web/components/Input.vue
@@ -82,7 +82,7 @@ input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
- border-radius: 0.375rem;
+ border-radius: 2px;
box-sizing: border-box;
/* box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); */
font-family: var(--font-family);
diff --git a/web/components/Panel.vue b/web/components/Panel.vue
index 1f2d22e..d417691 100644
--- a/web/components/Panel.vue
+++ b/web/components/Panel.vue
@@ -4,7 +4,7 @@ import { defineProps, type FunctionalComponent, type PropType } from 'vue';
defineProps({
kind: {
- type: String as PropType<"normal" | "error" | "success" | "emphasis">,
+ type: String as PropType<"normal" | "top" | "error" | "success" | "emphasis">,
required: false,
default: 'normal',
},
@@ -47,14 +47,11 @@ defineProps({
<style>
div.card {
padding: 1rem;
- border-radius: 0.5rem;
}
div.card-header {
padding: 1rem 1rem;
margin: -1rem -1rem 0 -1rem;
- border-top-left-radius: 0.5rem;
- border-top-right-radius: 0.5rem;;
display: flex;
justify-content: space-between;
align-items: center;
@@ -73,7 +70,7 @@ div.header-left > svg {
}
span.card-title {
- font-size: 1.5rem;
+ font-size: 1.3rem;
font-weight: 700;
}
@@ -99,17 +96,21 @@ span.breadcrumb > a:hover {
color: var(--color-primary);
}
-div.normal {
+div.normal, div.top {
background-color: white;
border: 0.1rem solid var(--color-border);
}
-div.normal .card-header {
+div.normal .card-header, div.top .card-header {
background-color: var(--color-background-muted);
border-bottom: 1px solid var(--color-border);
margin: -1rem -1rem 1rem -1rem;
}
+div.top {
+ border-top: 3px solid var(--color-primary)
+}
+
div.error {
background-color: var(--color-error-light);
border: 0.1rem solid var(--color-border-error-light);
diff --git a/web/components/Sidebar.vue b/web/components/Sidebar.vue
index 5fc42d3..fd64434 100644
--- a/web/components/Sidebar.vue
+++ b/web/components/Sidebar.vue
@@ -3,6 +3,7 @@ import { formatDistanceToNow } from "date-fns";
import { LucideClock, LucideRadio } from "lucide-vue-next";
const scheduleStore = useScheduleStore();
+const conferenceStore = useConferenceStore();
const errorStore = useErrorStore();
const timer = ref();
@@ -30,31 +31,35 @@ onBeforeUnmount(() => {
<template>
<div class="sidebar">
- <Panel class="conference">
- <span class="conference-title">{{ scheduleStore.schedule?.conference.title }}</span>
- <span class="conference-venue">{{ scheduleStore.schedule?.conference.venue }}</span>
- <span class="conference-city">{{ scheduleStore.schedule?.conference.city }}</span>
+ <Panel class="conference" kind="top">
+ <template v-if="conferenceStore.id && conferenceStore.title">
+ <span class="conference-title">{{ conferenceStore.title }}</span>
+ <span class="conference-venue">{{ conferenceStore.venue }}</span>
+ <span class="conference-city">{{ conferenceStore.city }}</span>
+ </template>
- <Button kind="secondary" @click="errorStore.setError('This doesn\'t do anything yet :-)')">Change conference</Button>
+ <Button kind="secondary" @click="navigateTo('/conferences')">Change conference</Button>
</Panel>
- <Panel kind="success" class="ongoing" v-if="ongoing">
- <span>This conference is ongoing</span>
- <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button>
- </Panel>
+ <template v-if="scheduleStore.schedule != null">
+ <Panel kind="success" class="ongoing" v-if="ongoing">
+ <span>This conference is ongoing</span>
+ <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button>
+ </Panel>
- <Panel kind="error" class="finished" v-else-if="finished">
- <span>This conference has finished</span>
- </Panel>
-
- <Panel class="upcoming" v-else>
- <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span>
- </Panel>
-
- <Nav />
+ <Panel kind="error" class="finished" v-else-if="finished">
+ <span>This conference has finished</span>
+ </Panel>
+
+ <Panel class="upcoming" v-else>
+ <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span>
+ </Panel>
+
+ <Nav />
+ </template>
<div class="info">
- <span>Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span>
+ <span v-if="scheduleStore.schedule != null">Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span>
<Version />
</div>
@@ -74,7 +79,7 @@ onBeforeUnmount(() => {
align-items: center;
gap: 1rem;
font-size: var(--text-small);
- font-style: oblique;
+ font-style: italic;
text-align: center;
}
diff --git a/web/composables/api.ts b/web/composables/api.ts
new file mode 100644
index 0000000..7881aff
--- /dev/null
+++ b/web/composables/api.ts
@@ -0,0 +1,14 @@
+export function $api<T>(
+ request: Parameters<typeof $fetch<T>>[0],
+ opts?: Parameters<typeof $fetch<T>>[1],
+) {
+ const authStore = useAuthStore()
+
+ return $fetch<T>(request, {
+ ...opts,
+ headers: {
+ Authorization: authStore.isLoggedIn() ? `Bearer ${authStore.token}` : '',
+ ...opts?.headers,
+ },
+ })
+}
diff --git a/web/composables/expire-auth.ts b/web/composables/expire-auth.ts
new file mode 100644
index 0000000..b0e277e
--- /dev/null
+++ b/web/composables/expire-auth.ts
@@ -0,0 +1,8 @@
+export function expireAuth() {
+ const authStore = useAuthStore()
+
+ authStore.admin = false;
+ authStore.username = null;
+ authStore.token = null;
+ navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } });
+} \ No newline at end of file
diff --git a/web/composables/fetch-favourites.ts b/web/composables/fetch-favourites.ts
index e586d5b..965120d 100644
--- a/web/composables/fetch-favourites.ts
+++ b/web/composables/fetch-favourites.ts
@@ -1,23 +1,21 @@
export default function() {
- const favouritesStore = useFavouritesStore();
- const errorStore = useErrorStore();
- const config = useRuntimeConfig();
-
- favouritesStore.setStatus('pending')
+ const conferenceStore = useConferenceStore()
+ const favouritesStore = useFavouritesStore();
+ const errorStore = useErrorStore();
+ const config = useRuntimeConfig();
+
+ favouritesStore.status = 'pending'
- useFetch(config.public.baseURL + '/favourites', {
- method: 'GET',
- server: false,
- lazy: true,
- onResponseError: ({ response }) => {
- favouritesStore.setStatus('idle')
+ return $api(config.public.baseURL + '/favourites/' + conferenceStore.id, {
+ method: 'GET',
+ onResponse: ({ response }) => {
+ favouritesStore.status = 'idle'
+ if (!response.ok) {
errorStore.setError(response._data.message || 'An unknown error occurred');
- },
- onResponse: ({ response }) => {
- if (response._data) {
- favouritesStore.setFavourites((response._data as any).data);
- }
- favouritesStore.setStatus('idle')
- },
- });
+ }
+ if (response._data) {
+ favouritesStore.setFavourites((response._data as any).data);
+ }
+ },
+ });
} \ No newline at end of file
diff --git a/web/composables/fetch-login.ts b/web/composables/fetch-login.ts
index 707a04f..b0b04ba 100644
--- a/web/composables/fetch-login.ts
+++ b/web/composables/fetch-login.ts
@@ -5,7 +5,7 @@ export default function() {
const errorStore = useErrorStore();
const config = useRuntimeConfig();
- loginOptionsStore.setStatus('pending')
+ loginOptionsStore.status = 'pending'
$fetch(config.public.baseURL + '/login', {
method: 'GET',
@@ -17,8 +17,8 @@ export default function() {
}
if (response._data) {
- loginOptionsStore.setLoginOptions((response._data as any).data.options);
- loginOptionsStore.setStatus('idle')
+ loginOptionsStore.loginOptions = (response._data as any).data.options;
+ loginOptionsStore.status = 'idle'
}
},
});
diff --git a/web/composables/fetch-schedule.ts b/web/composables/fetch-schedule.ts
index a0e6fec..5e91954 100644
--- a/web/composables/fetch-schedule.ts
+++ b/web/composables/fetch-schedule.ts
@@ -1,24 +1,39 @@
-export default function() {
- const scheduleStore = useScheduleStore();
- const errorStore = useErrorStore();
- const config = useRuntimeConfig();
-
- useFetch(config.public.baseURL + '/schedule', {
- method: 'GET',
- server: false,
- lazy: true,
- onResponse: ({ response }) => {
- if (!response.ok) {
- if (response.status === 401) {
- navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } });
- } else {
- errorStore.setError(response._data.message || 'An unknown error occurred');
- }
- }
+import { useConferenceStore } from "~/stores/conference";
+import { expireAuth } from "./expire-auth";
+
+export default async function() {
+ const conferenceStore = useConferenceStore()
+ const scheduleStore = useScheduleStore();
+ const errorStore = useErrorStore();
+ const config = useRuntimeConfig();
- if (response._data) {
- scheduleStore.setSchedule((response._data as any).data.schedule);
+ scheduleStore.status = 'pending'
+
+ return $api(config.public.baseURL + '/conference/' + conferenceStore.id, {
+ method: 'GET',
+ onResponse: ({ response }) => {
+ if (!response.ok) {
+ if (response.status === 401) {
+ expireAuth()
+ return
+ } else {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
}
- },
- });
+ }
+
+ if (response._data) {
+ let schedule = (response._data as any).data.schedule
+ scheduleStore.setSchedule(schedule);
+
+ conferenceStore.venue = schedule.conference.venue
+ conferenceStore.title = schedule.conference.title
+ conferenceStore.city = schedule.conference.city
+
+ scheduleStore.status = 'idle'
+ }
+ },
+ }).catch(() => {
+ // todo do this better
+ errorStore.setError('An unknown error occurred');
+ });
} \ No newline at end of file
diff --git a/web/composables/logout.ts b/web/composables/logout.ts
new file mode 100644
index 0000000..35b0511
--- /dev/null
+++ b/web/composables/logout.ts
@@ -0,0 +1,15 @@
+export function logout() {
+ const authStore = useAuthStore()
+ const conferenceStore = useConferenceStore()
+ const config = useRuntimeConfig();
+
+ $api(config.public.baseURL + '/logout', { method: 'POST' }).finally(() => {
+ authStore.admin = false;
+ authStore.username = null;
+ authStore.token = null;
+
+ conferenceStore.clear()
+
+ navigateTo({ path: '/login', state: { error: 'You have logged out' } });
+ })
+} \ No newline at end of file
diff --git a/web/layouts/default.vue b/web/layouts/default.vue
index 2baf5f1..5f85c38 100644
--- a/web/layouts/default.vue
+++ b/web/layouts/default.vue
@@ -8,7 +8,7 @@ definePageMeta({
middleware: ["logged-in"]
})
-const scheduleStore = useScheduleStore();
+const authStore = useAuthStore();
const selectedEventStore = useSelectedEventStore();
const errorStore = useErrorStore();
const router = useRouter();
@@ -21,9 +21,6 @@ const refErrorDialog = ref<typeof Dialog>();
const showHamburger = ref(false);
-fetchSchedule();
-fetchFavourites();
-
watch(selectedEvent, () => {
if (selectedEvent.value != null) {
refSelectedDialog.value?.show();
@@ -52,6 +49,7 @@ router.afterEach(() => {
<header>
<div class="planner-header">
<span class="text-icon planner-title" @click="navigateTo('/')"><BookHeart /> confplanner</span>
+ <NuxtLink class="logout logout-header" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink>
<span class="hamburger" @click="showHamburger = !showHamburger">
<LucideMenu :size="24" v-if="!showHamburger"/>
<LucideX :size="24" v-else />
@@ -59,25 +57,20 @@ router.afterEach(() => {
</div>
<div class="hamburger-content" v-if="showHamburger">
<Sidebar />
+
+ <div class="logout-hamburger">
+ <NuxtLink class="logout" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink>
+ </div>
</div>
</header>
<div class="planner-layout">
- <template v-if="scheduleStore.schedule">
- <aside class="planner-sidebar">
- <Sidebar />
- </aside>
+ <aside class="planner-sidebar">
+ <Sidebar />
+ </aside>
- <main class="planner-content">
- <slot />
- </main>
- </template>
- <template v-else>
- <div class="loading">
- <span class="loading-text">
- <Spinner color="var(--color-text-muted)" />Updating schedule...
- </span>
- </div>
- </template>
+ <main class="planner-content">
+ <slot />
+ </main>
</div>
</div>
@@ -109,7 +102,8 @@ header {
div.planner-header {
background-color: var(--color-background-muted);
color: var(--color-text-muted);
- border-bottom: 2px solid var(--color-border);
+ border-top: 3px solid var(--color-primary);
+ border-bottom: 1px solid var(--color-border);
height: 3.5rem;
display: flex;
justify-content: space-between;
@@ -164,6 +158,15 @@ aside.planner-sidebar {
font-size: var(--text-normal);
color: var(--color-text-muted);
}
+
+.logout {
+ cursor: pointer;
+}
+
+.logout-hamburger {
+ margin-top: 0.5rem;
+ text-align: right;
+}
.loading {
margin-top: 1rem;
@@ -184,6 +187,10 @@ aside.planner-sidebar {
flex-direction: column;
padding: 0.5rem;
}
+
+ .logout-header {
+ display: none;
+ }
.hamburger {
display: block;
diff --git a/web/middleware/conference-selected.ts b/web/middleware/conference-selected.ts
new file mode 100644
index 0000000..c6415bb
--- /dev/null
+++ b/web/middleware/conference-selected.ts
@@ -0,0 +1,15 @@
+import { useConferenceStore } from "~/stores/conference";
+
+const conferenceStore = useConferenceStore();
+const scheduleStore = useScheduleStore()
+
+export default defineNuxtRouteMiddleware((to, from) => {
+ if (conferenceStore.id === null) {
+ return navigateTo("/conferences");
+ }
+
+ if (scheduleStore.schedule === null) {
+ fetchSchedule();
+ fetchFavourites();
+ }
+}); \ No newline at end of file
diff --git a/web/middleware/logged-in.ts b/web/middleware/logged-in.ts
index 1ddd3ce..97db606 100644
--- a/web/middleware/logged-in.ts
+++ b/web/middleware/logged-in.ts
@@ -1,21 +1,8 @@
+const authStore = useAuthStore()
+
export default defineNuxtRouteMiddleware((to, from) => {
- if ("" === getCookie("fosdem_planner_session")) {
+ if (!authStore.isLoggedIn()) {
return navigateTo("/login");
}
});
-function getCookie(cname: string) {
- let name = cname + "=";
- let decodedCookie = decodeURIComponent(document.cookie);
- let ca = decodedCookie.split(";");
- for (let i = 0; i < ca.length; i++) {
- let c = ca[i];
- while (c.charAt(0) == " ") {
- c = c.substring(1);
- }
- if (c.indexOf(name) == 0) {
- return c.substring(name.length, c.length);
- }
- }
- return "";
-}
diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts
index d5762e7..1dfc34e 100644
--- a/web/nuxt.config.ts
+++ b/web/nuxt.config.ts
@@ -14,7 +14,7 @@ try {
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
- devtools: { enabled: true },
+ devtools: { enabled: false },
ssr: false,
css: ["~/assets/css/main.css"],
diff --git a/web/package-lock.json b/web/package-lock.json
index e1b6ef0..d034eec 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,6 +10,7 @@
"@date-fns/tz": "^1.2.0",
"@pinia/nuxt": "^0.9.0",
"@vite-pwa/nuxt": "^0.10.6",
+ "@vueuse/core": "^13.7.0",
"date-fns": "^4.1.0",
"lucide-vue-next": "^0.471.0",
"nuxt": "^3.15.1",
@@ -3339,6 +3340,11 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
+ },
"node_modules/@unhead/dom": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/@unhead/dom/-/dom-1.11.18.tgz",
@@ -3708,6 +3714,41 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="
},
+ "node_modules/@vueuse/core": {
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.7.0.tgz",
+ "integrity": "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "13.7.0",
+ "@vueuse/shared": "13.7.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.7.0.tgz",
+ "integrity": "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "13.7.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.7.0.tgz",
+ "integrity": "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index 50c00ed..a2378f6 100644
--- a/web/package.json
+++ b/web/package.json
@@ -13,6 +13,7 @@
"@date-fns/tz": "^1.2.0",
"@pinia/nuxt": "^0.9.0",
"@vite-pwa/nuxt": "^0.10.6",
+ "@vueuse/core": "^13.7.0",
"date-fns": "^4.1.0",
"lucide-vue-next": "^0.471.0",
"nuxt": "^3.15.1",
diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue
index 9b55c9b..5126d78 100644
--- a/web/pages/agenda.vue
+++ b/web/pages/agenda.vue
@@ -8,6 +8,10 @@ const scheduleStore = useScheduleStore();
const errorStore = useErrorStore();
const config = useRuntimeConfig();
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
+
const favouriteEvents = computed(() => {
return scheduleStore.events.filter((event) => favouritesStore.isFavourite(event));
});
@@ -19,44 +23,28 @@ const refConfirmDeleteDialog = ref<typeof Dialog>();
const calendarAction = ref(false);
-useFetch(config.public.baseURL + '/calendar', {
- method: 'GET',
- server: false,
- lazy: true,
- onResponse: ({ response }) => {
- if (!response.ok) {
- if (response.status !== 404) {
- errorStore.setError(response._data.message || 'An unknown error occurred');
- }
- } else if (response._data) {
- calendarLink.value = (response._data as any).data.url;
- }
- calendarStatus.value = 'idle';
- },
-});
-
function generateCalendar() {
calendarAction.value = true;
- useFetch(config.public.baseURL + '/calendar', {
+ $api(config.public.baseURL + '/calendar', {
method: 'POST',
server: false,
lazy: true,
- onResponseError: ({ response }) => {
- errorStore.setError(response._data.message || 'An unknown error occurred');
- calendarAction.value = false;
- },
onResponse: ({ response }) => {
+ calendarAction.value = false;
+ if (!response.ok) {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ return
+ }
if (response._data) {
calendarLink.value = (response._data as any).data.url;
}
- calendarAction.value = false;
},
});
}
function deleteCalendar() {
calendarAction.value = true;
- useFetch(config.public.baseURL + '/calendar', {
+ $api(config.public.baseURL + '/calendar', {
method: 'DELETE',
server: false,
onResponseError: ({ response }) => {
@@ -70,6 +58,24 @@ function deleteCalendar() {
});
}
+onMounted(() => {
+ $api(config.public.baseURL + '/calendar', {
+ method: 'GET',
+ server: false,
+ lazy: true,
+ onResponse: ({ response }) => {
+ calendarStatus.value = 'idle';
+ if (!response.ok) {
+ if (response.status !== 404) {
+ errorStore.setError(response._data.message || 'Could not fetch calendar');
+ }
+ } else if (response._data) {
+ calendarLink.value = (response._data as any).data.url;
+ }
+ },
+ });
+})
+
</script>
<template>
diff --git a/web/pages/conferences.vue b/web/pages/conferences.vue
new file mode 100644
index 0000000..993bc66
--- /dev/null
+++ b/web/pages/conferences.vue
@@ -0,0 +1,124 @@
+<script setup lang="ts">
+import { MapPinPlus, MapPin } from 'lucide-vue-next';
+import { expireAuth } from '~/composables/expire-auth';
+
+definePageMeta({
+ middleware: ['logged-in']
+})
+
+interface Conference {
+ id: number,
+ url: string,
+ title: string,
+ venue: string,
+ city: string,
+}
+
+const setting = ref(false)
+const conferences = ref([] as Conference[])
+
+const conferenceStore = useConferenceStore();
+const errorStore = useErrorStore();
+const authStore = useAuthStore();
+const config = useRuntimeConfig();
+
+const status = ref('idle' as 'loading' | 'idle')
+
+const fetchConferences = () => {
+ status.value = 'loading'
+ $api(config.public.baseURL + '/conference', {
+ method: 'GET',
+ onResponse: ({ response }) => {
+ if (!response.ok) {
+ if (response.status === 401) {
+ expireAuth()
+ return
+ }
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ }
+ status.value = 'idle'
+ conferences.value = response._data.data
+ },
+ })
+}
+
+const deleteConference = (id: number) => {
+ // todo make this better
+ $api(config.public.baseURL + '/conference', {
+ method: 'DELETE',
+ body: {
+ id: id
+ },
+ onResponse: ({ response }) => {
+ if (!response.ok) {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ }
+ },
+ })
+}
+
+const selectConference = async (c) => {
+ setting.value = true
+ conferenceStore.id = c.id
+ try {
+ await fetchSchedule()
+ await fetchFavourites()
+ navigateTo({ path: "/events" })
+ } catch (e) {
+ conferenceStore.clear()
+ setting.value = false
+ }
+}
+
+onMounted(fetchConferences)
+</script>
+
+<template>
+ <template v-if="!setting">
+ <Panel title="Conferences" :icon="MapPin">
+ <span class="loading-text" v-if="status === 'loading'"><Spinner color="var(--color-text-muted)" />Fetching conferences...</span>
+ <div class="conference-list" v-if="conferences.length > 0 && status !== 'loading'">
+ <template v-for="conference of conferences">
+ <span class="title">{{ conference.title }}</span>
+ <span>{{ conference.city }}</span>
+ <span>{{ conference.venue }}</span>
+ <span class="actions">
+ <Button v-if="authStore.admin" kind="secondary" @click="() => { deleteConference(conference.id) }">Delete</Button>
+ <Button @click="() => { selectConference(conference) }">Select</Button>
+ </span>
+ </template>
+ </div>
+ <p v-if="conferences.length == 0 && status !== 'loading'">
+ There are no conferences to display.
+ </p>
+ </Panel>
+
+ <Panel v-if="authStore.admin" title="Add conference" :icon="MapPinPlus">
+ <AddConference @update="fetchConferences" />
+ </Panel>
+ </template>
+ <template v-else>
+ <div class="loading">
+ <span class="loading-text"><Spinner color="var(--color-text-muted)" />Setting conference...</span>
+ </div>
+ </template>
+</template>
+
+<style>
+.conference-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr 2fr 1fr;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.conference-list > .title {
+ font-weight: bold;
+}
+
+.conference-list > .actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-self: flex-end;
+}
+</style> \ No newline at end of file
diff --git a/web/pages/events.vue b/web/pages/events.vue
index 093e959..d369bdc 100644
--- a/web/pages/events.vue
+++ b/web/pages/events.vue
@@ -2,12 +2,21 @@
import { Calendar, SquareGanttChart } from 'lucide-vue-next';
import { useScheduleStore } from '~/stores/schedule';
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
+
const scheduleStore = useScheduleStore();
</script>
<template>
- <Panel title="Events" :icon="SquareGanttChart" v-if="scheduleStore.schedule">
+ <div v-if="scheduleStore.status === 'pending'" class="loading">
+ <span class="loading-text">
+ <Spinner color="var(--color-text-muted)" />Updating schedule...
+ </span>
+ </div>
+ <Panel title="Events" :icon="SquareGanttChart" v-else>
<div v-for="[day, events] of Object.entries(scheduleStore.eventsPerDay)" :key="day" class="events-container">
<ul class="events-list">
<li v-for="event in events" :key="event.id" class="event-item" :data-index="event.id">
diff --git a/web/pages/index.vue b/web/pages/index.vue
index c455678..e662bc7 100644
--- a/web/pages/index.vue
+++ b/web/pages/index.vue
@@ -1,4 +1,8 @@
<script setup lang="ts">
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
+
const scheduleStore = useScheduleStore();
const destination = ref()
@@ -13,6 +17,11 @@ if (scheduleStore.isConferenceOngoing()) {
</script>
<template>
+ <div v-if="scheduleStore.status === 'pending'" class="loading">
+ <span class="loading-text">
+ <Spinner color="var(--color-text-muted)" />Updating schedule...
+ </span>
+ </div>
<Panel kind="success">
<span class="text-icon">
<Spinner />
diff --git a/web/pages/live.vue b/web/pages/live.vue
index d69dce5..638910e 100644
--- a/web/pages/live.vue
+++ b/web/pages/live.vue
@@ -4,6 +4,10 @@ import EventListing from '~/components/EventListing.vue';
import Panel from '~/components/Panel.vue';
import { type Event } from '~/stores/schedule';
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
+
const favouritesStore = useFavouritesStore();
const scheduleStore = useScheduleStore();
@@ -56,6 +60,11 @@ function isToday(date: Date): boolean {
</script>
<template>
+ <div v-if="scheduleStore.status === 'pending'" class="loading">
+ <span class="loading-text">
+ <Spinner color="var(--color-text-muted)" />Updating schedule...
+ </span>
+ </div>
<Panel kind="emphasis" class="ongoing" v-if="happeningNow.length > 0" title="Now" :icon="Radio">
<ul class="events-list">
<li v-for="event in showAllHappeningNow ? happeningNow : favouritesHappeningNow" :key="event.id">
diff --git a/web/pages/login/[[provider]].vue b/web/pages/login/[[provider]].vue
index bfc7e69..58cf2b1 100644
--- a/web/pages/login/[[provider]].vue
+++ b/web/pages/login/[[provider]].vue
@@ -16,6 +16,7 @@ const basicAuthEnabled = ref(false)
const route = useRoute()
const config = useRuntimeConfig()
+const authStore = useAuthStore()
const loginOptionsStore = useLoginOptionsStore()
const headers = useRequestHeaders(['cookie'])
@@ -25,6 +26,17 @@ watch(loginOptions, (options) => {
basicAuthEnabled.value = options.some(o => o.type === 'basic')
})
+const authFail = (e: any) => {
+ if ((e as FetchError).data) {
+ error.value = e.data.message
+ } else {
+ error.value = "An unknown error occurred"
+ }
+
+ authenticating.value = false
+ authenticatingProvider.value = ''
+}
+
const handleBasicAuth = async (e: Event, providerName: string) => {
const target = e.target as HTMLFormElement;
const formData = new FormData(target);
@@ -32,48 +44,43 @@ const handleBasicAuth = async (e: Event, providerName: string) => {
authenticating.value = true
authenticatingProvider.value = providerName
- try {
- await $fetch(config.public.baseURL + '/login/' + providerName, {
- method: 'POST',
- body: JSON.stringify(Object.fromEntries(formData)),
- headers: headers,
- server: false,
- });
-
- navigateTo("/");
- } catch (e: any) {
- if ((e as FetchError).data) {
- error.value = e.data.message
- } else {
- error.value = "An unknown error occurred"
- }
+ $fetch(config.public.baseURL + '/login/' + providerName, {
+ method: 'POST',
+ body: JSON.stringify(Object.fromEntries(formData)),
+ headers: headers,
+ server: false,
+ onResponse: ({ response }) => {
+ authStore.token = response._data.data.token
+ authStore.username = response._data.data.username
+ authStore.admin = response._data.data.admin
- authenticating.value = false
- authenticatingProvider.value = ''
- }
+ navigateTo("/");
+ },
+ onResponseError: authFail
+ });
+
}
const handleOIDCAuth = async (providerName: string) => {
authenticating.value = true
authenticatingProvider.value = providerName
- try {
- let response: any = await $fetch(config.public.baseURL + '/login/' + providerName, {
- method: 'POST',
- headers: headers,
- server: false,
- });
- navigateTo(response.data.url, { external: true })
- } catch (e: any) {
- if ((e as FetchError).data) {
- error.value = e.data.message
- } else {
- error.value = "An unknown error occurred"
- }
+ $fetch(config.public.baseURL + '/login/' + providerName, {
+ method: 'POST',
+ headers: headers,
+ server: false,
+ onResponse: ({ response }) => {
+ if (response._data.data.url) {
+ navigateTo(response._data.data.url, { external: true })
+ } else {
+ authStore.token = response._data.data.token
+ authStore.admin = response._data.data.admin
- authenticating.value = false
- authenticatingProvider.value = ''
- }
+ navigateTo("/");
+ }
+ },
+ onResponseError: authFail
+ });
}
onMounted(async () => {
@@ -230,30 +237,9 @@ div.auth-form {
gap: 1.5rem;
}
-form.basic-form {
- display: grid;
- gap: 1.5rem;
-}
-
div.auth-error {
color: var(--color-text-error);
- font-style: oblique;
-}
-
-div.form-group {
- display: flex;
- flex-direction: column;
-}
-
-label.form-label {
- display: block;
- font-size: 0.875rem;
- font-weight: 500;
- color: #374151;
-}
-
-div.form-input-container {
- margin-top: 0.25rem;
+ font-style: italic;
}
div.form-footer {
diff --git a/web/pages/tracks/[slug].vue b/web/pages/tracks/[slug].vue
index 27fb97d..9e1881d 100644
--- a/web/pages/tracks/[slug].vue
+++ b/web/pages/tracks/[slug].vue
@@ -2,6 +2,10 @@
import { TrainTrack } from 'lucide-vue-next';
import { useScheduleStore } from '~/stores/schedule';
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
+
const route = useRoute();
const scheduleStore = useScheduleStore();
@@ -9,6 +13,11 @@ const track = scheduleStore.schedule?.tracks.find((track) => track.slug === rout
</script>
<template>
+ <div v-if="scheduleStore.status === 'pending'" class="loading">
+ <span class="loading-text">
+ <Spinner color="var(--color-text-muted)" />Updating schedule...
+ </span>
+ </div>
<Panel v-if="track" :title="track.name" :breadcrumbs="[{ text: 'Tracks', to: '/tracks' }]" :icon="TrainTrack">
<ul class="events-list">
<li
diff --git a/web/pages/tracks/index.vue b/web/pages/tracks/index.vue
index 8d7534e..c3ec883 100644
--- a/web/pages/tracks/index.vue
+++ b/web/pages/tracks/index.vue
@@ -2,14 +2,22 @@
import { TrainTrack } from 'lucide-vue-next';
import Panel from '~/components/Panel.vue';
+definePageMeta({
+ middleware: ['logged-in', 'conference-selected']
+})
const scheduleStore = useScheduleStore();
</script>
<template>
- <Panel v-if="scheduleStore.schedule" title="Tracks" :icon="TrainTrack">
+ <div v-if="scheduleStore.status === 'pending'" class="loading">
+ <span class="loading-text">
+ <Spinner color="var(--color-text-muted)" />Updating schedule...
+ </span>
+ </div>
+ <Panel v-else title="Tracks" :icon="TrainTrack">
<ul class="tracks-list">
<li
- v-for="track in scheduleStore.schedule.tracks"
+ v-for="track in scheduleStore.schedule?.tracks"
:key="track.name"
class="tracks-item"
>
diff --git a/web/stores/auth.ts b/web/stores/auth.ts
new file mode 100644
index 0000000..71ba5c0
--- /dev/null
+++ b/web/stores/auth.ts
@@ -0,0 +1,12 @@
+import { useLocalStorage } from "@vueuse/core";
+import { defineStore } from "pinia";
+
+export const useAuthStore = defineStore('auth', () => {
+ const token = useLocalStorage('auth/token', null)
+ const username = useLocalStorage('auth/username', null)
+ const admin = useLocalStorage('auth/admin', false)
+
+ const isLoggedIn = () => token.value != null
+
+ return {token, username, admin, isLoggedIn}
+})
diff --git a/web/stores/conference.ts b/web/stores/conference.ts
new file mode 100644
index 0000000..2dbe82a
--- /dev/null
+++ b/web/stores/conference.ts
@@ -0,0 +1,18 @@
+import { useLocalStorage } from "@vueuse/core";
+import { defineStore } from "pinia";
+
+export const useConferenceStore = defineStore('conference', () => {
+ const id = useLocalStorage('conference/id', null)
+ const title = useLocalStorage('conference/title', null)
+ const venue = useLocalStorage('conference/venue', null)
+ const city = useLocalStorage('conference/city', null)
+
+ const clear = () => {
+ id.value = null
+ title.value = null
+ venue.value = null
+ city.value = null
+ }
+
+ return {id, title, venue, city, clear}
+})
diff --git a/web/stores/favourites.ts b/web/stores/favourites.ts
index 2bf7257..d502788 100644
--- a/web/stores/favourites.ts
+++ b/web/stores/favourites.ts
@@ -35,9 +35,5 @@ export const useFavouritesStore = defineStore('favourites', () => {
})
}
- const setStatus = (newStatus: 'idle' | 'pending') => {
- status.value = newStatus
- }
-
- return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite, setStatus}
+ return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite}
})
diff --git a/web/stores/login-options.ts b/web/stores/login-options.ts
index fd97a75..d66c833 100644
--- a/web/stores/login-options.ts
+++ b/web/stores/login-options.ts
@@ -10,13 +10,5 @@ export const useLoginOptionsStore = defineStore('loginOptions', () => {
const loginOptions = ref([] as LoginOption[])
const status = ref('idle' as 'idle' | 'pending')
- const setLoginOptions = (newLoginOptions: LoginOption[]) => {
- loginOptions.value = newLoginOptions
- }
-
- const setStatus = (newStatus: 'idle' | 'pending') => {
- status.value = newStatus
- }
-
- return {loginOptions, status, setLoginOptions, setStatus}
+ return {loginOptions, status}
})
diff --git a/web/stores/schedule.ts b/web/stores/schedule.ts
index d5f4b4c..83f274d 100644
--- a/web/stores/schedule.ts
+++ b/web/stores/schedule.ts
@@ -74,6 +74,7 @@ interface Link {
export const useScheduleStore = defineStore('schedule', () => {
const schedule = ref(null as Schedule | null)
+ const status = ref('idle' as 'idle' | 'pending')
const events = ref([] as Event[])
const eventsPerDay = ref({} as { [key: string]: Event[] })
@@ -101,7 +102,9 @@ export const useScheduleStore = defineStore('schedule', () => {
events.value.push(event)
- event.track = tracks.value[event.track as unknown as string]
+ if (event.track) {
+ event.track = tracks.value[event.track as unknown as string]
+ }
})
})
})
@@ -122,6 +125,7 @@ export const useScheduleStore = defineStore('schedule', () => {
eventsPerTrack.value = {}
events.value.forEach(event => {
+ if (!event.track) return
if (!eventsPerTrack.value[event.track.name]) {
eventsPerTrack.value[event.track.name] = []
}
@@ -147,7 +151,7 @@ export const useScheduleStore = defineStore('schedule', () => {
return schedule.value?.conference.start || 0
}
- return {schedule, events, eventsPerDay, eventsPerTrack, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate}
+ return {schedule, events, eventsPerDay, eventsPerTrack, status, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate}
})
function normalizeDates(event: Event, timeZone: string) {