Compare commits

...

10 Commits

Author SHA1 Message Date
cdricms
85045ab429 Soutenance 2026-03-21 12:16:25 +01:00
cdricms
83eddf89cd Some tweaks 2026-03-21 11:53:25 +01:00
cdricms
4cf85981eb Locations added 2025-03-10 16:25:12 +01:00
cdricms
7cb633b4c6 Fixed creation of users + better frontend handling of permissions 2025-03-06 17:34:52 +01:00
cdricms
3c6038bce1 Typo 2025-02-28 20:14:30 +01:00
cdricms
3e53c67a4d Planning fixed 2025-02-26 17:40:12 +01:00
cdricms
5ef6b82b83 Added logo modified site-name 2025-02-26 17:15:05 +01:00
cdricms
667cbcc6b1 Creating new admin user from CLI
go run ./cmd/migrate new admin
2025-02-26 12:51:29 +01:00
cdricms
efec258ae3 Article listing in dashboard 2025-02-25 18:37:00 +01:00
cdricms
a3f716446c Update + Delete articles 2025-02-25 17:49:37 +01:00
98 changed files with 78678 additions and 4535 deletions

73440
A3.pdf Normal file

File diff suppressed because one or more lines are too long

View File

@@ -3,18 +3,25 @@ package blogs
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
core "fr.latosa-escrima/core" core "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
) )
func HandleBlog(w http.ResponseWriter, r *http.Request) { func HandleBlog(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") identifier := r.PathValue("identifier")
query := "slug = ?"
if regexp.
MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`).
MatchString(identifier) {
query = "blog_id = ?"
}
var blog models.Blog var blog models.Blog
_, err := core.DB.NewSelect(). _, err := core.DB.NewSelect().
Model(&blog). Model(&blog).
Where("slug = ?", slug). Where(query, identifier).
Relation("Author"). Relation("Author").
ScanAndCount(context.Background()) ScanAndCount(context.Background())
if err != nil { if err != nil {

View File

@@ -18,7 +18,17 @@ var BlogsRoutes = map[string]core.Handler{
Handler: blogs.HandleCategories, Handler: blogs.HandleCategories,
Middlewares: []core.Middleware{Methods("GET")}, Middlewares: []core.Middleware{Methods("GET")},
}, },
"/blogs/{slug}": { "/blogs/{identifier}": {
Handler: blogs.HandleBlog, Handler: blogs.HandleBlog,
Middlewares: []core.Middleware{Methods("GET")}}, Middlewares: []core.Middleware{Methods("GET")}},
"/blogs/{blog_uuid}/delete": {
Handler: blogs.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"),
HasPermissions("blogs", "delete"), AuthJWT},
},
"/blogs/{blog_uuid}/update": {
Handler: blogs.HandleUpdate,
Middlewares: []core.Middleware{Methods("PATCH"),
HasPermissions("blogs", "update"), AuthJWT},
},
} }

View File

@@ -3,7 +3,6 @@ package events
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
@@ -32,8 +31,6 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Println(event)
_, err = core.DB.NewUpdate(). _, err = core.DB.NewUpdate().
Model(&event). Model(&event).
OmitZero(). OmitZero().

View File

@@ -0,0 +1,38 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleDelete(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
if _, err := core.DB.
NewDelete().
Model(&location).
WherePK().
Exec(context.Background()); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location deleted.",
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,56 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleLocation(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
err := core.DB.
NewSelect().
Model(&location).
Where("street = ? AND city = ? AND postal_code = ?", location.Street, location.City, location.PostalCode).
Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
var events []models.Event
err = core.DB.
NewSelect().
Model(&events).
Where("location = ? || ', ' || ? || ', ' || ?", location.Street, location.City, location.PostalCode).
Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
location.Events = &events
core.JSONSuccess{
Status: core.Success,
Message: "Location retrieved.",
Data: location,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,46 @@
package locations
import (
"context"
"fmt"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
)
func HandleLocations(w http.ResponseWriter, r *http.Request) {
var locations []*models.Location
if err := core.DB.
NewSelect().
Model(&locations).
Scan(context.Background()); err != nil {
fmt.Println("Error")
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
locations = utils.Map(locations, func(l *models.Location) *models.Location {
var events []models.Event
err := core.DB.
NewSelect().
Model(&events).
Where("location = ? || ', ' || ? || ', ' || ?", l.Street, l.City, l.PostalCode).
Scan(context.Background())
if err != nil {
return nil
}
l.Events = &events
return l
})
core.JSONSuccess{
Status: core.Success,
Message: "Locations retrieved.",
Data: locations,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,38 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleNew(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
if _, err := core.DB.
NewInsert().
Model(&location).
Ignore().
Exec(context.Background()); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location created.",
}.Respond(w, http.StatusCreated)
}

View File

@@ -0,0 +1,41 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleUpdate(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
_, err := core.DB.
NewUpdate().
Model(&location).
WherePK().
OmitZero().
Exec(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location updated",
Data: location,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,30 @@
package api
import (
"fr.latosa-escrima/api/locations"
"fr.latosa-escrima/core"
)
var LocationsRoutes = map[string]core.Handler{
"GET /locations/all": {
Handler: locations.HandleLocations,
Middlewares: []core.Middleware{Methods("GET")},
},
"/locations/new": {
Handler: locations.HandleNew,
Middlewares: []core.Middleware{Methods(("POST")),
HasPermissions("locations", "insert"), AuthJWT}},
"GET /locations": {
Handler: locations.HandleLocation,
Middlewares: []core.Middleware{Methods("GET")}},
"DELETE /locations": {
Handler: locations.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"),
HasPermissions("locations", "delete"), AuthJWT},
},
"PATCH /locations": {
Handler: locations.HandleUpdate,
Middlewares: []core.Middleware{Methods("PATCH"),
HasPermissions("blogs", "update"), AuthJWT},
},
}

View File

@@ -2,15 +2,21 @@ package shortcodes
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
) )
func HandleShortcodes(w http.ResponseWriter, r *http.Request) { func HandleShortcodes(w http.ResponseWriter, r *http.Request) {
var shortcodes []models.Shortcode var shortcodes []models.Shortcode
err := core.DB.NewSelect().Model(&shortcodes).Scan(context.Background()) err := core.DB.NewSelect().
Model(&shortcodes).
Relation("Media").
Scan(context.Background())
if err != nil { if err != nil {
core.JSONError{ core.JSONError{
Status: core.Error, Status: core.Error,
@@ -19,6 +25,26 @@ func HandleShortcodes(w http.ResponseWriter, r *http.Request) {
return return
} }
scheme := "http"
if r.TLS != nil || os.Getenv("ENVIRONMENT") != "DEV" { // Check if the request is over HTTPS
scheme = "https"
}
// Extract the host
host := r.Host
baseURL := fmt.Sprintf("%s://%s", scheme, host)
if os.Getenv("ENVIRONMENT") != "DEV" {
baseURL += "/api"
}
shortcodes = utils.Map(shortcodes, func(s models.Shortcode) models.Shortcode {
if s.MediaID == nil {
return s
}
s.Media.Author = nil
s.Media.URL = fmt.Sprintf("%s/media/%s/file", baseURL, s.MediaID)
return s
})
core.JSONSuccess{ core.JSONSuccess{
Status: core.Success, Status: core.Success,
Message: "Shortcodes retrieved.", Message: "Shortcodes retrieved.",

View File

@@ -8,9 +8,11 @@ import (
core "fr.latosa-escrima/core" core "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
) )
func HandleNew(w http.ResponseWriter, r *http.Request) { func HandleNew(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
var user models.User var user models.User
err := json.NewDecoder(r.Body).Decode(&user) err := json.NewDecoder(r.Body).Decode(&user)
if err != nil { if err != nil {
@@ -22,15 +24,8 @@ func HandleNew(w http.ResponseWriter, r *http.Request) {
} }
log.Println("User : ", user) log.Println("User : ", user)
res, err := user.Insert(core.DB, context.Background()) res, err := user.Insert(core.DB, ctx)
log.Println(res) log.Println(res)
// if res == nil {
// core.JSONError{
// Status: core.Error,
// Message: "The user couldn't be inserted.",
// }.Respond(w, http.StatusNotAcceptable)
// return
// }
if err != nil { if err != nil {
core.JSONError{ core.JSONError{
@@ -40,6 +35,24 @@ func HandleNew(w http.ResponseWriter, r *http.Request) {
return return
} }
userRoles := utils.Map(user.Roles, func(role models.Role) models.UserToRole {
return models.UserToRole{
UserID: user.UserID,
RoleID: role.ID,
}
})
for _, userRole := range userRoles {
_, err := core.DB.NewInsert().Model(&userRole).Ignore().Exec(ctx)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
}
core.JSONSuccess{ core.JSONSuccess{
Status: core.Success, Status: core.Success,
Message: "User inserted successfully.", Message: "User inserted successfully.",

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
@@ -11,6 +12,9 @@ import (
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
"github.com/google/uuid"
"github.com/uptrace/bun"
) )
type UpdateUserArgs struct { type UpdateUserArgs struct {
@@ -20,9 +24,11 @@ type UpdateUserArgs struct {
Password *string `json:"password,omitempty"` Password *string `json:"password,omitempty"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
Attributes *models.UserAttributes `json:"attributes"` Attributes *models.UserAttributes `json:"attributes"`
Roles *[]models.Role `json:"roles"`
} }
func HandleUpdate(w http.ResponseWriter, r *http.Request) { func HandleUpdate(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
var updateArgs UpdateUserArgs var updateArgs UpdateUserArgs
err := json.NewDecoder(r.Body).Decode(&updateArgs) err := json.NewDecoder(r.Body).Decode(&updateArgs)
if err != nil { if err != nil {
@@ -33,13 +39,34 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
user_uuid := r.PathValue("user_uuid")
uid, err := uuid.Parse(user_uuid)
if err != nil {
return
}
var user models.User var user models.User
err = core.DB.
NewSelect().
Model(&user).
Where("user_id = ?", user_uuid).
Relation("Roles").
Scan(ctx)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
updateQuery := core.DB.NewUpdate().Model(&user) updateQuery := core.DB.NewUpdate().Model(&user)
rolesInsert := []*bun.InsertQuery{}
rolesRemoved := []*bun.DeleteQuery{}
val := reflect.ValueOf(updateArgs) val := reflect.ValueOf(updateArgs)
typ := reflect.TypeOf(updateArgs) typ := reflect.TypeOf(updateArgs)
for i := 0; i < val.NumField(); i++ { for i := range val.NumField() {
field := val.Field(i) field := val.Field(i)
fieldname := typ.Field(i).Name fieldname := typ.Field(i).Name
@@ -52,6 +79,37 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
if field.IsValid() && !field.IsNil() && !field.IsZero() { if field.IsValid() && !field.IsNil() && !field.IsZero() {
if fieldname == "Password" { if fieldname == "Password" {
updateQuery.Set(fmt.Sprintf("%s = crypt(?, gen_salt('bf'))", strings.Split(tag, ",")[0]), field.Interface()) updateQuery.Set(fmt.Sprintf("%s = crypt(?, gen_salt('bf'))", strings.Split(tag, ",")[0]), field.Interface())
} else if fieldname == "Roles" {
_roles := field.Interface().(*[]models.Role)
if _roles == nil {
continue
}
currentRoles := utils.Map(user.Roles, func(role models.Role) uuid.UUID {
return role.ID
})
roles := utils.Map(*_roles, func(role models.Role) uuid.UUID {
return role.ID
})
log.Println(user.Roles)
toAdd, toRemove := utils.GetDiff(currentRoles, roles)
fmt.Println(toAdd, toRemove)
rolesInsert = utils.Map(toAdd, func(id uuid.UUID) *bun.InsertQuery {
userRole := models.UserToRole{
UserID: uid,
RoleID: id,
}
return core.DB.NewInsert().Model(&userRole).Ignore()
})
rolesRemoved = utils.Map(toRemove, func(id uuid.UUID) *bun.DeleteQuery {
return core.DB.NewDelete().Model((*models.UserToRole)(nil)).
Where("user_id = ? AND role_id = ?", uid, id)
})
} else { } else {
updateQuery.Set(fmt.Sprintf("%s = ?", strings.Split(tag, ",")[0]), field.Interface()) updateQuery.Set(fmt.Sprintf("%s = ?", strings.Split(tag, ",")[0]), field.Interface())
} }
@@ -61,11 +119,9 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
// Always update the `updated_at` field // Always update the `updated_at` field
updateQuery.Set("updated_at = ?", time.Now()) updateQuery.Set("updated_at = ?", time.Now())
uuid := r.PathValue("user_uuid")
_, err = updateQuery. _, err = updateQuery.
Where("user_id = ?", uuid). Where("user_id = ?", user_uuid).
Returning("*"). Exec(ctx)
Exec(context.Background())
if err != nil { if err != nil {
core.JSONError{ core.JSONError{
@@ -75,6 +131,28 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
for _, insert := range rolesInsert {
_, err = insert.Exec(ctx)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
}
for _, remove := range rolesRemoved {
_, err = remove.Exec(ctx)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
}
user.Password = "" user.Password = ""
core.JSONSuccess{ core.JSONSuccess{

View File

@@ -13,6 +13,7 @@ func HandleUsers(w http.ResponseWriter, r *http.Request) {
var users []models.User var users []models.User
count, err := core.DB.NewSelect(). count, err := core.DB.NewSelect().
Model(&users). Model(&users).
Order("created_at ASC").
Relation("Roles"). Relation("Roles").
ScanAndCount(context.Background()) ScanAndCount(context.Background())

View File

@@ -1,7 +1,8 @@
package main package main
import ( import (
"database/sql" "bufio"
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -9,9 +10,9 @@ import (
"fr.latosa-escrima/cmd/migrate/migrations" "fr.latosa-escrima/cmd/migrate/migrations"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/extra/bundebug" "github.com/uptrace/bun/extra/bundebug"
"github.com/uptrace/bun/migrate" "github.com/uptrace/bun/migrate"
@@ -42,8 +43,11 @@ func main() {
Password: os.Getenv("POSTGRES_PASSWORD"), Password: os.Getenv("POSTGRES_PASSWORD"),
} }
fmt.Println(dsn.ToString()) fmt.Println(dsn.ToString())
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn.ToString())))
db := bun.NewDB(sqldb, pgdialect.New()) db, err := core.InitDatabase(dsn)
if err != nil {
log.Fatal(err)
}
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
@@ -62,6 +66,7 @@ func main() {
Commands: []*cli.Command{ Commands: []*cli.Command{
newDBCommand(migrate.NewMigrator(db, migrations.Migrations)), newDBCommand(migrate.NewMigrator(db, migrations.Migrations)),
newAdminCommand(db),
}, },
} }
if err := app.Run(os.Args); err != nil { if err := app.Run(os.Args); err != nil {
@@ -217,3 +222,138 @@ func newDBCommand(migrator *migrate.Migrator) *cli.Command {
}, },
} }
} }
func newAdminCommand(db *bun.DB) *cli.Command {
return &cli.Command{
Name: "admin",
Usage: "Creation of admin role and super user",
Subcommands: []*cli.Command{
{
Name: "new",
Usage: "Creates a new admin user.",
Action: func(c *cli.Context) error {
ctx := context.Background()
/* CREATING ADMIN ROLE */
role := models.Role{
Name: "Admin",
}
res, err := db.
NewInsert().
Ignore().
Model(&role).
Returning("id").
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create admin role: %w", err)
}
fmt.Println("Admin role created successfully.")
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
// If no rows were affected, the role already exists, so query its id
if rowsAffected == 0 {
err = db.NewSelect().
Model(&role).
Where("name = ?", role.Name).
Scan(ctx)
if err != nil {
return fmt.Errorf("failed to fetch existing role id: %w", err)
}
}
/**/
// }
/* CREATING ADMIN USER */
// Prompt user for admin details
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter first name: ")
firstName, _ := reader.ReadString('\n')
fmt.Print("Enter last name: ")
lastName, _ := reader.ReadString('\n')
fmt.Print("Enter email: ")
email, _ := reader.ReadString('\n')
fmt.Print("Enter phone: ")
phone, _ := reader.ReadString('\n')
fmt.Print("Enter password: ")
password, _ := reader.ReadString('\n')
// Trim newline characters
firstName = strings.TrimSpace(firstName)
lastName = strings.TrimSpace(lastName)
email = strings.TrimSpace(email)
phone = strings.TrimSpace(phone)
password = strings.TrimSpace(password)
// Hash the password
// Create admin user
admin := &models.User{
FirstName: firstName,
LastName: lastName,
Email: email,
Password: password,
Phone: phone,
}
// Insert into database
if _, err := admin.Insert(db, ctx); err != nil {
return fmt.Errorf("failed to create admin user: %w", err)
}
/**/
/* LINKING ADMIN USER TO ADMIN ROLE */
userRole := models.UserToRole{
UserID: admin.UserID,
RoleID: role.ID,
}
if _, err := db.
NewInsert().
Model(&userRole).
Ignore().
Exec(ctx); err != nil {
return fmt.Errorf("failed to link user to ro admin role: %w", err)
}
/**/
/* LINKING ALL PERMISSIONS TO ADMIN ROLE */
var permissions []*models.Permission
if err := db.NewSelect().
Model(&permissions).
Scan(ctx); err != nil {
return fmt.Errorf("failed to gather all permissions: %w", err)
}
permissionsToRole := utils.Map(
permissions,
func(permission *models.Permission) *models.PermissionToRole {
return &models.PermissionToRole{
PermissionAction: permission.Action,
PermissionResource: permission.Resource,
RoleID: role.ID,
}
})
if _, err := db.NewInsert().
Model(&permissionsToRole).
Ignore().
Exec(ctx); err != nil {
return fmt.Errorf("failed to link every permission to admin role: %w", err)
}
/**/
fmt.Println("Admin user created successfully.")
return nil
},
},
},
}
}

View File

@@ -0,0 +1,30 @@
package migrations
import (
"context"
"fmt"
"fr.latosa-escrima/core/models"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [up migration] ")
_, err := db.
NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("description TEXT").
Exec(ctx)
_, err = db.
NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("location TEXT").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [down migration] ")
return nil
})
}

View File

@@ -17,5 +17,7 @@ type Event struct {
ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"` ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"`
FullDay bool `bun:"full_day,notnull,default:false" json:"fullDay"` FullDay bool `bun:"full_day,notnull,default:false" json:"fullDay"`
IsVisible bool `bun:"is_visible,notnull,default:true" json:"isVisible"` IsVisible bool `bun:"is_visible,notnull,default:true" json:"isVisible"`
Description *string `bun:"description" json:"description,omitempty"`
Location *string `bun:"location" json:"location,omitempty"`
Rrule string `bun:"rrule" json:"rrule"` Rrule string `bun:"rrule" json:"rrule"`
} }

View File

@@ -0,0 +1,16 @@
package models
import "github.com/uptrace/bun"
type Location struct {
bun.BaseModel `bun:"table:locations"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Street string `bun:"street,notnull,unique:location" json:"street"`
City string `bun:"city,notnull,unique:location" json:"city"`
PostalCode string `bun:"postal_code,notnull,unique:location" json:"postalCode"`
Latitude *float64 `bun:"latitude" json:"latitude,omitempty"`
Longitude *float64 `bun:"longitude" json:"longitude,omitempty"`
Events *[]Event `bun:"-" json:"events,omitempty"`
}

View File

@@ -32,10 +32,11 @@ type User struct {
} }
func (u *User) Insert(db *bun.DB, ctx context.Context) (sql.Result, error) { func (u *User) Insert(db *bun.DB, ctx context.Context) (sql.Result, error) {
u.Password = fmt.Sprintf("crypt('%s', gen_salt('bf'))", u.Password)
return db.NewInsert(). return db.NewInsert().
Model(u). Model(u).
Value("password", u.Password). Value("password", "crypt(?, gen_salt('bf'))", u.Password).
Ignore().
// Returning("user_id").
Exec(ctx) Exec(ctx)
} }

View File

@@ -10,7 +10,7 @@ import (
type Permissions []models.Permission type Permissions []models.Permission
func GetAllPermissions() Permissions { func GetAllPermissions() Permissions {
resources := []string{"users", "roles", "media", "events", "permissions", "shortcodes", "blogs"} resources := []string{"users", "roles", "media", "events", "permissions", "shortcodes", "blogs", "locations"}
var perms Permissions var perms Permissions
for _, resource := range resources { for _, resource := range resources {
perms = append(perms, Permissions{ perms = append(perms, Permissions{

View File

@@ -72,6 +72,9 @@ func InitDatabase(dsn DSN) (*bun.DB, error) {
_, err = db.NewCreateTable(). _, err = db.NewCreateTable().
Model((*m.UserToRole)(nil)).IfNotExists().Exec(ctx) Model((*m.UserToRole)(nil)).IfNotExists().Exec(ctx)
_, err = db.NewCreateTable().
Model((*m.Location)(nil)).IfNotExists().Exec(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -81,7 +81,8 @@ func main() {
api.MediaRoutes, api.MediaRoutes,
api.PermissionsRoutes, api.PermissionsRoutes,
api.RolesRoutes, api.RolesRoutes,
api.ShortcodesRoutes) api.ShortcodesRoutes,
api.LocationsRoutes)
core.HandleRoutes(mux, routes) core.HandleRoutes(mux, routes)
fmt.Printf("Serving on port %s\n", port) fmt.Printf("Serving on port %s\n", port)

42
backend/utils/get_diff.go Normal file
View File

@@ -0,0 +1,42 @@
package utils
import (
"log"
)
// GetDiff returns two slices: elements to add and elements to remove
func GetDiff[T comparable](current, newm []T) ([]T, []T) {
log.Println(current, newm)
// Use a single map with an int to track state:
// 1: only in current, 2: only in new, 3: in both
presence := make(map[T]int)
// Mark all items in current as 1
for _, item := range current {
presence[item] = 1
}
// Update map based on newm: add 2 if not present, set to 3 if present
for _, item := range newm {
if val, exists := presence[item]; exists {
presence[item] = val + 2 // 1 -> 3 (both)
} else {
presence[item] = 2 // only in new
}
}
var toAdd, toRemove []T
// Iterate once over the map to build results
for item, state := range presence {
switch state {
case 1: // Only in current -> remove
toRemove = append(toRemove, item)
case 2: // Only in new -> add
toAdd = append(toAdd, item)
// case 3: in both, do nothing
}
}
return toAdd, toRemove
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useApi } from "@/hooks/use-api";
import { Blog } from "@/types/types";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
const BlogEditor = dynamic(
() => import("@/components/article/edit").then((mod) => mod.default),
{
ssr: false,
loading: () => <Loader2 className="animate-spin" />,
},
);
export default function Page() {
const params = useParams<{ uuid: string }>();
const {
data: blog,
error,
mutate,
success,
isLoading,
} = useApi<Blog>(`/blogs/${params.uuid}`, {}, false, false);
return <BlogEditor blog={blog} />;
}

View File

@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { format } from "date-fns";
import { CircleX, Edit, MoreHorizontal, Trash } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Blog, Category } from "@/types/types";
import { useApi } from "@/hooks/use-api";
import request from "@/lib/request";
import IUser from "@/interfaces/IUser";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import hasPermissions from "@/lib/hasPermissions";
export default function BlogTable({ user }: { user: IUser }) {
const searchParams = useSearchParams();
const pathname = usePathname(); // Get current path
const { replace } = useRouter();
const { blogs: blogsPerm } = hasPermissions(user.roles, {
blogs: ["update", "insert", "delete"],
} as const);
const updateSearchParam = useCallback(
(key: string, value?: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
replace(`${pathname}?${params.toString()}`);
},
[searchParams, pathname, replace],
);
let url = "/blogs";
const category = searchParams.get("category");
if (category) url += `?category=${category}`;
const blogs = useApi<Blog[]>(url, {}, false, false);
const categories = useApi<Category[]>(
"/blogs/categories",
{},
false,
false,
);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [blogToDelete, setBlogToDelete] = React.useState<Blog | null>(null);
const handleDelete = async (blog: Blog) => {
setBlogToDelete(blog);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!blogToDelete) return;
try {
const res = await request(`/blogs/${blogToDelete.blogID}/delete`, {
method: "DELETE",
requiresAuth: true,
});
if (res.status === "Success") {
blogs.mutate();
}
setDeleteDialogOpen(false);
setBlogToDelete(null);
} catch (error) {
console.error("Failed to delete blog:", error);
}
};
return (
<div className="flex flex-col gap-4 m-4">
<div className="flex items-center justify-between gap-4">
<div className="flex gap-4">
<Select
value={category || ""}
onValueChange={(v) => {
updateSearchParam("category", v);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Catégories" />
</SelectTrigger>
<SelectContent>
{categories.data?.map((c) => (
<SelectItem key={c.category} value={c.category}>
{c.category} ({c.count})
</SelectItem>
))}
</SelectContent>
</Select>
{category && (
<Button
onClick={() => updateSearchParam("category")}
variant="destructive"
>
<CircleX />
</Button>
)}
</div>
{blogsPerm.insert && (
<Button asChild>
<Link href="/dashboard/blogs/new">Nouvel article</Link>
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Titre</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead>Auteur</TableHead>
<TableHead>Publié</TableHead>
{(blogsPerm.update || blogsPerm.delete) && (
<TableHead className="w-[100px]">
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{blogs.data?.map((blog) => (
<TableRow key={blog.blogID}>
<TableCell className="font-medium">
{blog.title}
</TableCell>
<TableCell>{blog.category}</TableCell>
<TableCell>
{blog.author.firstname}{" "}
{blog.author.lastname}
</TableCell>
<TableCell>
{format(
new Date(blog.published),
"MMM d, yyyy",
)}
</TableCell>
{(blogsPerm.delete || blogsPerm.update) && (
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">
Ouvrir le menu
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{blogsPerm.update && (
<DropdownMenuItem asChild>
<Link
href={`/dashboard/blogs/${blog.blogID}`}
className="flex items-center"
>
<Edit className="mr-2 h-4 w-4" />
Modifier
</Link>
</DropdownMenuItem>
)}
{blogsPerm.delete && (
<DropdownMenuItem
className="flex items-center text-destructive focus:text-destructive"
onClick={() =>
handleDelete(blog)
}
>
<Trash className="mr-2 h-4 w-4" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Êtes-vous sûr ?</DialogTitle>
<DialogDescription>
Cela supprimera définitivement l'article{" "}
<span className="font-medium">
{blogToDelete?.title}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Annuler
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Supprimer
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
const BlogEditor = dynamic(
() => import("@/components/article/edit").then((mod) => mod.default),
{
ssr: false,
loading: () => <Loader2 className="animate-spin" />,
},
);
export default function NewBlog() {
return <BlogEditor />;
}

View File

@@ -1,184 +0,0 @@
"use client";
import EditableText from "@/components/editable-text";
import { LocalEditor } from "@/components/editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
import useApiMutation, { useApi } from "@/hooks/use-api";
import sluggify from "@/lib/sluggify";
import { Category, NewBlog } from "@/types/types";
import { useEffect, useMemo, useState } from "react";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ActionButton,
ActionButtonDefault,
ActionButtonError,
ActionButtonLoading,
ActionButtonSuccess,
} from "@/components/action-button";
import ComboBox from "@/components/ui/combobox";
export default function BlogEditor() {
const { toast } = useToast();
const [title, setTitle] = useState("");
const [imageUrl, setImageUrl] = useState<string>("/placeholder.svg");
const [category, setCategory] = useState("");
const { data } = useApi<Category[]>(
"/blogs/categories",
undefined,
false,
false,
);
const [categories, setCategories] = useState<string[]>([]);
useEffect(() => {
if (data) setCategories(data.map((c) => c.category) ?? []);
}, [data]);
const content = localStorage.getItem("blog_draft") ?? "";
useEffect(() => {
const localImage = localStorage.getItem("blog_draft_image");
setImageUrl(
localImage && localImage.length > 0
? localImage
: "/placeholder.svg",
);
}, []);
const [summary, setSummary] = useState("");
const slug = useMemo(() => sluggify(title), [title]);
const {
trigger: newBlog,
isMutating: isSending,
isSuccess,
error,
} = useApiMutation<undefined, NewBlog>(
"/blogs/new",
{},
"POST",
true,
false,
);
return (
<section className="m-10 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<EditableText onChange={setTitle}>
<h1>
{title.length > 0 ? title : "Un titre doit-être fourni"}
</h1>
</EditableText>
<p className="italic">{slug}</p>
<Dialog>
<DialogTrigger>
<img
src={imageUrl}
alt="Blog cover"
className="w-full h-60 object-cover cursor-pointer rounded-lg"
/>
</DialogTrigger>
<DialogTitle></DialogTitle>
<DialogContent>
<Input
type="text"
placeholder="Enter image URL"
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
localStorage.setItem(
"blog_draft_image",
e.currentTarget.value,
);
}}
/>
</DialogContent>
</Dialog>
<div className="flex gap-4">
<Input
type="text"
placeholder="Enter a summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
<ComboBox
value={category}
setValue={setCategory}
key={categories.join(",")}
elements={categories}
trigger={(value) => (
<Button>
{value ?? "Selectionner une catégorie"}
</Button>
)}
onSubmit={(value) => {
setCategories((prev) => {
if (prev.includes(value)) return prev;
return [...prev, value];
});
setCategory(value);
}}
>
{(Item, element) => (
<Item value={element} key={element} label={element}>
{element}
</Item>
)}
</ComboBox>
</div>
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor content={content} setTitle={setTitle} />
</div>
</div>
<ActionButton
isLoading={isSending}
isSuccess={isSuccess}
error={error}
onClick={async () => {
try {
const blogContent = localStorage.getItem("blog_draft");
if (!blogContent) return;
if (title.length < 1) return;
const res = await newBlog({
title,
summary,
image: imageUrl,
slug,
content: blogContent,
category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
}}
>
<ActionButtonDefault>Publier</ActionButtonDefault>
<ActionButtonSuccess />
<ActionButtonError />
<ActionButtonLoading />
</ActionButton>
</section>
);
}

View File

@@ -1,13 +1,21 @@
"use client"; "use server";
import getMe from "@/lib/getMe";
import hasPermissions from "@/lib/hasPermissions";
import { redirect } from "next/navigation";
import NewBlog from "./_new";
import { Loader2 } from "lucide-react"; export default async function Page() {
import dynamic from "next/dynamic"; const me = await getMe();
if (
!me ||
me.status === "Error" ||
!me.data ||
!hasPermissions(me.data.roles, {
blogs: ["insert"],
} as const).all
) {
redirect("/dashboard");
}
const BlogEditor = dynamic(() => import("./new").then((mod) => mod.default), { return <NewBlog />;
ssr: false,
loading: () => <Loader2 className="animate-spin" />,
});
export default function Page() {
return <BlogEditor />;
} }

View File

@@ -1,7 +1,8 @@
"use server";
import getMe from "@/lib/getMe"; import getMe from "@/lib/getMe";
import hasPermissions from "@/lib/hasPermissions"; import hasPermissions from "@/lib/hasPermissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import UserDetailsPage from "./_user"; import BlogTable from "./blogs";
export default async function Page() { export default async function Page() {
const me = await getMe(); const me = await getMe();
@@ -10,11 +11,11 @@ export default async function Page() {
me.status === "Error" || me.status === "Error" ||
!me.data || !me.data ||
!hasPermissions(me.data.roles, { !hasPermissions(me.data.roles, {
users: ["get"], blogs: ["get"] as const,
}) }).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }
return <UserDetailsPage user={me.data} />; return <BlogTable user={me.data} />;
} }

View File

@@ -0,0 +1,89 @@
"use client";
import LocationDialog from "@/components/locations/location-dialog";
import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions";
import { useState } from "react";
import { Location } from "@/types/types";
import request from "@/lib/request";
import { useApi } from "@/hooks/use-api";
import { LocationCard } from "@/components/locations/location-card";
export default function LocationsPage({ user }: { user: IUser }) {
const { locations: locationsPerm } = hasPermissions(user.roles, {
locations: ["update", "insert", "delete"],
} as const);
const locations = useApi<Location[]>("/locations/all");
const onUpdate = async (l: Location) => {
try {
const res = await request("/locations", {
method: "PATCH",
body: l,
requiresAuth: true,
});
if (res.status === "Success") {
locations.mutate();
} else {
}
} catch (e) {
console.error(e);
}
};
const onDelete = async (l: Location) => {
try {
const res = await request("/locations", {
method: "DELETE",
body: l,
requiresAuth: true,
});
if (res.status === "Success") locations.mutate();
else {
}
} catch (e) {
console.error(e);
}
};
return (
<div className="p-4 flex flex-col gap-2">
{locationsPerm.insert && (
<div className="self-end">
<LocationDialog
onAdd={async (l) => {
try {
const res = await request("/locations/new", {
body: l,
method: "POST",
requiresAuth: true,
csrfToken: false,
});
if (res.status === "Success") {
locations.mutate();
} else {
}
} catch (e) {}
}}
/>
</div>
)}
<section className="flex flex-wrap gap-2">
{locations.data?.map((l) => {
return (
<LocationCard
key={`${l.city}:${l.street}:${l.postalCode}`}
location={l}
onUpdate={onUpdate}
onDelete={onDelete}
canUpdate={locationsPerm.update}
canDelete={locationsPerm.delete}
/>
);
})}
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use server";
import getMe from "@/lib/getMe";
import hasPermissions from "@/lib/hasPermissions";
import { redirect } from "next/navigation";
import LocationsPage from "./_locations";
export default async function Page() {
const me = await getMe();
if (
!me ||
me.status === "Error" ||
!me.data ||
!hasPermissions(me.data.roles, {
locations: ["get"],
} as const).all
) {
redirect("/dashboard");
}
return <LocationsPage user={me.data} />;
}

View File

@@ -1,238 +0,0 @@
"use client";
import { UserIcon, Building, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Role, User } from "@/types/types";
import { useParams } from "next/navigation";
import { useApi } from "@/hooks/use-api";
import { useState } from "react";
import request from "@/lib/request";
import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions";
export default function UserDetailsPage({ user }: { user: IUser }) {
const { uuid } = useParams<{ uuid: string }>();
const _user = useApi<User>(`/users/${uuid}`, {}, true);
const availableRoles = useApi<Role[]>("/roles", {}, true);
availableRoles.data ??= [];
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
// const [selectedOrg, setSelectedOrg] = useState("");
const addRole = async (role: Role) => {
const res = await request(
`/users/${_user.data?.userId}/roles/${role.id}/add`,
{ method: "PATCH", requiresAuth: true },
);
if (res.status === "Success") {
setSelectedRole(null);
_user.mutate();
}
};
const removeRole = async (role: Role) => {
const res = await request(
`/users/${_user.data?.userId}/roles/${role.id}/remove`,
{ method: "PATCH", requiresAuth: true },
);
if (res.status === "Success") _user.mutate();
};
const addOrganization = () => {
// if (selectedOrg && !user.organizations.includes(selectedOrg)) {
// setUser((prevUser) => ({
// ...prevUser,
// organizations: [...prevUser.organizations, selectedOrg],
// }));
// setSelectedOrg("");
// }
};
const removeOrganization = (orgToRemove: string) => {
// setUser((prevUser) => ({
// ...prevUser,
// organizations: prevUser.organizations.filter(
// (org) => org !== orgToRemove,
// ),
// }));
};
if (!_user.data || !_user.success) return <p>Error</p>;
return (
<div className="container mx-auto py-10">
<Card>
<CardContent className="pt-6">
<div className="grid gap-6">
<div className="flex items-center space-x-4">
<UserIcon className="h-12 w-12 text-gray-400" />
<div>
<h2 className="text-xl font-semibold">
{_user.data.firstname} {_user.data.lastname}
</h2>
<p className="text-sm text-gray-500">
{_user.data.email}
</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<h3 className="text-lg font-semibold mb-2">
Rôles
</h3>
<div className="flex flex-wrap gap-2">
{_user.data.roles?.map((role) => (
<Badge
key={role.id}
variant="secondary"
className="text-sm py-1 px-2"
>
{role.name}
{hasPermissions(user.roles, {
users: ["update"],
}) && (
<button
onClick={() =>
removeRole(role)
}
className="ml-2 text-gray-500 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))}
</div>
{hasPermissions(user.roles, {
users: ["update"],
}) && (
<div className="mt-2 flex space-x-2">
<Select
value={
selectedRole
? selectedRole.name
: ""
}
onValueChange={(s) => {
const r =
availableRoles.data?.find(
(r) => r.name === s,
);
console.log(r);
if (r) setSelectedRole(r);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
<SelectContent>
{availableRoles.data
.filter(
(org) =>
!_user.data?.roles?.includes(
org,
),
)
.map((role) => (
<SelectItem
key={role.id}
value={role.name}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={
!_user.data || !selectedRole
}
onClick={() =>
addRole(selectedRole!)
}
className="flex items-center"
>
<Building className="mr-2 h-4 w-4" />
Ajouter le rôle
</Button>
</div>
)}
</div>
{/*<div>
<h3 className="text-lg font-semibold mb-2">
Organizations
</h3>
<div className="flex flex-wrap gap-2">
{user.data.organizations.map((org) => (
<Badge
key={org}
variant="outline"
className="text-sm py-1 px-2"
>
{org}
<button
onClick={() =>
removeOrganization(org)
}
className="ml-2 text-gray-500 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="mt-2 flex space-x-2">
<Select
value={selectedOrg}
onValueChange={setSelectedOrg}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{availableOrganizations
.filter(
(org) =>
!user.organizations.includes(
org,
),
)
.map((org) => (
<SelectItem
key={org}
value={org}
>
{org}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={addOrganization}
className="flex items-center"
>
<Building className="mr-2 h-4 w-4" />
Add Org
</Button>
</div>
</div> */}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -12,7 +12,7 @@ export default async function Page({}) {
!me.data || !me.data ||
!hasPermissions(me.data.roles, { !hasPermissions(me.data.roles, {
users: ["get"], users: ["get"],
}) } as const).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }

View File

@@ -19,9 +19,11 @@ export default function PlanningPage({ user }: { user: IUser }) {
if (success) if (success)
return ( return (
<Planning <Planning
modifiable={hasPermissions(user.roles, { modifiable={
hasPermissions(user.roles, {
events: ["update", "insert", "delete"], events: ["update", "insert", "delete"],
})} } as const).all
}
events={requestedEvents ?? []} events={requestedEvents ?? []}
mutate={mutate} mutate={mutate}
/> />

View File

@@ -12,7 +12,7 @@ export default async function Page() {
!me.data || !me.data ||
!hasPermissions(me.data.roles, { !hasPermissions(me.data.roles, {
events: ["get"], events: ["get"],
}) } as const).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }

View File

@@ -77,7 +77,7 @@ export default function PhotoGallery({ user }: { user: IUser }) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Gallerie Photo</h1> <h1 className="text-3xl font-bold">Galerie Photo</h1>
<Button <Button
disabled={isLoading} disabled={isLoading}
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}

View File

@@ -11,7 +11,7 @@ export default async function Page() {
!me || !me ||
me.status === "Error" || me.status === "Error" ||
!me.data || !me.data ||
!hasPermissions(me.data.roles, { media: ["get"] }) !hasPermissions(me.data.roles, { media: ["get"] } as const).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }

View File

@@ -41,6 +41,10 @@ export default function RolesAndPermissions({ user }: { user: IUser }) {
const [newRoleName, setNewRoleName] = useState<string>(""); const [newRoleName, setNewRoleName] = useState<string>("");
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const { roles: rolesPerm } = hasPermissions(user.roles, {
roles: ["insert"],
} as const);
const { data: permissions } = useApi<PermissionsGrouped>( const { data: permissions } = useApi<PermissionsGrouped>(
"/permissions/grouped", "/permissions/grouped",
{}, {},
@@ -80,7 +84,7 @@ export default function RolesAndPermissions({ user }: { user: IUser }) {
<div className="container mx-auto p-4 space-y-6"> <div className="container mx-auto p-4 space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Rôles et Permissions</h1> <h1 className="text-2xl font-bold">Rôles et Permissions</h1>
{hasPermissions(user.roles, { roles: ["insert"] }) && ( {rolesPerm.insert && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
@@ -135,14 +139,17 @@ interface RoleCardProps {
} }
function RoleCard({ role, onDelete, permissions, user }: RoleCardProps) { function RoleCard({ role, onDelete, permissions, user }: RoleCardProps) {
const { roles, permissions: permPerms } = hasPermissions(user.roles, {
roles: ["delete", "update"],
permissions: ["update"],
} as const);
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>{toTitleCase(role.name)}</CardTitle> <CardTitle>{toTitleCase(role.name)}</CardTitle>
<Button <Button
disabled={ disabled={!roles.delete}
!hasPermissions(user.roles, { roles: ["delete"] })
}
variant="destructive" variant="destructive"
size="icon" size="icon"
onClick={onDelete} onClick={onDelete}
@@ -155,10 +162,11 @@ function RoleCard({ role, onDelete, permissions, user }: RoleCardProps) {
return ( return (
<ResourceSection <ResourceSection
disabled={ disabled={
!hasPermissions(user.roles, { !(roles.update && permPerms.update)
permissions: ["update"], // !hasPermissions(user.roles, {
roles: ["update"], // permissions: ["update"],
}) // roles: ["update"],
// })
} }
key={res} key={res}
resource={res} resource={res}

View File

@@ -12,7 +12,7 @@ export default async function Page() {
!hasPermissions(me.data.roles, { !hasPermissions(me.data.roles, {
roles: ["get"], roles: ["get"],
permissions: ["get"], permissions: ["get"],
}) } as const).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }

View File

@@ -11,7 +11,7 @@ export default async function Page() {
!me.data || !me.data ||
!hasPermissions(me.data.roles, { !hasPermissions(me.data.roles, {
shortcodes: ["get"], shortcodes: ["get"],
}) } as const).all
) { ) {
redirect("/dashboard"); redirect("/dashboard");
} }

View File

@@ -1,6 +1,10 @@
export const dynamic = "force-dynamic"; // Prevents static rendering export const dynamic = "force-dynamic"; // Prevents static rendering
import { LocationCard } from "@/components/locations/location-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -10,59 +14,139 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SITE_NAME } from "@/lib/constants";
import getShortcode from "@/lib/getShortcode"; import getShortcode from "@/lib/getShortcode";
import request from "@/lib/request";
import { Location } from "@/types/types";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
export default async function About() { // Define props interface for PricingCard
const make_contact_div = ( interface PricingCardProps {
<a className="w-full" href="/contact"> title: string;
<Button className="w-full" variant={"outline"}> price: number;
Prendre contact description: string;
features: string[];
isPopular?: boolean;
ctaText?: string;
ctaLink?: string;
}
const UnderConstructionBanner = () => {
return (
<Alert variant="destructive" className="rounded-2xl shadow-md mb-4">
<Info className="h-5 w-5 text-red-500 mr-2" />
<div>
<AlertTitle>Attention</AlertTitle>
<AlertDescription>
Cette page est encore en cours de construction et les
informations listées peuvent ne pas être exactes pour le
moment.
</AlertDescription>
</div>
</Alert>
);
};
// Reusable Pricing Card Component
const PricingCard: React.FC<PricingCardProps> = ({
title,
price,
description,
features,
isPopular = false,
ctaText = "Prendre contact",
ctaLink = "/contact",
}) => (
<Card
className={`border transition-all duration-300 hover:shadow-lg flex-1 max-w-md ${
isPopular ? "border-primary shadow-lg" : "shadow-sm"
}`}
>
<CardHeader className="text-center pb-2">
{isPopular && (
<Badge className="uppercase w-max self-center mb-3 bg-gradient-to-r from-indigo-500 to-purple-500">
Le plus populaire
</Badge>
)}
<CardTitle className="mb-4 text-2xl">{title}</CardTitle>
<span className="font-bold text-4xl">{price}</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
{description}
</CardDescription>
<CardContent>
<ul className="mt-6 space-y-3 text-sm">
{features.map((feature, index) => (
<li key={index} className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4 text-green-500" />
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<a href={ctaLink} className="w-full">
<Button
className={`w-full transition-all duration-300 ${
isPopular
? "bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600"
: "bg-gray-800 hover:bg-gray-900"
}`}
aria-label={`Contact us for ${title} plan`}
>
{ctaText}
</Button> </Button>
</a> </a>
); </CardFooter>
</Card>
);
export default async function About() {
const profileImage = await getShortcode("profile_image"); const profileImage = await getShortcode("profile_image");
const locations = await request<Location[]>("/locations/all", {
requiresAuth: false,
});
return ( return (
<> <div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div className=""> {/* Hero Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full p-12 items-stretch"> <div className="px-6 py-12 lg:py-16 lg:px-12">
<UnderConstructionBanner />
</div>
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6 w-full px-6 py-12 lg:px-12 lg:py-16 items-stretch">
{/* Text Section - Takes 2/3 on large screens */} {/* Text Section - Takes 2/3 on large screens */}
<div className="lg:col-span-2 flex flex-col justify-center"> <div className="lg:col-span-2 flex flex-col justify-center">
<Card className="h-full"> <Card className="h-full shadow-md transition-all duration-300 hover:shadow-xl">
<CardHeader className="text-center p-4"> <CardHeader className="text-center p-6">
<CardTitle className="text-5xl"> <CardTitle className="text-4xl lg:text-5xl font-bold">
Nicolas GORUK Nicolas GORUK
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className="text-lg mt-2">
Président de l'association française de Président de l'association française de{" "}
Latosa Escrima {SITE_NAME}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="px-8 sm:px-10 py-14"> <CardContent className="px-6 sm:px-10 py-12 space-y-6">
<div className="flex flex-col gap-4 justify-center"> <div className="flex flex-col gap-6 justify-center text-justify">
<h2 className="text-center text-xl font-semibold sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-2xl">
Lorem ipsum, dolor sit amet Notre mission
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-base">
Lorem ipsum dolor sit amet consectetur Chez {SITE_NAME}, nous nous engageons à
adipisicing elit. Debitis accusamus promouvoir l'excellence. Nous offrons un
illum, nam nemo quod delectus velit environnement dynamique pour tous nos
repellat odio dolorum sapiente soluta, membres, avec des événements réguliers et
aliquam atque praesentium ea placeat ad, des opportunités uniques.
neque eveniet adipisci?
</p> </p>
<h2 className="text-center text-xl font-semibold sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-2xl">
Lorem ipsum, dolor sit amet Notre histoire
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-base">
Lorem ipsum dolor sit amet consectetur Fondée en [année], notre association a
adipisicing elit. Debitis accusamus grandi pour devenir un acteur clé dans
illum, nam nemo quod delectus velit [domaine]. Nous avons organisé [nombre]
repellat odio dolorum sapiente soluta, événements et touché plus de [nombre]
aliquam atque praesentium ea placeat ad, personnes grâce à nos initiatives.
neque eveniet adipisci?
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -72,136 +156,62 @@ export default async function About() {
{/* Image Section - Takes 1/3 on large screens */} {/* Image Section - Takes 1/3 on large screens */}
<div className="lg:col-span-1 flex items-center"> <div className="lg:col-span-1 flex items-center">
<img <img
className="w-full h-full object-cover rounded" className="w-full h-full object-cover rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
src={ src={
profileImage?.media?.url ?? profileImage?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-dark-1.svg" "https://shadcnblocks.com/images/block/placeholder-dark-1.svg"
} }
alt="president profile image" alt="Portrait de Nicolas GORUK, président de l'association"
/> />
</div> </div>
</section>
{/* Locations Section */}
{locations.data && locations.data.length > 0 && (
<section className="py-16 px-6 lg:px-12">
<h2 className="scroll-m-20 border-b pb-3 text-3xl font-semibold tracking-tight text-center">
Retrouvez-nous
</h2>
<div className="mt-12 flex flex-wrap gap-6 justify-center">
{locations.data.map((l: Location) => (
<LocationCard
key={`${l.street}-${l.city}`}
location={l}
/>
))}
</div> </div>
<div className="max-w-2xl mx-auto text-center mb-10 lg:mb-14"> </section>
<h2 className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"> )}
{/* Pricing Section */}
<section className="py-16 px-6 lg:px-12">
<div className="max-w-3xl mx-auto text-center mb-12">
<h2 className="scroll-m-20 border-b pb-3 text-3xl font-semibold tracking-tight">
Tarifs Tarifs
</h2> </h2>
<p className="mt-1 text-muted-foreground"> <p className="mt-3 text-muted-foreground text-base">
License accessible à partir de 90€. Aide "une aide de Adhésion à partir de [prix].
l'état" possible.
</p> </p>
<p className="mt-1 text-muted-foreground"> <p className="mt-2 text-muted-foreground text-base">
equipement (gants, casque) pris en compte. Prévoir une Équipement (gants, casque) fourni. Prévoir une tenue
tenue sportive adaptée. sportive adaptée.
</p> </p>
</div> </div>
<div className="mt-12 flex flex-col sm:flex-row px-12 justify-center gap-6 lg:items-center"> <div className="mt-12 flex flex-col sm:flex-row justify-center gap-6 lg:items-center">
<Card className="border-primary"> <PricingCard
<CardHeader className="text-center pb-2"> title="Étudiant"
<Badge className="uppercase w-max self-center mb-3"> price={125}
Most popular description="Tarif d'une année pour un étudiant."
</Badge> features={[]}
<CardTitle className="!mb-7">Startup</CardTitle> />
<span className="font-bold text-5xl">£39</span> <PricingCard
</CardHeader> title="Normal"
<CardDescription className="text-center w-11/12 mx-auto"> price={150}
All the basics for starting a new business description="Tarif normal pour n'importe quel individu."
</CardDescription> features={[]}
<CardContent> />
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
2 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>
<a href="/contact" className="w-full">
<Button className="w-full">
Prendre contact
</Button>
</a>
</CardFooter>
</Card>
<Card>
<CardHeader className="text-center pb-2">
<CardTitle className="mb-7">Team</CardTitle>
<span className="font-bold text-5xl">£89</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
Everything you need for a growing business
</CardDescription>
<CardContent>
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
5 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>{make_contact_div}</CardFooter>
</Card>
<Card>
<CardHeader className="text-center pb-2">
<CardTitle className="mb-7">Enterprise</CardTitle>
<span className="font-bold text-5xl">149</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
Advanced features for scaling your business
</CardDescription>
<CardContent>
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
10 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>{make_contact_div}</CardFooter>
</Card>
</div> </div>
</section>
</div> </div>
</>
); );
} }

View File

@@ -1,6 +1,7 @@
"use server"; "use server";
import BlogArticle from "@/components/article"; import BlogArticle from "@/components/article";
import getMe from "@/lib/getMe";
import request from "@/lib/request"; import request from "@/lib/request";
import { Blog } from "@/types/types"; import { Blog } from "@/types/types";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -20,5 +21,7 @@ export default async function HistoryDetails({
return notFound(); return notFound();
} }
return <BlogArticle blog={blog.data} />; const me = await getMe();
return <BlogArticle blog={blog.data} user={me?.data} />;
} }

View File

@@ -32,7 +32,7 @@ export default function PhotoGallery() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Gallerie Photo</h1> <h1 className="text-3xl font-bold">Galerie Photo</h1>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex w-full h-full justify-center"> <div className="flex w-full h-full justify-center">

View File

@@ -156,7 +156,7 @@ export default async function Home() {
tagLine="" tagLine=""
cta="Voir toutes les photos" cta="Voir toutes les photos"
ctaHref="/gallery" ctaHref="/gallery"
title="Gallerie" title="Galerie"
> >
<HomepageGalleryItems /> <HomepageGalleryItems />
</Gallery> </Gallery>
@@ -183,7 +183,7 @@ export default async function Home() {
})} })}
</Gallery> </Gallery>
)} )}
<Testimonial /> {/*<Testimonial />*/}
</div> </div>
</main> </main>
); );

View File

@@ -4,6 +4,7 @@
body { body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
pointer-events: auto !important;
} }
@layer base { @layer base {
@@ -82,6 +83,7 @@ body {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View File

@@ -1,9 +1,11 @@
import "@/lib/utils";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "@/app/globals.css"; import "@/app/globals.css";
import SWRLayout from "@/components/layouts/swr-layout"; import SWRLayout from "@/components/layouts/swr-layout";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { SITE_NAME } from "@/lib/constants";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -16,15 +18,15 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Latosa Escrima France", title: `${SITE_NAME} France`,
description: "Site officiel de Latosa Escrima France.", description: `Site officiel de ${SITE_NAME} France.`,
openGraph: { openGraph: {
title: "Latosa Escrima France", title: `${SITE_NAME} France`,
description: "Site officiel de Latosa Escrima France.", description: `Site officiel de ${SITE_NAME} France.`,
type: "website", type: "website",
countryName: "France", countryName: "France",
}, },
applicationName: "Latosa Escrima France", applicationName: `${SITE_NAME} France`,
authors: { authors: {
name: "Wing Tsun Picardie", name: "Wing Tsun Picardie",
url: "https://www.youtube.com/@WingTsunPicardie", url: "https://www.youtube.com/@WingTsunPicardie",

13
frontend/app/robots.ts Normal file
View File

@@ -0,0 +1,13 @@
import { BASE_URL } from "@/lib/constants";
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard/", "/gallery/"],
},
sitemap: `${BASE_URL}/sitemap.xml`,
};
}

43
frontend/app/sitemap.ts Normal file
View File

@@ -0,0 +1,43 @@
import { BASE_URL } from "@/lib/constants";
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: `${BASE_URL}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/blog`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.5,
},
{
url: `${BASE_URL}/planning`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${BASE_URL}/gallery`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${BASE_URL}/contact`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.8,
},
];
}

View File

@@ -13,10 +13,10 @@ import {
Loader2, Loader2,
Camera, Camera,
UserRoundCog, UserRoundCog,
MapPin,
} from "lucide-react"; } from "lucide-react";
import { NavMain } from "@/components/nav-main"; import { NavMain } from "@/components/nav-main";
import { NavProjects } from "@/components/nav-projects";
import { NavUser } from "@/components/nav-user"; import { NavUser } from "@/components/nav-user";
import { TeamSwitcher } from "@/components/team-switcher"; import { TeamSwitcher } from "@/components/team-switcher";
import { import {
@@ -27,18 +27,12 @@ import {
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import useMe from "@/hooks/use-me"; import useMe from "@/hooks/use-me";
import { useEffect } from "react";
// This is sample data. // This is sample data.
const data = { const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
teams: [ teams: [
{ {
name: "Latosa-Escrima", name: "Latosa Escrima",
logo: GalleryVerticalEnd, logo: GalleryVerticalEnd,
plan: "", plan: "",
}, },
@@ -61,6 +55,17 @@ const data = {
}, },
], ],
}, },
{
title: "Adresses",
url: "/dashboard/locations",
icon: MapPin,
items: [
{
title: "Listes des adresses",
url: "/dashboard/locations",
},
],
},
{ {
title: "Planning", title: "Planning",
icon: Calendar, icon: Calendar,
@@ -78,16 +83,8 @@ const data = {
icon: BookOpen, icon: BookOpen,
items: [ items: [
{ {
title: "Catégorie 1", title: "Articles",
url: "/dashboard/blogs/categorie-1", url: "/dashboard/blogs",
},
{
title: "Catégorie 2",
url: "/dashboard/blogs/categorie-2",
},
{
title: "Nouvelle catégorie",
url: "/dashboard/blogs/categories/new",
}, },
{ {
title: "Nouvel article", title: "Nouvel article",
@@ -96,7 +93,7 @@ const data = {
], ],
}, },
{ {
title: "Settings", title: "Configurations",
url: "/dashboard/settings", url: "/dashboard/settings",
icon: Settings2, icon: Settings2,
items: [ items: [

View File

@@ -3,8 +3,28 @@ import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon } from "lucide-react";
import { Blog } from "@/types/types"; import { Blog } from "@/types/types";
import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions";
import { Button } from "./ui/button";
import DeleteArticleButton from "./article/delete-button";
import Link from "next/link";
const BlogArticle: React.FC<{ blog: Blog; user?: IUser }> = ({
blog,
user,
}) => {
const perms =
user && hasPermissions(user.roles, { blogs: ["update"] } as const);
const UpdateButton = () => {
if (!perms?.blogs.update) return;
return (
<Button variant="secondary">
<Link href={`/dashboard/blogs/${blog.blogID}`}>Modifier</Link>
</Button>
);
};
const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
return ( return (
<article className="mx-auto max-w-3xl px-4 py-8 md:py-12"> <article className="mx-auto max-w-3xl px-4 py-8 md:py-12">
<div className="space-y-6"> <div className="space-y-6">
@@ -28,6 +48,7 @@ const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
)} )}
</div> </div>
<div className="flex justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar> <Avatar>
<AvatarImage <AvatarImage
@@ -48,14 +69,17 @@ const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
<p className="text-sm text-muted-foreground">idk</p> <p className="text-sm text-muted-foreground">idk</p>
</div> </div>
</div> </div>
<div className="flex gap-2">
<UpdateButton />
<DeleteArticleButton id={blog.blogID} user={user} />
</div>
</div>
<div className="relative aspect-video overflow-hidden rounded-lg"> <div className="relative aspect-video overflow-hidden rounded-lg">
<Image <img
src={blog.image ?? "/placeholder.svg"} src={blog.image ?? "/placeholder.svg"}
alt={blog.title} alt={blog.title}
className="object-cover" className="object-contain"
fill
priority
/> />
</div> </div>

View File

@@ -0,0 +1,95 @@
"use client";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import hasPermissions from "@/lib/hasPermissions";
import IUser from "@/interfaces/IUser";
import request from "@/lib/request";
interface DeleteArticleButtonProps {
id: string;
user?: IUser;
}
const DeleteArticleButton: React.FC<DeleteArticleButtonProps> = ({
id,
user,
}) => {
const perms =
user && hasPermissions(user.roles, { blogs: ["delete"] } as const);
if (!perms?.blogs.delete) {
return null;
}
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
await request(`/blogs/${id}/delete`, {
requiresAuth: true,
method: "DELETE",
});
// if (res.status === "Success") {
// toast({
// title: "Article supprimé",
// description: "L'article a été supprimé avec succès.",
// });
// setOpen(false); // Only close on success
// router.replace("/blogs");
// } else {
// toast({
// title: "Erreur",
// description: res?.message || "Une erreur est survenue.",
// variant: "destructive",
// });
// // Don't setOpen(false) here - keep dialog open on error
// }
// } catch (e: unknown) {
// toast({
// title: "Erreur",
// description:
// (e as Error)?.message || "Une erreur est survenue.",
// variant: "destructive",
// });
// // Don't setOpen(false) here - keep dialog open on exception
// } finally {
// setIsDeleting(false); // Just reset the loading state
// }
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Êtes-vous sûr de vouloir supprimer cet article ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement cet article.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<Button variant="destructive" onClick={handleDelete}>
Supprimer
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteArticleButton;

View File

@@ -0,0 +1,261 @@
"use client";
import EditableText from "@/components/editable-text";
import { LocalEditor } from "@/components/editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
import useApiMutation, { useApi } from "@/hooks/use-api";
import sluggify from "@/lib/sluggify";
import { Blog, Category, NewBlog } from "@/types/types";
import { useEffect, useMemo, useState } from "react";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ActionButton,
ActionButtonDefault,
ActionButtonError,
ActionButtonLoading,
ActionButtonSuccess,
} from "@/components/action-button";
import ComboBox from "@/components/ui/combobox";
export type BlogState = Partial<Blog> & {
[K in keyof Required<Blog>]?: Blog[K] | null; // Allows null for resetting or un-setting fields
};
export default function BlogEditor({ blog }: { blog?: Blog }) {
const { toast } = useToast();
const setItem = (b: BlogState) =>
blog
? localStorage.setItem(`draft_${blog.blogID}`, JSON.stringify(b))
: localStorage.setItem("draft", JSON.stringify(b));
const [draft, setDraft] = useState<BlogState>(() => {
if (blog) {
setItem(blog);
return blog;
}
const d = {
slug: "",
content: "",
title: "",
category: "",
image: "/placeholder.svg",
};
try {
const _draft = localStorage.getItem("draft");
if (_draft) {
const draft: BlogState = JSON.parse(_draft);
return draft;
}
} catch (e) {}
return d;
});
const handleChange =
(field: keyof BlogState) =>
(e: React.ChangeEvent<HTMLInputElement> | string) => {
const value = typeof e === "string" ? e : e.target.value;
setDraft((prev) => {
const n: typeof prev = {
...prev,
[field]: value,
};
setItem(n);
return n;
});
};
const { data } = useApi<Category[]>(
"/blogs/categories",
undefined,
false,
false,
);
const [categories, setCategories] = useState<string[]>(
draft.category ? [draft.category] : [],
);
useEffect(() => {
if (data) setCategories(data.map((c) => c.category) ?? []);
}, [data]);
const slug = useMemo(() => {
const slug = draft.title ? sluggify(draft.title) : "";
handleChange("slug")(slug);
return slug;
}, [draft.title]);
const newBlog = useApiMutation<undefined, NewBlog>(
"/blogs/new",
{},
"POST",
true,
false,
);
const updateBlog = useApiMutation(
`/blogs/${blog?.blogID}/update`,
{},
"PATCH",
true,
false,
);
const handleNewBlog = async () => {
try {
if (!draft.content) return;
if (!draft.title || draft.title.length < 1) return;
const res = await newBlog.trigger({
title: draft.title,
summary: draft.summary,
image: draft.image,
slug,
content: draft.content,
category: draft.category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
};
const handleUpdateBlog = async () => {
try {
if (!draft.content) return;
if (!draft.title || draft.title.length < 1) return;
const res = await updateBlog.trigger({
title: draft.title,
summary: draft.summary,
image: draft.image,
slug,
content: draft.content,
category: draft.category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
};
return (
<section className="m-10 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<EditableText onChange={handleChange("title")}>
<h1>
{draft.title && draft.title.length > 0
? draft.title
: "Un titre doit-être fourni"}
</h1>
</EditableText>
<p className="italic">{slug}</p>
<Dialog>
<DialogTrigger>
<img
src={draft.image}
alt="Blog cover"
className="w-full h-60 object-cover cursor-pointer rounded-lg"
/>
</DialogTrigger>
<DialogTitle></DialogTitle>
<DialogContent>
<Input
type="text"
placeholder="Enter image URL"
value={draft.image}
onChange={handleChange("image")}
/>
</DialogContent>
</Dialog>
<div className="flex gap-4">
<Input
type="text"
placeholder="Enter a summary"
value={draft.summary}
onChange={handleChange("summary")}
/>
<ComboBox
value={draft.category ?? ""}
onValueChange={handleChange("category")}
key={categories.join(",")}
elements={categories}
trigger={(value) => (
<Button>
{value ?? "Selectionner une catégorie"}
</Button>
)}
onSubmit={(value) => {
setCategories((prev) => {
if (prev.includes(value)) return prev;
return [...prev, value];
});
handleChange("category")(value);
}}
>
{(Item, element) => (
<Item value={element} key={element} label={element}>
{element}
</Item>
)}
</ComboBox>
</div>
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor
onChange={handleChange("content")}
content={draft.content ?? ""}
onTitleChange={handleChange("title")}
/>
</div>
</div>
<ActionButton
isLoading={newBlog.isMutating || updateBlog.isMutating}
isSuccess={newBlog.isSuccess || updateBlog.isSuccess}
error={newBlog.error || updateBlog.error}
onClick={blog ? handleUpdateBlog : handleNewBlog}
>
<ActionButtonDefault>
{blog ? "Modifier" : "Publier"}
</ActionButtonDefault>
<ActionButtonSuccess />
<ActionButtonError />
<ActionButtonLoading />
</ActionButton>
</section>
);
}

View File

@@ -7,8 +7,6 @@ import {
AlignLeft, AlignLeft,
AlignRight, AlignRight,
Bold, Bold,
Code,
Code2,
Heading1, Heading1,
Heading2, Heading2,
Heading3, Heading3,
@@ -43,10 +41,14 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import {
import { Label } from "./ui/label"; Popover,
import { Input } from "./ui/input"; PopoverContent,
import { Switch } from "./ui/switch"; PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useState } from "react"; import { useState } from "react";
import { import {
Dialog, Dialog,
@@ -55,12 +57,27 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "./ui/dialog"; } from "@/components/ui/dialog";
import { SizeOption } from "./extensions/marks";
interface EditorMenuProps { interface EditorMenuProps {
editor: Editor | null; editor: Editor | null;
} }
const sizeOptions: SizeOption[] = ["xs", "sm", "base", "lg", "xl"];
const tailwindColors = [
{ value: "default", label: "Default" }, // Changed from '' to "default"
{ value: "gray-500", label: "Gray" },
{ value: "red-500", label: "Red" },
{ value: "yellow-500", label: "Yellow" },
{ value: "green-500", label: "Green" },
{ value: "blue-500", label: "Blue" },
{ value: "indigo-500", label: "Indigo" },
{ value: "purple-500", label: "Purple" },
{ value: "pink-500", label: "Pink" },
];
export function EditorMenu({ editor }: EditorMenuProps) { export function EditorMenu({ editor }: EditorMenuProps) {
if (!editor) { if (!editor) {
return null; return null;
@@ -70,6 +87,7 @@ export function EditorMenu({ editor }: EditorMenuProps) {
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className="flex flex-wrap gap-2 rounded-t-lg border bg-background p-1"> <div className="flex flex-wrap gap-2 rounded-t-lg border bg-background p-1">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* Existing formatting toggles */}
<ToggleGroup <ToggleGroup
type="multiple" type="multiple"
size="sm" size="sm"
@@ -154,30 +172,82 @@ export function EditorMenu({ editor }: EditorMenuProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Strikethrough</TooltipContent> <TooltipContent>Strikethrough</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="code"
aria-label="Toggle code"
aria-selected={editor.isActive("code")}
onClick={() =>
editor
.chain()
.focus()
.toggleCode()
.run()
}
>
<Code className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Code</TooltipContent>
</Tooltip>
</ToggleGroup> </ToggleGroup>
<Separator orientation="vertical" className="h-8" /> <Separator orientation="vertical" className="h-8" />
{/* Font Size Selector */}
<Select
value={
editor.isActive("fontSize")
? editor.getAttributes("fontSize").size ||
"base"
: "base"
}
onValueChange={(value: SizeOption) => {
editor
.chain()
.focus()
.setFontSize({ size: value })
.run();
}}
>
<SelectTrigger className="h-8 w-[100px]">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sizeOptions.map((size) => (
<SelectItem key={size} value={size}>
{size.toUpperCase()}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Text Color Selector */}
{/*<Select
value={
editor.isActive("textColor")
? editor.getAttributes("textColor").color ||
"default"
: "default"
}
onValueChange={(value) => {
if (value === "default") {
editor.chain().focus().unsetTextColor().run();
} else {
editor
.chain()
.focus()
.setTextColor({ color: value })
.run();
}
}}
>
<SelectTrigger className="h-8 w-[100px]">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{tailwindColors.map(({ value, label }) => (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-2">
<span
className={`h-3 w-3 rounded-full ${value === "default" ? "bg-gray-200" : `bg-${value}`}`}
/>
{label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select> */}
<Separator orientation="vertical" className="h-8" />
{/* Heading Selector */}
<Select <Select
value={getActiveHeading(editor)} value={getActiveHeading(editor)}
onValueChange={(value) => { onValueChange={(value) => {
@@ -229,6 +299,7 @@ export function EditorMenu({ editor }: EditorMenuProps) {
<Separator orientation="vertical" className="h-8" /> <Separator orientation="vertical" className="h-8" />
{/* Alignment Controls */}
<ToggleGroup <ToggleGroup
type="single" type="single"
size="sm" size="sm"
@@ -324,6 +395,7 @@ export function EditorMenu({ editor }: EditorMenuProps) {
<Separator orientation="vertical" className="h-8" /> <Separator orientation="vertical" className="h-8" />
{/* List and Blockquote Controls */}
<ToggleGroup <ToggleGroup
type="multiple" type="multiple"
size="sm" size="sm"
@@ -373,26 +445,6 @@ export function EditorMenu({ editor }: EditorMenuProps) {
<TooltipContent>Ordered list</TooltipContent> <TooltipContent>Ordered list</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="codeBlock"
aria-label="Toggle code block"
aria-pressed={editor.isActive("codeBlock")}
onClick={() =>
editor
.chain()
.focus()
.toggleCodeBlock()
.run()
}
>
<Code2 className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Code block</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<ToggleGroupItem <ToggleGroupItem
@@ -418,6 +470,7 @@ export function EditorMenu({ editor }: EditorMenuProps) {
<Separator orientation="vertical" className="h-8" /> <Separator orientation="vertical" className="h-8" />
{/* Additional Controls */}
<div className="flex gap-1"> <div className="flex gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -477,6 +530,7 @@ export function EditorMenu({ editor }: EditorMenuProps) {
); );
} }
// Rest of the components remain unchanged
function getActiveHeading(editor: Editor) { function getActiveHeading(editor: Editor) {
if (editor.isActive("heading", { level: 1 })) return "1"; if (editor.isActive("heading", { level: 1 })) return "1";
if (editor.isActive("heading", { level: 2 })) return "2"; if (editor.isActive("heading", { level: 2 })) return "2";

View File

@@ -0,0 +1,222 @@
// extensions/customMarks.ts
import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from "@tiptap/core";
// Define available size options
const sizeOptions = {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
} as const;
export type SizeOption = keyof typeof sizeOptions;
export interface FontSizeOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (attributes: { size: SizeOption }) => ReturnType;
toggleFontSize: (attributes: { size: SizeOption }) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const fontSizeInputRegex = /(?:^|\s)(~((?:[^~]+))~)$/;
export const fontSizePasteRegex = /(?:^|\s)(~((?:[^~]+))~)/g;
export const FontSize = Mark.create<FontSizeOptions>({
name: "fontSize",
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
size: {
default: "base",
parseHTML: (element: HTMLElement) => {
const sizeClass = Object.entries(sizeOptions).find(
([, className]) =>
element.classList.contains(className),
)?.[0];
return sizeClass || "base";
},
renderHTML: (attributes: { size: SizeOption }) => ({
class: sizeOptions[attributes.size],
}),
},
};
},
parseHTML() {
return [
{
tag: "span",
getAttrs: (node: HTMLElement) => {
const hasSizeClass = Object.values(sizeOptions).some(
(className) => node.classList.contains(className),
);
return hasSizeClass ? {} : null;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setFontSize:
(attributes: { size: SizeOption }) =>
({ commands }) => {
return commands.setMark(this.name, attributes);
},
toggleFontSize:
(attributes: { size: SizeOption }) =>
({ commands }) => {
return commands.toggleMark(this.name, attributes);
},
unsetFontSize:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
addKeyboardShortcuts() {
return {
"Mod-s": () => this.editor.commands.toggleFontSize({ size: "sm" }),
};
},
addInputRules() {
return [
markInputRule({
find: fontSizeInputRegex,
type: this.type,
getAttributes: { size: "sm" }, // Default size for input rule
}),
];
},
addPasteRules() {
return [
markPasteRule({
find: fontSizePasteRegex,
type: this.type,
getAttributes: { size: "sm" }, // Default size for paste rule
}),
];
},
});
// TextColor remains unchanged
export interface TextColorOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textColor: {
setTextColor: (attributes: { color: string }) => ReturnType;
toggleTextColor: (attributes: { color: string }) => ReturnType;
unsetTextColor: () => ReturnType;
};
}
}
export const TextColor = Mark.create<TextColorOptions>({
name: "textColor",
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
color: {
default: null,
parseHTML: (element: HTMLElement) => {
const colorClass = Array.from(element.classList).find(
(cls) => cls.startsWith("text-"),
);
return colorClass ? colorClass.replace("text-", "") : null;
},
renderHTML: (attributes) => {
if (!attributes.color) return {};
// Use !important to override prose styles
return {
class: `text-${attributes.color} !text-${attributes.color}`,
};
},
},
};
},
parseHTML() {
return [
{
tag: "span",
getAttrs: (node: HTMLElement) => {
const colorClass = Array.from(node.classList).find((cls) =>
cls.startsWith("text-"),
);
return colorClass
? { color: colorClass.replace("text-", "") }
: null;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setTextColor:
(attributes: { color: string }) =>
({ commands }) => {
return commands.setMark(this.name, attributes);
},
toggleTextColor:
(attributes: { color: string }) =>
({ commands }) => {
return commands.toggleMark(this.name, attributes);
},
unsetTextColor:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-c": () =>
this.editor.commands.toggleTextColor({ color: "gray-500" }),
};
},
});

View File

@@ -13,19 +13,20 @@ import TextAlign from "@tiptap/extension-text-align";
import Youtube from "@tiptap/extension-youtube"; import Youtube from "@tiptap/extension-youtube";
import Dropcursor from "@tiptap/extension-dropcursor"; import Dropcursor from "@tiptap/extension-dropcursor";
import { EditorMenu } from "./editor-menu"; import { EditorMenu } from "./editor-menu";
import { FontSize, TextColor } from "./extensions/marks";
interface EditorProps { interface EditorProps {
content: string; content: string;
onChange?: (content: string) => void; onChange?: (content: string) => void;
className?: string; className?: string;
setTitle?: React.Dispatch<React.SetStateAction<string>>; onTitleChange?: (t: string) => void;
} }
export function LocalEditor({ export function LocalEditor({
content, content,
onChange, onChange,
className, className,
setTitle, onTitleChange: setTitle,
}: EditorProps) { }: EditorProps) {
const getTitle = (editor: Editor) => { const getTitle = (editor: Editor) => {
const firstNode = editor.state.doc.firstChild; const firstNode = editor.state.doc.firstChild;
@@ -59,6 +60,8 @@ export function LocalEditor({
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
FontSize,
TextColor,
Underline, Underline,
Link, Link,
Youtube, Youtube,
@@ -87,7 +90,7 @@ export function LocalEditor({
setTitle?.(title ?? ""); setTitle?.(title ?? "");
}, },
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
localStorage.setItem("blog_draft", editor.getHTML()); onChange?.(editor.getHTML());
const title = getTitle(editor); const title = getTitle(editor);
setTitle?.(title ?? ""); setTitle?.(title ?? "");
}, },

View File

@@ -30,30 +30,35 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import { useForm } from "react-hook-form";
SubmitErrorHandler,
SubmitHandler,
useForm,
UseFormReturn,
} from "react-hook-form";
import { CalendarEventExternal } from "@schedule-x/calendar";
import ICalendarEvent from "@/interfaces/ICalendarEvent";
import { useEffect } from "react"; import { useEffect } from "react";
import ICalendarEvent from "@/interfaces/ICalendarEvent";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
import { Textarea } from "@/components/ui/textarea";
import { useApi } from "@/hooks/use-api";
import { Location } from "@/types/types";
import openNavigationApp from "@/lib/openNavigationMap";
import formatLocation from "@/lib/formatLocation";
export const eventFormSchema = z.object({ export const eventFormSchema = z.object({
title: z.string().min(1, "Titre requis"), title: z.string().min(1, "Titre requis"),
startDate: z.date({ startDate: z.date({ required_error: "Date de début requise" }),
required_error: "Date de début requise",
}),
startTime: z.string(), startTime: z.string(),
endDate: z.date({ endDate: z.date({ required_error: "Date finale requise" }),
required_error: "Date finale requise",
}),
endTime: z.string(), endTime: z.string(),
fullDay: z.boolean().default(false), fullDay: z.boolean().default(false),
frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]), frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]),
frequencyEndDate: z.date().optional(), frequencyEndDate: z.date().optional(),
isVisible: z.boolean().default(true), isVisible: z.boolean().default(true),
description: z.string().optional(),
location: z.string().optional(), // Store as a formatted string
}); });
export type EventFormValues = z.infer<typeof eventFormSchema>; export type EventFormValues = z.infer<typeof eventFormSchema>;
@@ -65,37 +70,38 @@ const frequencies = [
{ label: "Mensuel", value: "mensuel" }, { label: "Mensuel", value: "mensuel" },
]; ];
const isCalendarEventExternal = (
event: CalendarEventExternal | Omit<CalendarEventExternal, "id">,
): event is CalendarEventExternal => {
return (event as CalendarEventExternal).id !== undefined;
};
export const EventForm: React.FC<{ export const EventForm: React.FC<{
event: ICalendarEvent | Omit<ICalendarEvent, "id">; event: ICalendarEvent | Omit<ICalendarEvent, "id">;
setForm: React.Dispatch< setForm: React.Dispatch<
React.SetStateAction<UseFormReturn<EventFormValues> | undefined> React.SetStateAction<
ReturnType<typeof useForm<EventFormValues>> | undefined
>
>; >;
}> = ({ event, setForm }) => { }> = ({ event, setForm }) => {
const locations = useApi<Location[]>("/locations/all");
const _start = new Date(event.start ?? Date.now()); const _start = new Date(event.start ?? Date.now());
const _end = new Date(event.end ?? Date.now()); const _end = new Date(event.end ?? Date.now());
const form = useForm<EventFormValues>({ const form = useForm<EventFormValues>({
resolver: zodResolver(eventFormSchema), resolver: zodResolver(eventFormSchema),
defaultValues: { defaultValues: {
title: event.title ? event.title : "", title: event.title || "",
startDate: _start, // event.start), startDate: _start,
startTime: format(_start, "HH:mm"), startTime: format(_start, "HH:mm"),
endDate: _end, // event.end), endDate: _end,
endTime: format(_end, "HH:mm"), endTime: format(_end, "HH:mm"),
fullDay: event.fullday, fullDay: event.fullday ?? false,
frequency: "unique", frequency: "unique",
isVisible: event.isVisible, isVisible: event.isVisible ?? true,
location: event.location,
description: event.description,
}, },
}); });
useEffect(() => { useEffect(() => {
setForm(form); setForm(form);
}, []); }, [form, setForm]);
const frequency = form.watch("frequency"); const frequency = form.watch("frequency");
@@ -120,53 +126,54 @@ export const EventForm: React.FC<{
/> />
<div className="grid grid-cols-[1fr,auto,1fr] items-end gap-2"> <div className="grid grid-cols-[1fr,auto,1fr] items-end gap-2">
<FormField {/* Start Date */}
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Début</FormLabel> <FormLabel>Début</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full pl-3 text-left font-normal", "w-full pl-3 text-left font-normal",
!field.value && !form.getValues("startDate") &&
"text-muted-foreground", "text-muted-foreground",
)} )}
> >
{field.value ? ( {form.getValues("startDate") ? (
format( format(
field.value, form.getValues("startDate"),
"dd/MM/yyyy", "dd/MM/yyyy",
) )
) : ( ) : (
<span> <span>Choisis une date</span>
Choisis une date
</span>
)} )}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-0" className="w-auto p-0"
align="start" align="start"
> >
<div style={{ pointerEvents: "auto" }}>
<Calendar <Calendar
mode="single" mode="single"
selected={field.value} selected={form.getValues("startDate")}
onSelect={field.onChange} onSelect={(date) => {
if (date) {
form.setValue(
"startDate",
date,
{ shouldValidate: true },
);
}
}}
initialFocus initialFocus
/> />
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
@@ -187,53 +194,52 @@ export const EventForm: React.FC<{
<span className="invisible">Until</span> <span className="invisible">Until</span>
<FormField {/* End Date */}
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Fin</FormLabel> <FormLabel>Fin</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full pl-3 text-left font-normal", "w-full pl-3 text-left font-normal",
!field.value && !form.getValues("endDate") &&
"text-muted-foreground", "text-muted-foreground",
)} )}
> >
{field.value ? ( {form.getValues("endDate") ? (
format( format(
field.value, form.getValues("endDate"),
"dd/MM/yyyy", "dd/MM/yyyy",
) )
) : ( ) : (
<span> <span>Choisis une date</span>
Choisis une date
</span>
)} )}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-0" className="w-auto p-0"
align="start" align="start"
> >
<div style={{ pointerEvents: "auto" }}>
<Calendar <Calendar
mode="single" mode="single"
selected={field.value} selected={form.getValues("endDate")}
onSelect={field.onChange} onSelect={(date) => {
if (date) {
form.setValue("endDate", date, {
shouldValidate: true,
});
}
}}
initialFocus initialFocus
/> />
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
@@ -283,7 +289,7 @@ export const EventForm: React.FC<{
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selectionner Fréquence" /> <SelectValue placeholder="Sélectionner Fréquence" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@@ -303,54 +309,62 @@ export const EventForm: React.FC<{
/> />
{frequency !== "unique" && ( {frequency !== "unique" && (
<FormField
control={form.control}
name="frequencyEndDate"
render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel>Jusqu'au</FormLabel> <FormLabel>Jusqu'au</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl>
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full pl-3 text-left font-normal", "w-full pl-3 text-left font-normal",
!field.value && !form.getValues(
"text-muted-foreground", "frequencyEndDate",
) && "text-muted-foreground",
)} )}
> >
{field.value ? ( {form.getValues("frequencyEndDate") ? (
format( format(
field.value, form.getValues(
"frequencyEndDate",
)!,
"dd/MM/yyyy", "dd/MM/yyyy",
) )
) : ( ) : (
<span> <span>Choisis une date</span>
Choisis une date
</span>
)} )}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button> </Button>
</FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-0" className="w-auto p-0"
align="start" align="start"
> >
<div style={{ pointerEvents: "auto" }}>
<Calendar <Calendar
mode="single" mode="single"
selected={field.value} selected={form.getValues(
onSelect={field.onChange} "frequencyEndDate",
)}
onSelect={(date) => {
if (date) {
form.setValue(
"frequencyEndDate",
date,
{
shouldValidate:
true,
},
);
}
}}
initialFocus initialFocus
/> />
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/>
)}
</div> </div>
<FormField <FormField
@@ -359,7 +373,7 @@ export const EventForm: React.FC<{
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel className="align-sub"> <FormLabel className="align-sub">
Evènement visible ? Évènement visible ?
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Checkbox <Checkbox
@@ -372,6 +386,97 @@ export const EventForm: React.FC<{
</FormItem> </FormItem>
)} )}
/> />
{/* Updated Location Field with Command */}
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Lieu</FormLabel>
<FormControl>
<Command className="rounded-lg border shadow-md">
<CommandInput
placeholder="Rechercher un lieu..."
value={field.value || ""}
onValueChange={(value) =>
field.onChange(value)
}
/>
<CommandList>
{locations.isLoading && (
<CommandEmpty>
Chargement...
</CommandEmpty>
)}
{!locations.isLoading &&
!locations.data?.length && (
<CommandEmpty>
Aucun lieu trouvé.
</CommandEmpty>
)}
{!locations.isLoading &&
locations.data?.length && (
<CommandGroup heading="Suggestions">
{locations.data
.filter((location) =>
formatLocation(
location,
)
.toLowerCase()
.includes(
(
field.value ||
""
).toLowerCase(),
),
)
.map((location) => (
<CommandItem
key={
location.id
}
onSelect={() => {
const formatted =
formatLocation(
location,
);
field.onChange(
formatted,
);
}}
>
{formatLocation(
location,
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Ajouter une description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
); );

View File

@@ -1,37 +1,17 @@
import { import { SITE_NAME } from "@/lib/constants";
FaFacebook, import { FaFacebook, FaYoutube } from "react-icons/fa";
FaInstagram, import Logo from "./logo";
FaLinkedin,
FaTwitter,
FaYoutube,
} from "react-icons/fa";
const sections = [ const sections = [
{
title: "Product",
links: [
{ name: "Overview", href: "#" },
{ name: "Pricing", href: "#" },
{ name: "Marketplace", href: "#" },
{ name: "Features", href: "#" },
],
},
{
title: "Company",
links: [
{ name: "About", href: "#" },
{ name: "Team", href: "#" },
{ name: "Blog", href: "#" },
{ name: "Careers", href: "#" },
],
},
{ {
title: "Resources", title: "Resources",
links: [ links: [
{ name: "Help", href: "#" }, { name: "Accueil", href: "/" },
{ name: "Sales", href: "#" }, { name: "Planning", href: "/planning" },
{ name: "Advertise", href: "#" }, { name: "À propos", href: "/about" },
{ name: "Privacy", href: "#" }, { name: "Galerie", href: "/gallery" },
{ name: "Blog", href: "/blogs" },
{ name: "Contact", href: "/contact" },
], ],
}, },
]; ];
@@ -45,20 +25,12 @@ const Footer = () => {
<div className="flex w-full max-w-96 shrink flex-col items-center justify-between gap-6 lg:items-start"> <div className="flex w-full max-w-96 shrink flex-col items-center justify-between gap-6 lg:items-start">
<div> <div>
<span className="flex items-center justify-center gap-4 lg:justify-start"> <span className="flex items-center justify-center gap-4 lg:justify-start">
<img <Logo className="h-11 w-11" />
src="https://shadcnblocks.com/images/block/block-1.svg"
alt="logo"
className="h-11"
/>
<p className="text-3xl font-semibold"> <p className="text-3xl font-semibold">
Latosa-Escrima {SITE_NAME}
</p> </p>
</span> </span>
<p className="mt-6 text-sm text-muted-foreground"> <p className="mt-6 text-sm text-muted-foreground"></p>
A collection of 100+ responsive HTML
templates for your startup business or side
project.
</p>
</div> </div>
<ul className="flex items-center space-x-6 text-muted-foreground"> <ul className="flex items-center space-x-6 text-muted-foreground">
<li className="font-medium hover:text-primary"> <li className="font-medium hover:text-primary">
@@ -73,7 +45,9 @@ const Footer = () => {
</li> </li>
</ul> </ul>
</div> </div>
<div className="grid grid-cols-3 gap-6 lg:gap-20"> <div
className={`grid grid-cols-[${sections.length}] gap-6 lg:gap-20`}
>
{sections.map((section, sectionIdx) => ( {sections.map((section, sectionIdx) => (
<div key={sectionIdx}> <div key={sectionIdx}>
<h3 className="mb-6 font-bold"> <h3 className="mb-6 font-bold">
@@ -97,8 +71,8 @@ const Footer = () => {
</div> </div>
<div className="mt-20 flex flex-col justify-between gap-4 border-t pt-8 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left"> <div className="mt-20 flex flex-col justify-between gap-4 border-t pt-8 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left">
<p> <p>
© {new Date(Date.now()).getFullYear()}{" "} © {new Date(Date.now()).getFullYear()} {SITE_NAME}.
Latosa-Escrima. Tous droits réservés. Tous droits réservés.
</p> </p>
<ul className="flex justify-center gap-4 lg:justify-start"> <ul className="flex justify-center gap-4 lg:justify-start">
<li className="hover:text-primary"> <li className="hover:text-primary">

View File

@@ -2,8 +2,9 @@ import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { API_URL } from "@/lib/constants"; import { API_URL, SITE_NAME } from "@/lib/constants";
import Image from "next/image"; import Image from "next/image";
import Logo from "./logo";
const Hero: React.FC<{ background: string }> = ({ background }) => { const Hero: React.FC<{ background: string }> = ({ background }) => {
return ( return (
@@ -22,17 +23,13 @@ const Hero: React.FC<{ background: string }> = ({ background }) => {
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-transparent bg-opacity-30 backdrop-blur-sm"></div> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-transparent bg-opacity-30 backdrop-blur-sm"></div>
<div className="mx-auto flex max-w-5xl flex-col items-center"> <div className="mx-auto flex max-w-5xl flex-col items-center">
<div className="z-10 flex flex-col items-center gap-6 text-center"> <div className="z-10 flex flex-col items-center gap-6 text-center">
<img <Logo className="min-w-16 min-h-16 max-h-44 max-w-44 text-white" />
src="https://shadcnblocks.com/images/block/block-1.svg"
alt="logo"
className="h-16"
/>
<div> <div>
<h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl font-times"> <h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl font-times">
Trouvez votre <em>équilibre</em> avec Trouvez votre <em>équilibre</em> avec
<br /> <br />
<span className="font-extrabold text-3xl lg:text-6xl"> <span className="font-extrabold text-3xl lg:text-6xl">
Latosa Escrima {SITE_NAME}
</span> </span>
</h1> </h1>
<p className="text-muted-foreground lg:text-xl"> <p className="text-muted-foreground lg:text-xl">
@@ -44,8 +41,13 @@ const Hero: React.FC<{ background: string }> = ({ background }) => {
<Link href="/contact">Nous contacter</Link> <Link href="/contact">Nous contacter</Link>
</Button> </Button>
<Button variant="outline"> <Button variant="outline">
<Link
href="/about"
className="flex items-center"
>
À propos À propos
<ExternalLink className="ml-2 h-4" /> <ExternalLink className="ml-2 h-4" />
</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,197 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Trash2, MilestoneIcon, Clock } from "lucide-react";
import { Location } from "@/types/types";
import getOsmEmbedUrl from "@/lib/osmEmbed";
import LocationDialog from "./location-dialog";
import openNavigationApp from "@/lib/openNavigationMap";
import formatLocation from "@/lib/formatLocation";
interface LocationCardProps {
location: Location;
onUpdate?: (location: Location) => void;
onDelete?: (location: Location) => void;
canUpdate?: boolean;
canDelete?: boolean;
}
export const LocationCard: React.FC<LocationCardProps> = ({
location,
onUpdate,
onDelete,
canDelete = false,
canUpdate = false,
}) => {
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const handleDelete = () => {
onDelete?.(location);
setIsDialogOpen(false);
};
return (
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row justify-between align-middle">
<div>
<CardTitle>{location.street}</CardTitle>
<CardDescription>
{location.city}, {location.postalCode}
</CardDescription>
</div>
<Button
onClick={() => openNavigationApp(formatLocation(location))}
className="m-0"
>
<MilestoneIcon />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* OSM Embed Map */}
{location.latitude && location.longitude && (
<div className="h-[200px] w-full rounded-lg border overflow-hidden">
<iframe
width="100%"
height="100%"
src={getOsmEmbedUrl(
location.latitude,
location.longitude,
)}
title="OpenStreetMap Preview"
loading="lazy"
/>
</div>
)}
<div className="flex gap-2 overflow-y-auto">
{location.events?.slice(0, 3).map((event) => (
<div
key={event.id}
className="border rounded-lg p-3 text-sm shadow-sm hover:shadow-md transition-shadow"
>
{/* Event Title */}
<p className="font-semibold truncate">
{event.title || `Event ${event.id}`}
</p>
{/* Event Start Date/Time */}
{event.start && (
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400 text-xs">
<Clock className="h-3 w-3" />
<span>
{new Date(event.start).toLocaleString(
"fr-FR",
{
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
)}
</span>
{event.end && (
<>
<span></span>
<span>
{new Date(
event.end,
).toLocaleString("fr-FR", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</span>
</>
)}
</div>
)}
{/* Full-day Badge */}
{event.fullday && (
<span className="inline-block mt-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full px-2 py-0.5">
Full Day
</span>
)}
</div>
))}
</div>
{/* Action Buttons */}
<div className="flex justify-between">
<LocationDialog
location={location}
onUpdate={canUpdate ? onUpdate : undefined}
onDelete={canDelete ? onDelete : undefined}
/>
{canDelete && (
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Supprimer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Confirmation de suppression
</DialogTitle>
<DialogDescription>
Cela supprimera définitivement cette
adresse:{" "}
<span className="font-semibold">
{location.street}
</span>
, {location.city}, {location.postalCode}
?
<br />
Êtes-vous sûr de vouloir continuer ?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Annuler
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
Supprimer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,79 @@
import { DialogProps } from "@radix-ui/react-dialog";
import { LocationForm, LocationFormValues } from "./location-form";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Location } from "@/types/types";
const LocationDialog: React.FC<
{
onDelete?: (location: Location) => void;
onUpdate?: (formValues: LocationFormValues) => void;
onAdd?: (formValues: LocationFormValues) => void;
location?: Location;
} & DialogProps
> = ({ open, onOpenChange, onDelete, onUpdate, onAdd, location }) => {
const [form, setForm] = useState<UseFormReturn<LocationFormValues>>();
const submitForm = (event: "add" | "update") => {
const callback = event === "add" ? onAdd : onUpdate;
if (callback) form?.handleSubmit(callback)();
};
if (!(onAdd || onUpdate)) return;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button>
{!location
? "Ajouter une nouvelle adresse"
: "Modifier l'adresse"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{!location ? "Nouvelle adresse" : "Modifier"}
</DialogTitle>
</DialogHeader>
<LocationForm location={location} setForm={setForm} />
<DialogFooter className="flex flex-row justify-end">
{onUpdate && (
<Button
variant="outline"
onClick={() => submitForm("update")}
type="submit"
>
Actualiser
</Button>
)}
{onDelete && (
<Button
variant="destructive"
onClick={() => location && onDelete(location)}
type="submit"
>
Supprimer
</Button>
)}
{onAdd && !onUpdate && !onDelete && (
<Button onClick={() => submitForm("add")} type="submit">
Ajouter
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default LocationDialog;

View File

@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import useDebounce from "@/hooks/use-debounce";
import { OpenStreetMapLocation } from "@/types/types";
import getOsmEmbedUrl from "@/lib/osmEmbed";
import { Location } from "@/types/types";
// Zod schema for validation
const locationFormSchema = z.object({
id: z.number().optional(),
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
postalCode: z.string().min(1, "Postal code is required"),
latitude: z.number().optional(),
longitude: z.number().optional(),
});
export type LocationFormValues = z.infer<typeof locationFormSchema>;
export const LocationForm: React.FC<{
location?: Location;
setForm: React.Dispatch<
React.SetStateAction<
ReturnType<typeof useForm<LocationFormValues>> | undefined
>
>;
}> = ({ location, setForm }) => {
const [osmQuery, setOsmQuery] = React.useState("");
const [suggestions, setSuggestions] = React.useState<
OpenStreetMapLocation[]
>([]);
const [isLoading, setIsLoading] = React.useState(false);
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationFormSchema),
defaultValues: location || {
street: "",
city: "",
postalCode: "",
},
});
React.useEffect(() => {
setForm(form);
}, [form, setForm]);
// Fetch suggestions from OpenStreetMap Nominatim API
const fetchSuggestions = async (query: string) => {
if (!query || query.length < 3) {
setSuggestions([]);
return;
}
setIsLoading(true);
try {
const url = new URL("https://nominatim.openstreetmap.org/search");
url.searchParams.append("q", query);
url.searchParams.append("format", "json");
url.searchParams.append("addressdetails", "1");
url.searchParams.append("limit", "5");
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: OpenStreetMapLocation[] = await response.json();
setSuggestions(data);
} catch (error) {
console.error("Error fetching OSM suggestions:", error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const debouncedFetchSuggestions = useDebounce(fetchSuggestions, 300);
// Handle form submission
const onSubmit = (data: LocationFormValues) => {
console.log("New Location:", data);
// Here you can send the data to your backend (e.g., via API call)
};
const longitude = form.watch("longitude");
const latitude = form.watch("latitude");
// Helper function to construct street from OSM address
const getStreetFromAddress = (
address: OpenStreetMapLocation["address"],
): string => {
const houseNumber = address.house_number || "";
const road = address.road || "";
return (
[houseNumber, road].filter(Boolean).join(" ").trim() ||
"Unknown Street"
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Autocomplete Input */}
<Command className="rounded-lg border shadow-md h-40">
<CommandInput
placeholder="Écrivez pour trouver une adresse..."
value={osmQuery}
onValueChange={(value) => {
setOsmQuery(value);
debouncedFetchSuggestions(value);
}}
/>
<CommandList>
{isLoading && <CommandEmpty>Loading...</CommandEmpty>}
{!isLoading &&
suggestions.length === 0 &&
osmQuery.length < 1 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{!isLoading && suggestions.length > 0 && (
<CommandGroup heading="Suggestions">
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion.place_id}
onSelect={() => {
const address = suggestion.address;
form.setValue(
"street",
getStreetFromAddress(address),
);
form.setValue(
"city",
address.city ||
address.town ||
address.village ||
"",
);
form.setValue(
"postalCode",
address.postcode || "",
);
form.setValue(
"latitude",
parseFloat(suggestion.lat),
);
form.setValue(
"longitude",
parseFloat(suggestion.lon),
);
}}
>
{suggestion.display_name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
{/* Editable Fields */}
<FormField
control={form.control}
name="street"
render={({ field }) => (
<FormItem>
<FormLabel>Rue</FormLabel>
<FormControl>
<Input
placeholder="Entrer une rue"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Ville</FormLabel>
<FormControl>
<Input
placeholder="Entrer une ville"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>Code postal</FormLabel>
<FormControl>
<Input
placeholder="Entrer un code postal"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* OSM Embed Map Preview */}
{latitude && longitude && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-2">
Prévisualisation
</h3>
<div className="h-[300px] w-full rounded-lg border overflow-hidden">
<iframe
width="100%"
height="100%"
src={getOsmEmbedUrl(latitude, longitude)}
title="OpenStreetMap Preview"
/>
</div>
</div>
)}
</form>
</Form>
);
};

View File

@@ -1,12 +1,17 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useState } from "react"; import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import useLogin from "@/hooks/use-login"; import useLogin from "@/hooks/use-login";
import { Loader2 } from "lucide-react"; import {
ActionButton,
ActionButtonDefault,
ActionButtonError,
ActionButtonLoading,
ActionButtonSuccess,
} from "./action-button";
export function LoginForm({ export function LoginForm({
className, className,
@@ -70,7 +75,7 @@ export function LoginForm({
href="#" href="#"
className="ml-auto text-sm underline-offset-4 hover:underline" className="ml-auto text-sm underline-offset-4 hover:underline"
> >
Forgot your password? Mot de passe oublier
</a> </a>
</div> </div>
<Input <Input
@@ -82,35 +87,23 @@ export function LoginForm({
required required
/> />
</div> </div>
<Button <ActionButton
disabled={loading} isLoading={loading}
isSuccess={isSuccess}
type="submit" type="submit"
className="w-full transition-all ease-in-out"
> >
{loading && <Loader2 className="animate-spin" />} <ActionButtonDefault>Se connecter</ActionButtonDefault>
Se connecter <ActionButtonLoading />
</Button> <ActionButtonError />
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"> <ActionButtonSuccess />
<span className="relative z-10 bg-background px-2 text-muted-foreground"> </ActionButton>
Ou connectez-vous avec
</span>
</div> </div>
<Button variant="outline" className="w-full"> {/*<div className="text-center text-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Login with GitHub
</Button>
</div>
<div className="text-center text-sm">
Pas de compte ?{" "} Pas de compte ?{" "}
<a href="#" className="underline underline-offset-4"> <a href="#" className="underline underline-offset-4">
Créer un compte Créer un compte
</a> </a>
</div> </div>*/}
</form> </form>
); );
} }

1029
frontend/components/logo.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
@@ -28,6 +28,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "./ui/select"; } from "./ui/select";
import { useApi } from "@/hooks/use-api";
import { Role, User } from "@/types/types";
import { Badge } from "./ui/badge";
import { Building, X } from "lucide-react";
// Define the Role schema based on assumed Role type
const roleSchema = z.object({
id: z.string().min(1, "Role ID is required"),
name: z.string().min(1, "Role name is required"),
});
const memberSchema = z.object({ const memberSchema = z.object({
userId: z.string().optional(), userId: z.string().optional(),
@@ -39,7 +49,10 @@ const memberSchema = z.object({
.min(6, "Le mot de passe doit avoir au moins 6 caractères.") .min(6, "Le mot de passe doit avoir au moins 6 caractères.")
.optional(), .optional(),
phone: z.string().regex(/^\d{10}$/, "Le numéro doit avoir 10 chiffres."), phone: z.string().regex(/^\d{10}$/, "Le numéro doit avoir 10 chiffres."),
role: z.string().min(1, "Le rôle est requis."), roles: z
.array(roleSchema)
.min(1, "At least one role is required")
.optional(),
}); });
const updateMemberSchema = memberSchema.partial(); const updateMemberSchema = memberSchema.partial();
@@ -60,6 +73,8 @@ export default function MemberDialog({
onSave, onSave,
}: MemberDialogProps) { }: MemberDialogProps) {
const schema = member?.userId ? updateMemberSchema : memberSchema; const schema = member?.userId ? updateMemberSchema : memberSchema;
const { data: availableRoles = [] } = useApi<Role[]>("/roles", {}, true); // Fetch roles
const form = useForm<Member>({ const form = useForm<Member>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: member?.userId defaultValues: member?.userId
@@ -71,7 +86,7 @@ export default function MemberDialog({
email: "", email: "",
password: "", password: "",
phone: "", phone: "",
role: "", roles: [], // Default to empty array
}, },
}); });
@@ -86,11 +101,38 @@ export default function MemberDialog({
email: "", email: "",
password: "", password: "",
phone: "", phone: "",
role: "", roles: [],
}); });
} }
}, [member, form]); }, [member, form]);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const addRole = () => {
if (selectedRole) {
const currentRoles = form.getValues("roles");
if (!currentRoles?.some((role) => role.id === selectedRole.id)) {
form.setValue(
"roles",
[...(currentRoles || []), selectedRole],
{
shouldValidate: true,
},
);
}
setSelectedRole(null); // Reset selection
}
};
const removeRole = (roleToRemove: Role) => {
const currentRoles = form.getValues("roles");
form.setValue(
"roles",
currentRoles?.filter((role) => role.id !== roleToRemove.id),
{ shouldValidate: true },
);
};
const onSubmit = (data: Member) => { const onSubmit = (data: Member) => {
onSave(data); onSave(data);
onClose(); onClose();
@@ -118,13 +160,13 @@ export default function MemberDialog({
name="firstname" name="firstname"
render={({ field }) => ( render={({ field }) => (
<FormItem className="grid gap-2"> <FormItem className="grid gap-2">
<FormLabel htmlFor="name"> <FormLabel htmlFor="firstname">
Prénom Prénom
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
id="firstname" id="firstname"
placeholder="John Doe" placeholder="John"
type="text" type="text"
autoComplete="firstname" autoComplete="firstname"
{...field} {...field}
@@ -134,8 +176,8 @@ export default function MemberDialog({
</FormItem> </FormItem>
)} )}
/> />
<div className="grid gap-4">
{/* Firstname Field */} {/* Lastname Field */}
<FormField <FormField
control={form.control} control={form.control}
name="lastname" name="lastname"
@@ -147,7 +189,7 @@ export default function MemberDialog({
<FormControl> <FormControl>
<Input <Input
id="lastname" id="lastname"
placeholder="John Doe" placeholder="Doe"
type="text" type="text"
autoComplete="lastname" autoComplete="lastname"
{...field} {...field}
@@ -180,7 +222,8 @@ export default function MemberDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* Password Field */}
{/* Password Field (only for new members) */}
{!member?.userId && ( {!member?.userId && (
<FormField <FormField
control={form.control} control={form.control}
@@ -205,44 +248,6 @@ export default function MemberDialog({
/> />
)} )}
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel htmlFor="role">
Role
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(
value: string,
) => field.onChange(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Séléctionner un rôle" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
Role
</SelectLabel>
<SelectItem value="admin">
Administrateur
</SelectItem>
<SelectItem value="user">
Utilisateur
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Phone Field */} {/* Phone Field */}
<FormField <FormField
control={form.control} control={form.control}
@@ -265,7 +270,97 @@ export default function MemberDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* Roles Field */}
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Roles</FormLabel>
<div className="flex flex-wrap gap-2">
{field.value?.map((role) => (
<Badge
key={role.id}
variant="secondary"
className="text-sm py-1 px-2"
>
{role.name}
<button
onClick={() =>
removeRole(role)
}
className="ml-2 text-gray-500 hover:text-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div> </div>
<div className="flex space-x-2 mt-2">
<Select
value={
selectedRole
? selectedRole.name
: ""
}
onValueChange={(value) => {
const role =
availableRoles.find(
(r) =>
r.name ===
value,
);
if (role)
setSelectedRole(role);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
Roles
</SelectLabel>
{availableRoles
.filter(
(role) =>
!field.value?.some(
(r) =>
r.id ===
role.id,
),
)
.map((role) => (
<SelectItem
key={
role.id
}
value={
role.name
}
>
{role.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type="button"
disabled={!selectedRole}
onClick={addRole}
className="flex items-center"
>
<Building className="mr-2 h-4 w-4" />
Ajouter le rôle
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit"> <Button type="submit">

View File

@@ -10,21 +10,19 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import MemberDialog, { Member } from "./member-dialog"; import MemberDialog, { Member } from "./member-dialog";
import { useApi } from "@/hooks/use-api"; import { useApi } from "@/hooks/use-api";
import request from "@/lib/request"; import request from "@/lib/request";
import { import { Loader2, MoreHorizontal, UserRoundPlus } from "lucide-react";
CircleX,
Loader2,
Trash2,
UserRoundPen,
UserRoundPlus,
} from "lucide-react";
import Link from "next/link";
import IUser from "@/interfaces/IUser"; import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions"; import hasPermissions from "@/lib/hasPermissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
export default function MembersTable({ user }: { user: IUser }) { export default function MembersTable({ user }: { user: IUser }) {
const { const {
@@ -34,23 +32,12 @@ export default function MembersTable({ user }: { user: IUser }) {
success, success,
isLoading, isLoading,
} = useApi<Member[]>("/users", undefined, true, false); } = useApi<Member[]>("/users", undefined, true, false);
const [selectMode, setSelectMode] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [currentMember, setCurrentMember] = useState<Member | null>(null); const [currentMember, setCurrentMember] = useState<Member | null>(null);
const toggleSelectMode = () => { const { users } = hasPermissions(user.roles, {
setSelectMode(!selectMode); users: ["delete", "update", "insert"],
setSelectedMembers([]); } as const);
};
const toggleMemberSelection = (userId: string) => {
setSelectedMembers((prev) =>
prev.includes(userId)
? prev.filter((id) => id !== userId)
: [...prev, userId],
);
};
const handleOpenDialog = (member: Member | null) => { const handleOpenDialog = (member: Member | null) => {
setCurrentMember(member); setCurrentMember(member);
@@ -59,10 +46,12 @@ export default function MembersTable({ user }: { user: IUser }) {
const handleSaveMember = async (savedMember: Member) => { const handleSaveMember = async (savedMember: Member) => {
if (savedMember.userId) { if (savedMember.userId) {
const dif = DiffUtils.getDifferences(currentMember!, savedMember);
if (DiffUtils.isEmpty(dif)) return;
const res = await request<unknown>( const res = await request<unknown>(
`/users/${savedMember.userId}/update`, `/users/${savedMember.userId}/update`,
{ {
body: savedMember, body: dif,
requiresAuth: true, requiresAuth: true,
method: "PATCH", method: "PATCH",
}, },
@@ -96,105 +85,83 @@ export default function MembersTable({ user }: { user: IUser }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between"> <div className="flex justify-between">
<Button onClick={toggleSelectMode}> {users.insert && (
{selectMode ? <CircleX /> : "Selectionner"}
</Button>
{hasPermissions(user.roles, { users: ["insert"] }) && (
<Button onClick={() => handleOpenDialog(null)}> <Button onClick={() => handleOpenDialog(null)}>
<UserRoundPlus /> <UserRoundPlus />
</Button> </Button>
)} )}
</div> </div>
<div className="relative"> <div className="relative">
{isLoading && <Loader2 className="animate-spin" />}
<ScrollArea className="h-full rounded-md border"> <ScrollArea className="h-full rounded-md border">
<Table> <Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm"> <TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow> <TableRow>
{selectMode && (
<TableHead className="w-[50px]">
Sélectionner
</TableHead>
)}
<TableHead>Prénom</TableHead> <TableHead>Prénom</TableHead>
<TableHead>Nom</TableHead> <TableHead>Nom</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Téléphone</TableHead> <TableHead>Téléphone</TableHead>
<TableHead>Rôle</TableHead> <TableHead>Rôles</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
Actions Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <Loader2 className="animate-spin" />}
{members && {members &&
members.map((member) => ( members.map((_member) => (
<TableRow key={member.userId}> <TableRow key={_member.userId}>
{selectMode && (
<TableCell> <TableCell>
<Checkbox {_member.firstname}
checked={selectedMembers.includes( </TableCell>
member.userId!, <TableCell>
)} {_member.lastname}
onCheckedChange={() => </TableCell>
toggleMemberSelection( <TableCell>{_member.email}</TableCell>
member.userId!, <TableCell>{_member.phone}</TableCell>
<TableCell>
{_member.roles
?.map((r) => r.name)
.join(", ")}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Ouvrir le menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{users.update && (
<DropdownMenuItem
onClick={() =>
handleOpenDialog(
_member,
) )
} }
/> >
</TableCell> Mettre à jour
</DropdownMenuItem>
)} )}
<TableCell> {users.delete && (
<Link <DropdownMenuItem
href={`/dashboard/members/${member.userId}`}
>
<span className="underline">
{member.firstname}
</span>
</Link>
</TableCell>
<TableCell>
<Link
href={`/dashboard/members/${member.userId}`}
>
<span className="underline">
{member.lastname}
</span>
</Link>
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>{member.phone}</TableCell>
<TableCell>{member.role}</TableCell>
<TableCell className="text-right">
{hasPermissions(user.roles, {
users: ["update"],
}) && (
<Button
variant="outline"
size="sm"
className="mr-2"
onClick={() =>
handleOpenDialog(member)
}
>
<UserRoundPen />
</Button>
)}
{hasPermissions(user.roles, {
users: ["delete"],
}) && (
<Button
variant="destructive"
size="sm"
onClick={() => onClick={() =>
handleDelete( handleDelete(
member.userId!, _member.userId!,
) )
} }
> >
<Trash2 /> Supprimer
</Button> </DropdownMenuItem>
)} )}
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -17,6 +17,8 @@ import { deleteCookie, getCookie } from "cookies-next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ThemeSwitcher } from "./theme-switcher"; import { ThemeSwitcher } from "./theme-switcher";
import "./nav-bar.css"; import "./nav-bar.css";
import Logo from "./logo";
import { SITE_NAME } from "@/lib/constants";
const Href: React.FC<React.PropsWithChildren<{ href: string }>> = ({ const Href: React.FC<React.PropsWithChildren<{ href: string }>> = ({
href, href,
@@ -50,20 +52,16 @@ const Navbar = () => {
<nav className="hidden justify-between lg:flex"> <nav className="hidden justify-between lg:flex">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <Logo className="w-8 h-8" />
src="https://shadcnblocks.com/images/block/block-1.svg"
className="w-8"
alt="logo"
/>
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Latosa-Escrima {SITE_NAME}
</span> </span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Href href="/">Accueil</Href> <Href href="/">Accueil</Href>
<Href href="/planning">Planning</Href> <Href href="/planning">Planning</Href>
<Href href="/about">À propos</Href> <Href href="/about">À propos</Href>
<Href href="/gallery">Gallerie</Href> <Href href="/gallery">Galerie</Href>
<Href href="/blogs">Blog</Href> <Href href="/blogs">Blog</Href>
</div> </div>
</div> </div>
@@ -96,7 +94,7 @@ const Navbar = () => {
Se connecter Se connecter
</Link> </Link>
)} )}
{cookie ? ( {cookie && (
<Button <Button
onClick={() => { onClick={() => {
deleteCookie("auth_token"); deleteCookie("auth_token");
@@ -105,21 +103,15 @@ const Navbar = () => {
> >
Se déconnecter Se déconnecter
</Button> </Button>
) : (
<Button>Créer un compte</Button>
)} )}
</div> </div>
</nav> </nav>
<div className="block lg:hidden"> <div className="block lg:hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <Logo className="w-8 h-8" />
src="https://shadcnblocks.com/images/block/block-1.svg"
className="w-8"
alt="logo"
/>
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Latosa-Escrima {SITE_NAME}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -134,13 +126,9 @@ const Navbar = () => {
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <Logo className="w-8 h-8" />
src="https://shadcnblocks.com/images/block/block-1.svg"
className="w-8"
alt="logo"
/>
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Latosa-Escrima {SITE_NAME}
</span> </span>
</div> </div>
</SheetTitle> </SheetTitle>
@@ -168,10 +156,10 @@ const Navbar = () => {
href="/gallery" href="/gallery"
className="font-semibold" className="font-semibold"
> >
Gallerie Galerie
</Link> </Link>
<Link <Link
href="/blog" href="/blogs"
className="font-semibold" className="font-semibold"
> >
Blog Blog
@@ -252,7 +240,6 @@ const Navbar = () => {
Se connecter Se connecter
</Link> </Link>
</Button> </Button>
<Button>Sign up</Button>
</div> </div>
</div> </div>
</SheetContent> </SheetContent>

View File

@@ -5,7 +5,7 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@radix-ui/react-dialog"; } from "@/components/ui/dialog";
import Image, { ImageProps } from "next/image"; import Image, { ImageProps } from "next/image";
import React, { useState } from "react"; import React, { useState } from "react";

View File

@@ -8,7 +8,12 @@ import { createEventsServicePlugin } from "@schedule-x/events-service";
import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop";
import { createResizePlugin } from "@schedule-x/resize"; import { createResizePlugin } from "@schedule-x/resize";
import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence"; import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence";
import { createViewDay, createViewWeek } from "@schedule-x/calendar"; import { createEventModalPlugin } from "@schedule-x/event-modal";
import {
createViewDay,
createViewWeek,
PluginBase,
} from "@schedule-x/calendar";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Dialog, DialogProps } from "@radix-ui/react-dialog"; import { Dialog, DialogProps } from "@radix-ui/react-dialog";
@@ -29,6 +34,8 @@ import { UseFormReturn } from "react-hook-form";
import mapFrequencyToRrule from "@/lib/mapFrequencyToRrule"; import mapFrequencyToRrule from "@/lib/mapFrequencyToRrule";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
type Plugins = PluginBase<string>[];
const Planning: React.FC<{ const Planning: React.FC<{
events: ICalendarEvent[]; events: ICalendarEvent[];
mutate?: KeyedMutator<ApiResponse<ICalendarEvent[]>>; mutate?: KeyedMutator<ApiResponse<ICalendarEvent[]>>;
@@ -37,15 +44,16 @@ const Planning: React.FC<{
const { toast } = useToast(); const { toast } = useToast();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const isConnected = getCookie("auth_token"); const isConnected = getCookie("auth_token");
const plugins = const plugins: Plugins = [
isConnected && modifiable
? [
createEventsServicePlugin(), createEventsServicePlugin(),
createDragAndDropPlugin(),
createResizePlugin(),
createEventRecurrencePlugin(), createEventRecurrencePlugin(),
] ];
: []; if (isConnected && modifiable) {
plugins.push(createDragAndDropPlugin());
plugins.push(createResizePlugin());
} else if (isConnected || !isConnected) {
plugins.push(createEventModalPlugin());
}
const [eventSelected, setEventSelected] = useState<ICalendarEvent | null>( const [eventSelected, setEventSelected] = useState<ICalendarEvent | null>(
null, null,
); );
@@ -55,6 +63,7 @@ const Planning: React.FC<{
const handleEventUpdate = async ( const handleEventUpdate = async (
eventSelected: ICalendarEvent | Omit<ICalendarEvent, "id">, eventSelected: ICalendarEvent | Omit<ICalendarEvent, "id">,
willMutate: boolean = false,
) => { ) => {
if (!isConnected || !modifiable) return; if (!isConnected || !modifiable) return;
const event = { const event = {
@@ -75,7 +84,7 @@ const Planning: React.FC<{
description: res.message, description: res.message,
}); });
} else { } else {
// mutate?.(); willMutate && mutate?.();
} }
} catch (e) { } catch (e) {
if (e instanceof Error) if (e instanceof Error)
@@ -171,6 +180,8 @@ const Planning: React.FC<{
fullday: formValues.fullDay, fullday: formValues.fullDay,
rrule: rrule, rrule: rrule,
isVisible: formValues.isVisible, isVisible: formValues.isVisible,
description: formValues.description,
location: formValues.location,
}; };
const res = await request<undefined>(`/events/new`, { const res = await request<undefined>(`/events/new`, {
method: "POST", method: "POST",
@@ -255,8 +266,10 @@ const Planning: React.FC<{
fullday: formValues.fullDay, fullday: formValues.fullDay,
rrule: rrule, rrule: rrule,
isVisible: formValues.isVisible, isVisible: formValues.isVisible,
description: formValues.description,
location: formValues.location,
}; };
await handleEventUpdate(event); await handleEventUpdate(event, true);
setEventSelected(null); setEventSelected(null);
}} }}
/> />

View File

@@ -196,7 +196,7 @@ const PhotoGrid: React.FC<{ onSelect: (photo: Media) => void }> = ({
return ( return (
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Gallerie Photo</h1> <h1 className="text-2xl font-bold mb-4">Galerie Photo</h1>
{selectedPhoto ? ( {selectedPhoto ? (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<Image <Image

View File

@@ -21,6 +21,12 @@ import ShortcodeDialog from "@/components/shortcode-dialogue";
import { useState } from "react"; import { useState } from "react";
import IUser from "@/interfaces/IUser"; import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions"; import hasPermissions from "@/lib/hasPermissions";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@radix-ui/react-hover-card";
import Image from "next/image";
interface ShortcodeTableProps { interface ShortcodeTableProps {
user: IUser; user: IUser;
@@ -37,13 +43,17 @@ export function ShortcodeTable({
onAdd, onAdd,
user, user,
}: ShortcodeTableProps) { }: ShortcodeTableProps) {
const permissions = hasPermissions(user.roles, {
shortcodes: ["insert", "update", "delete"],
} as const);
const [shortcodeSelected, setUpdateDialog] = useState<IShortcode | null>( const [shortcodeSelected, setUpdateDialog] = useState<IShortcode | null>(
null, null,
); );
const [addDialog, setAddDialog] = useState<boolean>(false); const [addDialog, setAddDialog] = useState<boolean>(false);
return ( return (
<div> <div>
{hasPermissions(user.roles, { shortcodes: ["insert"] }) && ( {permissions.shortcodes.insert && (
<div className="mb-4"> <div className="mb-4">
<Button <Button
onClick={() => { onClick={() => {
@@ -81,7 +91,21 @@ export function ShortcodeTable({
{shortcode.value || "N/A"} {shortcode.value || "N/A"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<HoverCard>
<HoverCardTrigger className="underline decoration-dotted cursor-pointer">
{shortcode.media_id || "N/A"} {shortcode.media_id || "N/A"}
</HoverCardTrigger>
{shortcode.media && (
<HoverCardContent>
<Image
src={shortcode.media.url}
alt={shortcode.media.alt}
width={200}
height={200}
/>
</HoverCardContent>
)}
</HoverCard>
</TableCell> </TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
@@ -97,9 +121,7 @@ export function ShortcodeTable({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{hasPermissions(user.roles, { {permissions.shortcodes.update && (
shortcodes: ["update"],
}) && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
setUpdateDialog( setUpdateDialog(
@@ -110,9 +132,7 @@ export function ShortcodeTable({
Mettre à jour Mettre à jour
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{hasPermissions(user.roles, { {permissions.shortcodes.delete && (
shortcodes: ["delete"],
}) && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
onDelete(shortcode.code) onDelete(shortcode.code)

View File

@@ -0,0 +1,141 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn(
"mb-1 font-medium leading-none tracking-tight",
className,
)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,13 +1,13 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react" import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker" import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker> export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ function Calendar({
className, className,
@@ -27,7 +27,7 @@ function Calendar({
nav: "space-x-1 flex items-center", nav: "space-x-1 flex items-center",
nav_button: cn( nav_button: cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
), ),
nav_button_previous: "absolute left-1", nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1", nav_button_next: "absolute right-1",
@@ -40,11 +40,11 @@ function Calendar({
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range" props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md" : "[&:has([aria-selected])]:rounded-md",
), ),
day: cn( day: cn(
buttonVariants({ variant: "ghost" }), buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100" "h-8 w-8 p-0 font-normal aria-selected:opacity-100",
), ),
day_range_start: "day-range-start", day_range_start: "day-range-start",
day_range_end: "day-range-end", day_range_end: "day-range-end",
@@ -61,16 +61,22 @@ function Calendar({
}} }}
components={{ components={{
IconLeft: ({ className, ...props }) => ( IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} /> <ChevronLeft
className={cn("h-4 w-4", className)}
{...props}
/>
), ),
IconRight: ({ className, ...props }) => ( IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} /> <ChevronRight
className={cn("h-4 w-4", className)}
{...props}
/>
), ),
}} }}
{...props} {...props}
/> />
) );
} }
Calendar.displayName = "Calendar" Calendar.displayName = "Calendar";
export { Calendar } export { Calendar };

View File

@@ -24,7 +24,7 @@ interface ComboBoxProps<T> {
trigger: (value?: string) => React.ReactNode; trigger: (value?: string) => React.ReactNode;
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void;
value: string; value: string;
setValue: React.Dispatch<React.SetStateAction<string>>; onValueChange: (v: string) => void;
children: ( children: (
ItemComponent: ( ItemComponent: (
props: React.ComponentProps<typeof CommandItem> & { label: string }, props: React.ComponentProps<typeof CommandItem> & { label: string },
@@ -39,7 +39,7 @@ const ComboBox = <T,>({
children, children,
onSubmit, onSubmit,
value, value,
setValue, onValueChange,
}: ComboBoxProps<T>) => { }: ComboBoxProps<T>) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -47,7 +47,7 @@ const ComboBox = <T,>({
const handleSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
e.preventDefault(); e.preventDefault();
setValue(searchValue); onValueChange(searchValue);
onSubmit?.(searchValue); onSubmit?.(searchValue);
}; };
@@ -82,7 +82,7 @@ const ComboBox = <T,>({
key={index} key={index}
value={elementValue ?? ""} value={elementValue ?? ""}
onSelect={(_value) => { onSelect={(_value) => {
setValue(_value); onValueChange(_value);
console.log(elementValue); console.log(elementValue);
setOpen(false); setOpen(false);
onSelect?.(_value); onSelect?.(_value);

View File

@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ComponentRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -24,7 +24,7 @@ const ScrollArea = React.forwardRef<
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef< const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef< React.ComponentPropsWithoutRef<
typeof ScrollAreaPrimitive.ScrollAreaScrollbar typeof ScrollAreaPrimitive.ScrollAreaScrollbar
> >

View File

@@ -41,7 +41,7 @@ const toastVariants = cva(
); );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ComponentRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {

View File

@@ -15,7 +15,7 @@ const ToggleGroupContext = React.createContext<
}); });
const ToggleGroup = React.forwardRef< const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => ( >(({ className, variant, size, children, ...props }, ref) => (

View File

@@ -29,7 +29,7 @@ const toggleVariants = cva(
); );
const Toggle = React.forwardRef< const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>, React.ComponentRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => ( >(({ className, variant, size, ...props }, ref) => (

View File

@@ -1,3 +0,0 @@
{
"unstable": ["unsafe-proto"]
}

3111
frontend/deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function useDebounce<T extends (...args: any[]) => void>(
callback: T,
delay: number,
) {
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const debouncedFunction = React.useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
// Cleanup on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedFunction;
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { API_URL } from "@/lib/constants"; import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/types/types"; import { ApiResponse } from "@/types/types";
import { getApiUrl } from "@/utils/api";
import { getCookie } from "cookies-next"; import { getCookie } from "cookies-next";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
@@ -24,7 +25,7 @@ const useFileUpload = (): UseFileUploadReturn => {
const uploadFile = useCallback( const uploadFile = useCallback(
(file: File, url: string, onSuccess?: (response: any) => void) => { (file: File, url: string, onSuccess?: (response: any) => void) => {
url = `${API_URL}${url}`; url = `${getApiUrl()}${url}`;
if (!file || !url) { if (!file || !url) {
setError("File and upload URL are required."); setError("File and upload URL are required.");
return; return;
@@ -35,7 +36,7 @@ const useFileUpload = (): UseFileUploadReturn => {
return; return;
} }
fetch(`${API_URL}/media/verify`, { fetch(`${getApiUrl()}/media/verify`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
name: file.name, name: file.name,

View File

@@ -1,8 +1,6 @@
import { import { CalendarEventExternal } from "@schedule-x/calendar";
CalendarEventExternal,
} from "@schedule-x/calendar";
export default interface ICalendarEvent extends CalendarEventExternal { export default interface ICalendarEvent extends CalendarEventExternal {
isVisible: boolean, isVisible: boolean;
fullday: boolean, fullday: boolean;
rrule: string rrule: string;
} }

View File

@@ -1 +1,3 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
export const SITE_NAME = "Latosa® Escrima";
export const BASE_URL = process.env.SERVER_NAME ?? "latosa.cems.dev";

View File

@@ -0,0 +1,8 @@
import { Location } from "@/types/types";
// Helper to format location as a string
const formatLocation = (location: Location): string => {
return `${location.street}, ${location.city}, ${location.postalCode}`;
};
export default formatLocation;

View File

@@ -3,6 +3,7 @@ import { API_URL } from "./constants";
import { ApiResponse } from "@/types/types"; import { ApiResponse } from "@/types/types";
import IUser from "@/interfaces/IUser"; import IUser from "@/interfaces/IUser";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getApiUrl } from "@/utils/api";
const getMe = cache( const getMe = cache(
async (sessionCookie?: string): Promise<ApiResponse<IUser> | null> => { async (sessionCookie?: string): Promise<ApiResponse<IUser> | null> => {
@@ -12,7 +13,7 @@ const getMe = cache(
if (!token) return null; if (!token) return null;
sessionCookie = token; sessionCookie = token;
} }
const res = await fetch(`${API_URL}/users/me`, { const res = await fetch(`${getApiUrl()}/users/me`, {
headers: { Authorization: `Bearer ${sessionCookie}` }, headers: { Authorization: `Bearer ${sessionCookie}` },
}); });
return await res.json(); return await res.json();

View File

@@ -1,25 +1,43 @@
import { Role } from "@/types/types"; import { Role } from "@/types/types";
export default function hasPermissions( type PermissionResult<T extends Record<string, readonly string[]>> = {
roles: Role[], [K in keyof T]: {
permissions: { [key: string]: string[] }, [A in T[K][number]]: boolean;
) { } & { all: boolean }; // Per-resource 'all'
} & { all: boolean }; // Global 'all'
// hasPermissions function with 'all' flags
export default function hasPermissions<
T extends Record<string, readonly string[]>,
>(roles: Role[], permissions: T): PermissionResult<T> {
// Build permissions set
const permissionsSet: Map<string, null> = new Map(); const permissionsSet: Map<string, null> = new Map();
for (const role of roles) { for (const role of roles) {
if (!role.permissions) continue; if (!role.permissions) continue;
for (const perm of role?.permissions) { for (const perm of role.permissions) {
const key = perm.resource + ":" + perm.action; const key = `${perm.resource}:${perm.action}`;
permissionsSet.set(key, null); permissionsSet.set(key, null);
} }
} }
for (const [perm, actions] of Object.entries(permissions)) { // Build result
const result = { all: true } as PermissionResult<T>; // Initialize global 'all' as true
let globalAll = true; // Track global state
for (const resource in permissions) {
const actions = permissions[resource];
let resourceAll = true; // Track per-resource state
result[resource] = { all: true } as any; // Initialize resource object
for (const action of actions) { for (const action of actions) {
if (!permissionsSet.has(perm + ":" + action)) { const hasPerm = permissionsSet.has(`${resource}:${action}`);
return false; (result[resource] as Record<string, boolean>)[action] = hasPerm;
} resourceAll = resourceAll && hasPerm; // Update resource 'all'
} }
result[resource].all = resourceAll; // Set resource 'all'
globalAll = globalAll && resourceAll; // Update global 'all'
} }
return true; result.all = globalAll; // Set global 'all'
return result as PermissionResult<T>;
} }

View File

@@ -0,0 +1,28 @@
const openNavigationApp = (
location: string,
latitude?: number | null,
longitude?: number | null,
) => {
if (latitude && longitude) {
const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`;
const appleMapsUrl = `maps://maps.apple.com/?daddr=${latitude},${longitude}`;
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isIOS) {
window.open(appleMapsUrl, "_blank");
} else {
window.open(googleMapsUrl, "_blank");
}
} else {
const encodedAddress = encodeURIComponent(location);
const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}`;
const appleMapsUrl = `maps://maps.apple.com/?q=${encodedAddress}`;
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isIOS) {
window.open(appleMapsUrl, "_blank");
} else {
window.open(googleMapsUrl, "_blank");
}
}
};
export default openNavigationApp;

11
frontend/lib/osmEmbed.ts Normal file
View File

@@ -0,0 +1,11 @@
// Construct OSM embed URL
const getOsmEmbedUrl = (lat: number, lon: number) => {
const delta = 0.005; // Adjust zoom level
const minLon = lon - delta;
const minLat = lat - delta;
const maxLon = lon + delta;
const maxLat = lat + delta;
return `https://www.openstreetmap.org/export/embed.html?bbox=${minLon},${minLat},${maxLon},${maxLat}&marker=${lat},${lon}&layer=mapnik`;
};
export default getOsmEmbedUrl;

View File

@@ -1,5 +1,6 @@
import { API_URL } from "@/lib/constants"; import { API_URL } from "@/lib/constants";
import { ApiResponse } from "@/types/types"; import { ApiResponse } from "@/types/types";
import { getApiUrl } from "@/utils/api";
import { getCookie } from "cookies-next"; import { getCookie } from "cookies-next";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
export default async function request<T>( export default async function request<T>(
@@ -20,7 +21,7 @@ export default async function request<T>(
if (options.csrfToken) { if (options.csrfToken) {
const res: ApiResponse<{ csrf: string }> = await ( const res: ApiResponse<{ csrf: string }> = await (
await fetch(`${API_URL}/csrf-token`, { credentials: "include" }) await fetch(`${getApiUrl()}/csrf-token`, { credentials: "include" })
).json(); ).json();
if (res.data) headers["X-CSRF-Token"] = res.data.csrf; if (res.data) headers["X-CSRF-Token"] = res.data.csrf;
} }
@@ -39,7 +40,7 @@ export default async function request<T>(
headers.Authorization = `Bearer ${authToken}`; headers.Authorization = `Bearer ${authToken}`;
} }
const response = await fetch(`${API_URL}${endpoint}`, { const response = await fetch(`${getApiUrl()}${endpoint}`, {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,

View File

@@ -8,3 +8,33 @@ export function cn(...inputs: ClassValue[]) {
export function toTitleCase(str: string) { export function toTitleCase(str: string) {
return str.replace(/\b\w/g, (char) => char.toUpperCase()); return str.replace(/\b\w/g, (char) => char.toUpperCase());
} }
namespace DiffUtils {
export function getDifferences<T extends object>(
obj1: T,
obj2: T,
): Partial<T> {
return Object.entries(obj2).reduce((diff, [key, value]) => {
if (
JSON.stringify(obj1[key as keyof T]) !==
JSON.stringify(obj2[key as keyof T])
) {
diff[key as keyof T] = value as T[keyof T];
}
return diff;
}, {} as Partial<T>);
}
export function isEmpty<T extends object>(obj: T) {
return Object.keys(obj).length === 0;
}
}
// Make it globally available
if (typeof window !== "undefined") {
(window as any).DiffUtils = DiffUtils;
} else {
(global as any).DiffUtils = DiffUtils;
}
export default DiffUtils;

View File

@@ -1,41 +1,19 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const apiUrl =
process.env.NODE_ENV !== "production"
? `http://localhost:${process.env.BACKEND_PORT ?? 3001}`
: `https://${process.env.SERVER_NAME}/api`;
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ transpilePackages: ["@mdxeditor/editor"],
transpilePackages: ['@mdxeditor/editor'],
output: "standalone", output: "standalone",
compiler: { compiler: {
removeConsole: process.env.NODE_ENV === "production", removeConsole: process.env.NODE_ENV === "production",
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
{ { protocol: "https", hostname: "img.youtube.com" },
protocol: "https", { protocol: "https", hostname: "avatar.vercel.sh" },
hostname: "img.youtube.com", { protocol: "http", hostname: "localhost" },
}, { protocol: "https", hostname: "latosa.cems.dev" },
{
protocol: "https",
hostname: "avatar.vercel.sh",
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "latosa.cems.dev",
},
], ],
}, },
env: {
NEXT_PUBLIC_BACKEND_PORT: process.env.BACKEND_PORT,
NEXT_PUBLIC_API_URL: apiUrl,
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -10,11 +10,13 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
@@ -52,6 +54,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"isomorphic-dompurify": "^2.21.0", "isomorphic-dompurify": "^2.21.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"marked": "^15.0.6", "marked": "^15.0.6",
"next": "15.1.4", "next": "15.1.4",
@@ -62,6 +65,7 @@
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^5.0.0",
"swr": "^2.3.0", "swr": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -70,6 +74,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/leaflet": "^1.9.16",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -873,6 +878,57 @@
} }
} }
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
@@ -1057,25 +1113,25 @@
} }
}, },
"node_modules/@radix-ui/react-dialog": { "node_modules/@radix-ui/react-dialog": {
"version": "1.1.4", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
"integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.1", "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1", "@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2",
"@radix-ui/react-id": "1.1.0", "@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.3", "@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2", "@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.1", "@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1", "aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.1" "react-remove-scroll": "^2.6.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1092,21 +1148,102 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.1" "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
"optional": true "optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
"integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
} }
} }
}, },
@@ -1221,6 +1358,166 @@
} }
} }
}, },
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz",
"integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-popper": "1.2.2",
"@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
"integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
"integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
"integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-rect": "1.1.0",
"@radix-ui/react-use-size": "1.1.0",
"@radix-ui/rect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": { "node_modules/@radix-ui/react-id": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
@@ -2290,6 +2587,17 @@
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@remirror/core-constants": { "node_modules/@remirror/core-constants": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@@ -2881,6 +3189,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2895,6 +3210,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.16",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz",
"integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/linkify-it": { "node_modules/@types/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -6099,6 +6424,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -7384,17 +7715,31 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.6.2", "version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
"integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-remove-scroll-bar": "^2.3.7", "react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.1", "react-style-singleton": "^2.2.3",
"tslib": "^2.1.0", "tslib": "^2.1.0",
"use-callback-ref": "^1.3.3", "use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.2" "use-sidecar": "^1.1.3"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"

View File

@@ -11,11 +11,13 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
@@ -53,6 +55,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"isomorphic-dompurify": "^2.21.0", "isomorphic-dompurify": "^2.21.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"marked": "^15.0.6", "marked": "^15.0.6",
"next": "15.1.4", "next": "15.1.4",
@@ -63,6 +66,7 @@
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^5.0.0",
"swr": "^2.3.0", "swr": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -71,6 +75,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/leaflet": "^1.9.16",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

16
frontend/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// types/global.d.ts
declare namespace DiffUtils {
function getDifferences<T extends object>(obj1: T, obj2: T): Partial<T>;
function isEmpty<T extends object>(obj: T): boolean;
}
declare global {
interface Window {
DiffUtils: typeof DiffUtils;
}
namespace NodeJS {
interface Global {
DiffUtils: typeof DiffUtils;
}
}
}

View File

@@ -1,3 +1,5 @@
import ICalendarEvent from "@/interfaces/ICalendarEvent";
export interface Permission { export interface Permission {
resource: string; resource: string;
action: string; action: string;
@@ -52,7 +54,6 @@ export interface User {
email: string; email: string;
password?: string; // Optional field, since it's omitted in the JSON password?: string; // Optional field, since it's omitted in the JSON
phone: string; phone: string;
role: Role; // 'admin' or 'user'
createdAt: string; // ISO date string createdAt: string; // ISO date string
updatedAt: string; // ISO date string updatedAt: string; // ISO date string
@@ -66,3 +67,39 @@ export interface ApiResponse<T> {
message: string; message: string;
data?: T; data?: T;
} }
export interface Location {
id?: number;
street: string;
city: string;
postalCode: string;
latitude?: number;
longitude?: number;
events?: ICalendarEvent[];
}
// types/types.ts
export interface OpenStreetMapLocation {
place_id: string; // Unique identifier for the location
licence: string; // Licensing information
osm_type: string; // e.g., "node", "way", "relation"
osm_id: string; // OSM-specific ID
lat: string; // Latitude
lon: string; // Longitude
display_name: string; // Human-readable full address
address: {
house_number?: string; // House number (optional)
road?: string; // Street name (optional)
neighbourhood?: string; // Neighborhood (optional)
suburb?: string; // Suburb (optional)
city?: string; // City (optional)
town?: string; // Town (fallback for city)
village?: string; // Village (fallback for city)
county?: string; // County (optional)
state?: string; // State or region (optional)
postcode?: string; // Postal code (optional)
country?: string; // Country (optional)
country_code?: string; // ISO country code (e.g., "fr")
[key: string]: string | undefined; // Allow for additional fields
};
}

9
frontend/utils/api.ts Normal file
View File

@@ -0,0 +1,9 @@
export const getApiUrl = () => {
// If window is undefined, we are running on the server (SSR in Docker)
if (typeof window === "undefined") {
return process.env.INTERNAL_API_URL || "http://latosa-backend:4001";
}
// Otherwise, we are running in the user's browser
return process.env.NEXT_PUBLIC_API_URL;
};