added frontend and made it usable
This commit is contained in:
1 parent
96408f00d8
commit
b9b15152bc
50 files changed
+7974
-1
No files matched your search
+3
-1
@@ -1 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
import_files/
|
||||||
|
public
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"whimsoc/vindn-backend/config"
|
||||||
|
"whimsoc/vindn-backend/models"
|
||||||
|
"whimsoc/vindn-backend/repositories"
|
||||||
|
"whimsoc/vindn-backend/services"
|
||||||
|
"whimsoc/vindn-backend/tasks"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Lade .env
|
||||||
|
config.LoadEnv()
|
||||||
|
|
||||||
|
// Verbinde DB
|
||||||
|
db := config.ConnectDatabase()
|
||||||
|
models.Migrate(db)
|
||||||
|
|
||||||
|
// Repositories & Services
|
||||||
|
eventRepo := repositories.NewEventRepository(db)
|
||||||
|
eventService := services.NewEventService(eventRepo)
|
||||||
|
|
||||||
|
// Starte Cronjob
|
||||||
|
tasks.StartCronJobs(eventService)
|
||||||
|
|
||||||
|
// Direkt beim Start einmal holen
|
||||||
|
go func() {
|
||||||
|
if err := eventService.ImportEvents(); err != nil {
|
||||||
|
log.Printf("Fehler beim initialen Import: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("Start server")
|
||||||
|
|
||||||
|
// Starte Fiber
|
||||||
|
|
||||||
|
log.Fatal(app.Listen(":" + config.GetEnv("PORT", "8080")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadEnv() {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Println("Keine .env Datei gefunden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEnv(key string, fallback string) string {
|
||||||
|
if value := os.Getenv(key); len(value) != 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectDatabase() *gorm.DB {
|
||||||
|
dsn := GetEnv("DATABASE_URL", "")
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Fehler beim Verbinden der DB:", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module whimsoc/vindn-backend
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||||
|
github.com/google/uuid v1.3.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.16.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.49.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gofiber/fiber/v2 v2.49.2
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.15.0 // indirect
|
||||||
|
gorm.io/gorm v1.25.10
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||||
|
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gofiber/fiber/v2 v2.49.2 h1:ONEN3/Vc+dUCxxDgZZwpqvhISgHqb+bu+isBiEyKEQs=
|
||||||
|
github.com/gofiber/fiber/v2 v2.49.2/go.mod h1:gNsKnyrmfEWFpJxQAV0qvW6l70K1dZGno12oLtukcts=
|
||||||
|
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||||
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||||
|
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.49.0 h1:9FdvCpmxB74LH4dPb7IJ1cOSsluR07XG3I1txXWwJpE=
|
||||||
|
github.com/valyala/fasthttp v1.49.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||||
|
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
type ApiResponseRaw struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
OverallCount int `json:"overallcount"`
|
||||||
|
Channels []interface{} `json:"channels"`
|
||||||
|
FacetGroups []interface{} `json:"facetGroups"`
|
||||||
|
Items []json.RawMessage `json:"items"` // Raw JSON for items
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
gorm.Model
|
||||||
|
ExternalID string `gorm:"uniqueIndex" json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Street string `json:"street"`
|
||||||
|
Country string
|
||||||
|
Categories []Category `gorm:"many2many:event_categories;" json:"-"`
|
||||||
|
CategoryNames []string `gorm:"-" json:"categories"` // used only for JSON decoding MediaObjects []MediaObject `json:"media_objects"`
|
||||||
|
Features []string `gorm:"-" json:"features"`
|
||||||
|
Keywords []string `gorm:"-" json:"keywords"`
|
||||||
|
Type string
|
||||||
|
Zip string
|
||||||
|
Phone string
|
||||||
|
Web string
|
||||||
|
Email string
|
||||||
|
Author string
|
||||||
|
Latitude float64 `gorm:"index"`
|
||||||
|
Longitude float64 `gorm:"index"`
|
||||||
|
Geo *GeoWrapper `gorm:"-" json:"geo"` // ignored by GORM, used for JSON
|
||||||
|
Description string
|
||||||
|
Teaser string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
MediaObjects []MediaObject `gorm:"many2many:event_media_objects;" json:"-"`
|
||||||
|
RawMediaObjects []MediaObject `gorm:"-" json:"media_objects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaObject struct {
|
||||||
|
gorm.Model
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Alt string `json:"alt"`
|
||||||
|
Rel string `json:"rel"`
|
||||||
|
Name string `json:"value" `
|
||||||
|
Type string `json:"type"`
|
||||||
|
RefID string `json:"id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Prio int `json:"prio"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
License string `json:"license"`
|
||||||
|
AiOptimized bool `json:"aiOptimized"`
|
||||||
|
Ext string
|
||||||
|
Hash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
gorm.Model
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"uniqueIndex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeoWrapper struct {
|
||||||
|
Main *GeoCoordinates `json:"main"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeoCoordinates struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(db *gorm.DB) {
|
||||||
|
db.AutoMigrate(&Event{}, &Category{}, &MediaObject{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"whimsoc/vindn-backend/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventRepository interface {
|
||||||
|
Save(event *models.Event) error
|
||||||
|
FindAll() ([]models.Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventRepository(db *gorm.DB) EventRepository {
|
||||||
|
return &eventRepository{db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *eventRepository) Save(event *models.Event) error {
|
||||||
|
if event.Geo != nil && event.Geo.Main != nil {
|
||||||
|
event.Latitude = event.Geo.Main.Latitude
|
||||||
|
event.Longitude = event.Geo.Main.Longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories []models.Category
|
||||||
|
for _, name := range event.CategoryNames {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cat models.Category
|
||||||
|
if err := r.db.FirstOrCreate(&cat, models.Category{Name: name}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
categories = append(categories, cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.Categories = categories
|
||||||
|
|
||||||
|
var mediaObjects []models.MediaObject
|
||||||
|
for _, rawMediaObject := range event.RawMediaObjects {
|
||||||
|
if rawMediaObject.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mediaObject models.MediaObject
|
||||||
|
h := md5.New()
|
||||||
|
io.WriteString(h, rawMediaObject.Rel)
|
||||||
|
io.WriteString(h, rawMediaObject.Name)
|
||||||
|
io.WriteString(h, rawMediaObject.URL)
|
||||||
|
|
||||||
|
if err := r.db.FirstOrCreate(&mediaObject, models.MediaObject{
|
||||||
|
Hash: fmt.Sprintf("%x", h.Sum(nil)),
|
||||||
|
RefID: rawMediaObject.RefID,
|
||||||
|
Name: rawMediaObject.Name,
|
||||||
|
URL: rawMediaObject.URL,
|
||||||
|
Type: rawMediaObject.Type,
|
||||||
|
Rel: rawMediaObject.Rel,
|
||||||
|
Alt: rawMediaObject.Alt,
|
||||||
|
Source: rawMediaObject.Source,
|
||||||
|
Description: rawMediaObject.Description,
|
||||||
|
Prio: rawMediaObject.Prio,
|
||||||
|
AiOptimized: rawMediaObject.AiOptimized,
|
||||||
|
License: rawMediaObject.License,
|
||||||
|
Ext: filepath.Ext(rawMediaObject.URL),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaObject.Type != "" {
|
||||||
|
mediaType, _, mediaTypeFound := strings.Cut(mediaObject.Type, "/")
|
||||||
|
if err := r.saveMediaFile(mediaObject.Hash, mediaObject.URL, mediaType, mediaObject.Ext); mediaTypeFound && err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaObjects = append(mediaObjects, mediaObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.MediaObjects = mediaObjects
|
||||||
|
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "external_id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).Create(event).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *eventRepository) FindAll() ([]models.Event, error) {
|
||||||
|
var events []models.Event
|
||||||
|
result := r.db.Find(&events)
|
||||||
|
return events, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *eventRepository) saveMediaFile(filename string, url string, mediaType string, extension string) error {
|
||||||
|
mediaFilePath := fmt.Sprintf("./public/storage/%s/%s%s", strings.ToLower(mediaType), filename, extension)
|
||||||
|
|
||||||
|
log.Printf("Get file %s and save it to %s", url, mediaFilePath)
|
||||||
|
|
||||||
|
// Check if the directory exists
|
||||||
|
if _, err := os.Stat(filepath.Dir(mediaFilePath)); os.IsNotExist(err) {
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
err := os.MkdirAll(filepath.Dir(mediaFilePath), 0755) // 0755 is a common permission for directories
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(mediaFilePath)
|
||||||
|
|
||||||
|
if errors.Is(err, os.ErrNotExist) || fileInfo.Size() == 0 {
|
||||||
|
// Create the file
|
||||||
|
out, err := os.Create(mediaFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// Get the data
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Write the body to file
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
log.Printf("File already exists, do not download again. Stats: Name: %s, Size: %d", fileInfo.Name(), fileInfo.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"whimsoc/vindn-backend/models"
|
||||||
|
"whimsoc/vindn-backend/repositories"
|
||||||
|
"whimsoc/vindn-backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FetchEvents() ([]models.Event, error) {
|
||||||
|
return utils.FetchDataFromAPI()
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventService struct {
|
||||||
|
repo repositories.EventRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventService(repo repositories.EventRepository) *EventService {
|
||||||
|
return &EventService{repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EventService) ImportEvents() error {
|
||||||
|
events, err := utils.FetchDataFromAPI()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Data will be saved.")
|
||||||
|
for _, event := range events {
|
||||||
|
if err := s.repo.Save(&event); err != nil {
|
||||||
|
log.Println("Fehler beim Speichern:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Data is saved.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"whimsoc/vindn-backend/services"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartCronJobs(eventService *services.EventService) {
|
||||||
|
c := cron.New()
|
||||||
|
|
||||||
|
// Zweimal täglich, um 06:00 und 18:00 Uhr
|
||||||
|
c.AddFunc("0 6,18 * * *", func() {
|
||||||
|
log.Println("Starte geplanten Event Import...")
|
||||||
|
if err := eventService.ImportEvents(); err != nil {
|
||||||
|
log.Printf("Fehler beim geplanten Import: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"whimsoc/vindn-backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// odhText mirrors one entry of an ODH item's "texts" array (rel/type/value
|
||||||
|
// triples holding description, teaser, price info, etc. in several
|
||||||
|
// languages/formats).
|
||||||
|
type odhText struct {
|
||||||
|
Rel string `json:"rel"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// odhExtras captures the ODH fields that don't map onto flat Event columns
|
||||||
|
// by name (description/teaser live inside "texts", dates inside
|
||||||
|
// "timeIntervals") and need extraction after the plain json.Unmarshal.
|
||||||
|
type odhExtras struct {
|
||||||
|
Texts []odhText `json:"texts"`
|
||||||
|
TimeIntervals []struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
} `json:"timeIntervals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var htmlTagPattern = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
|
// extractText returns the plain-text value of the first "texts" entry whose
|
||||||
|
// rel matches one of the given candidates (checked in order), preferring a
|
||||||
|
// text/plain variant over text/html and stripping tags if only html exists.
|
||||||
|
func extractText(texts []odhText, rels ...string) string {
|
||||||
|
var htmlFallback string
|
||||||
|
for _, rel := range rels {
|
||||||
|
for _, t := range texts {
|
||||||
|
if t.Rel != rel || t.Value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Type == "text/plain" {
|
||||||
|
return t.Value
|
||||||
|
}
|
||||||
|
if htmlFallback == "" && t.Type == "text/html" {
|
||||||
|
htmlFallback = t.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(htmlTagPattern.ReplaceAllString(htmlFallback, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchDataFromAPI2(url string) ([]models.Event, error) {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to fetch data: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var events []models.Event
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
|
||||||
|
log.Printf("Failed to decode JSON: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchDataFromAPI() ([]models.Event, error) {
|
||||||
|
fmt.Println("Starting to get odh files")
|
||||||
|
|
||||||
|
//dataHubTypes := [5]string{"Tour", "POI", "Gastro", "Hotel", "Event"}
|
||||||
|
dataHubTypes := [1]string{"Event"}
|
||||||
|
licenseKey := os.Getenv("ODH_LICENCE_KEY")
|
||||||
|
|
||||||
|
var events []models.Event
|
||||||
|
|
||||||
|
for _, dataHubType := range dataHubTypes {
|
||||||
|
queryOffset := 0
|
||||||
|
limit := 20
|
||||||
|
|
||||||
|
for {
|
||||||
|
rawItems, err := getItemsRaw(dataHubType, licenseKey, queryOffset, limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to fetch items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawItems) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawItem := range rawItems {
|
||||||
|
var event models.Event
|
||||||
|
|
||||||
|
if err := json.Unmarshal(rawItem, &event); err != nil {
|
||||||
|
log.Printf("Failed to decode JSON: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras odhExtras
|
||||||
|
if err := json.Unmarshal(rawItem, &extras); err != nil {
|
||||||
|
log.Printf("Failed to decode text/date extras for %s: %v", event.ExternalID, err)
|
||||||
|
} else {
|
||||||
|
event.Description = extractText(extras.Texts, "details", "open-data-nrw-detail")
|
||||||
|
event.Teaser = extractText(extras.Texts, "teaser", "open-data-nrw-teaser")
|
||||||
|
if len(extras.TimeIntervals) > 0 {
|
||||||
|
event.StartDate = extras.TimeIntervals[0].Start
|
||||||
|
event.EndDate = extras.TimeIntervals[0].End
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully got event %s \"%s\"\n", event.ExternalID, event.Title)
|
||||||
|
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
queryOffset += limit
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all items of a single type
|
||||||
|
func getItemsRaw(dataHubType string, licenseKey string, queryOffset int, limit int) ([]json.RawMessage, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://meta.et4.de/rest.ashx/search/", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("experience", "open-data-nrw-tourismus")
|
||||||
|
q.Add("type", dataHubType)
|
||||||
|
q.Add("licenseKey", licenseKey)
|
||||||
|
q.Add("q", "area:\"Nordrhein-Westfalen\"")
|
||||||
|
q.Add("template", "ET2014A.json")
|
||||||
|
q.Add("limit", strconv.Itoa(limit))
|
||||||
|
q.Add("offset", strconv.Itoa(queryOffset))
|
||||||
|
q.Add("sort", "id asc")
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
fmt.Println("Query: " + req.URL.String())
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("client: error making http request: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var apiResp models.ApiResponseRaw
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiResp.Items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: golang:1.24
|
||||||
|
working_dir: /backend
|
||||||
|
ports:
|
||||||
|
- "8082:8080"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- ODH_LICENCE_KEY=${ODH_LICENCE_KEY}
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
- ./backend:/backend
|
||||||
|
command: go run cmd/server/main.go
|
||||||
|
restart: unless-stopped
|
||||||
|
db:
|
||||||
|
image: postgres:14
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ./pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
working_dir: /app
|
||||||
|
ports:
|
||||||
|
- "8083:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- ./backend/public/storage:/app/public/storage
|
||||||
|
command: sh -c "composer install --no-interaction && php -S 0.0.0.0:8000 -t public"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
admin:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- 5433:8080
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
###> symfony/framework-bundle ###
|
||||||
|
/.env.local
|
||||||
|
/.env.local.php
|
||||||
|
/.env.*.local
|
||||||
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/public/bundles/
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM php:8.3-cli
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev unzip git \
|
||||||
|
&& docker-php-ext-install pdo_pgsql \
|
||||||
|
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# The imported event data and the site's audience are both German-local;
|
||||||
|
# default to that instead of the container's UTC so any future "now"/
|
||||||
|
# "today" computation doesn't quietly disagree with the data for the two
|
||||||
|
# hours after every UTC midnight.
|
||||||
|
ENV TZ=Europe/Berlin
|
||||||
|
RUN echo "date.timezone = Europe/Berlin" > /usr/local/etc/php/conf.d/timezone.ini
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
|
||||||
|
echo 'Warning: The console should be invoked via the CLI version of PHP, not the '.\PHP_SAPI.' SAPI'.\PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"doctrine/dbal": "^4.4",
|
||||||
|
"doctrine/doctrine-bundle": "^2.19",
|
||||||
|
"doctrine/orm": "^3.6",
|
||||||
|
"symfony/asset": "7.4.*",
|
||||||
|
"symfony/console": "7.4.*",
|
||||||
|
"symfony/doctrine-bridge": "7.4.*",
|
||||||
|
"symfony/dotenv": "7.4.*",
|
||||||
|
"symfony/flex": "^2",
|
||||||
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/twig-bundle": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*",
|
||||||
|
"twig/twig": "^3.28"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true,
|
||||||
|
"symfony/flex": true,
|
||||||
|
"symfony/runtime": true
|
||||||
|
},
|
||||||
|
"bump-after-update": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-ctype": "*",
|
||||||
|
"symfony/polyfill-iconv": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php74": "*",
|
||||||
|
"symfony/polyfill-php80": "*",
|
||||||
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"auto-scripts": [
|
||||||
|
],
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/symfony": "*"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
"require": "7.4.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4122
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
server_version: '14'
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: true
|
||||||
|
enable_lazy_ghost_objects: true
|
||||||
|
report_fields_where_declared: true
|
||||||
|
validate_xml_mapping: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
|
auto_mapping: true
|
||||||
|
mappings:
|
||||||
|
App:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
|
prefix: 'App\Entity'
|
||||||
|
alias: App
|
||||||
|
controller_resolver:
|
||||||
|
auto_mapping: true
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
doctrine:
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: false
|
||||||
|
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||||
|
query_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.system_cache_pool
|
||||||
|
result_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.result_cache_pool
|
||||||
|
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
doctrine.result_cache_pool:
|
||||||
|
adapter: cache.app
|
||||||
|
doctrine.system_cache_pool:
|
||||||
|
adapter: cache.system
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
router:
|
||||||
|
utf8: true
|
||||||
|
php_errors:
|
||||||
|
log: true
|
||||||
|
assets: ~
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
twig:
|
||||||
|
default_path: '%kernel.project_dir%/templates'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
|||||||
|
controllers:
|
||||||
|
resource:
|
||||||
|
path: ../src/Controller/
|
||||||
|
namespace: App\Controller
|
||||||
|
type: attribute
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
parameters:
|
||||||
|
|
||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
exclude:
|
||||||
|
- '../src/DependencyInjection/'
|
||||||
|
- '../src/Entity/'
|
||||||
|
- '../src/Kernel.php'
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repository\CategoryRepository;
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
use App\Service\EventMapSerializer;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class EventController extends AbstractController
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 24;
|
||||||
|
|
||||||
|
#[Route('/events', name: 'event_list')]
|
||||||
|
public function list(Request $request, EventRepository $events, CategoryRepository $categories): Response
|
||||||
|
{
|
||||||
|
$page = max(1, $request->query->getInt('page', 1));
|
||||||
|
$category = $request->query->get('category') ?: null;
|
||||||
|
$offset = (($page ?: 1) - 1) * self::PER_PAGE;
|
||||||
|
|
||||||
|
$items = $events->findPage(self::PER_PAGE, $offset, $category);
|
||||||
|
$total = $events->countPage($category);
|
||||||
|
|
||||||
|
return $this->render('events/list.html.twig', [
|
||||||
|
'activeNav' => 'events',
|
||||||
|
'events' => $items,
|
||||||
|
'categories' => $categories->findUsedNames(),
|
||||||
|
'activeCategory' => $category,
|
||||||
|
'page' => $page,
|
||||||
|
'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)),
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/events/{id}', name: 'event_show', requirements: ['id' => '\d+'])]
|
||||||
|
public function show(int $id, EventRepository $events, EventMapSerializer $mapSerializer): Response
|
||||||
|
{
|
||||||
|
$event = $events->findOneNotDeleted($id);
|
||||||
|
if (!$event) {
|
||||||
|
throw $this->createNotFoundException('Event nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('events/show.html.twig', [
|
||||||
|
'activeNav' => 'events',
|
||||||
|
'event' => $event,
|
||||||
|
'mapEventJson' => $event->hasCoordinates() ? $mapSerializer->toJson([$event]) : '[]',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repository\CategoryRepository;
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
use App\Service\DefaultLocation;
|
||||||
|
use App\Service\EventMapSerializer;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class HomeController extends AbstractController
|
||||||
|
{
|
||||||
|
// Matches /map's MAX_RANGE_KM/POOL_LIMIT: the light map only shows the
|
||||||
|
// nearest 20, but the candidate pool it picks from needs to actually
|
||||||
|
// cover that area, not just be an arbitrary unordered chunk of events.
|
||||||
|
private const POOL_RADIUS_KM = 100;
|
||||||
|
private const POOL_LIMIT = 3000;
|
||||||
|
|
||||||
|
#[Route('/', name: 'home')]
|
||||||
|
public function index(
|
||||||
|
EventRepository $events,
|
||||||
|
CategoryRepository $categories,
|
||||||
|
EventMapSerializer $mapSerializer
|
||||||
|
): Response {
|
||||||
|
// Same fallback as /map: default to Köln until real geolocation
|
||||||
|
// (checked client-side in map.js) says otherwise.
|
||||||
|
$lat = DefaultLocation::LAT;
|
||||||
|
$lon = DefaultLocation::LON;
|
||||||
|
|
||||||
|
$pool = $events->findWithinRadius($lat, $lon, self::POOL_RADIUS_KM, self::POOL_LIMIT);
|
||||||
|
|
||||||
|
return $this->render('home.html.twig', [
|
||||||
|
'activeNav' => 'home',
|
||||||
|
'upcomingEvents' => $events->findRecent(6),
|
||||||
|
'mapEventsJson' => $mapSerializer->toJson($pool),
|
||||||
|
'popularCategories' => $categories->findMostUsedNames(12),
|
||||||
|
'centerLat' => $lat,
|
||||||
|
'centerLon' => $lon,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repository\CategoryRepository;
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
use App\Service\DefaultLocation;
|
||||||
|
use App\Service\EventMapSerializer;
|
||||||
|
use App\Service\RangeDefaults;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class MapController extends AbstractController
|
||||||
|
{
|
||||||
|
// Safety cap on the pool shipped to the client, not a practical limit:
|
||||||
|
// even the densest 100km radius seen (NRW around Köln) comes in under
|
||||||
|
// 3000, so this should never actually truncate in normal use.
|
||||||
|
private const POOL_LIMIT = 3000;
|
||||||
|
|
||||||
|
#[Route('/map', name: 'map')]
|
||||||
|
public function index(
|
||||||
|
Request $request,
|
||||||
|
EventRepository $events,
|
||||||
|
CategoryRepository $categories,
|
||||||
|
EventMapSerializer $mapSerializer
|
||||||
|
): Response {
|
||||||
|
$lat = (float) ($request->query->get('lat') ?: DefaultLocation::LAT);
|
||||||
|
$lon = (float) ($request->query->get('lon') ?: DefaultLocation::LON);
|
||||||
|
|
||||||
|
$rangeKm = (int) $request->query->get('range', RangeDefaults::DEFAULT_KM);
|
||||||
|
if ($rangeKm <= 0 || $rangeKm > RangeDefaults::MAX_KM) {
|
||||||
|
$rangeKm = RangeDefaults::DEFAULT_KM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship the pool sized to the *max* selectable range so the range
|
||||||
|
// dropdown and small map clicks/drags can be handled entirely
|
||||||
|
// client-side, without another page load.
|
||||||
|
$pool = $events->findWithinRadius($lat, $lon, RangeDefaults::MAX_KM, self::POOL_LIMIT);
|
||||||
|
|
||||||
|
return $this->render('map.html.twig', [
|
||||||
|
'activeNav' => 'map',
|
||||||
|
'mapEventsJson' => $mapSerializer->toJson($pool),
|
||||||
|
'categories' => $categories->findUsedNames(),
|
||||||
|
'centerLat' => $lat,
|
||||||
|
'centerLon' => $lon,
|
||||||
|
'rangeKm' => $rangeKm,
|
||||||
|
'rangeOptions' => RangeDefaults::OPTIONS_KM,
|
||||||
|
'maxRangeKm' => RangeDefaults::MAX_KM,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repository\CategoryRepository;
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
use App\Service\RangeDefaults;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class SearchController extends AbstractController
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 24;
|
||||||
|
|
||||||
|
#[Route('/search', name: 'search')]
|
||||||
|
public function index(Request $request, EventRepository $events, CategoryRepository $categories): Response
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query->get('q', ''));
|
||||||
|
$category = $request->query->get('category') ?: null;
|
||||||
|
$when = $request->query->get('when') ?: 'any';
|
||||||
|
$page = max(1, $request->query->getInt('page', 1));
|
||||||
|
|
||||||
|
// Location filtering is opt-in — plain text/category search behaves
|
||||||
|
// exactly as before unless a place/"use my location" actually set
|
||||||
|
// lat/lon, unlike /map which always has *some* center.
|
||||||
|
$latParam = $request->query->get('lat');
|
||||||
|
$lonParam = $request->query->get('lon');
|
||||||
|
$hasLocation = null !== $latParam && null !== $lonParam && '' !== $latParam && '' !== $lonParam;
|
||||||
|
$lat = $hasLocation ? (float) $latParam : null;
|
||||||
|
$lon = $hasLocation ? (float) $lonParam : null;
|
||||||
|
|
||||||
|
$rangeKm = (int) $request->query->get('range', RangeDefaults::DEFAULT_KM);
|
||||||
|
if ($rangeKm <= 0 || $rangeKm > RangeDefaults::MAX_KM) {
|
||||||
|
$rangeKm = RangeDefaults::DEFAULT_KM;
|
||||||
|
}
|
||||||
|
|
||||||
|
$distances = [];
|
||||||
|
$locationIds = null;
|
||||||
|
if ($hasLocation) {
|
||||||
|
$distances = $events->distancesWithinRadius($lat, $lon, $rangeKm, 3000);
|
||||||
|
$locationIds = array_keys($distances);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($page - 1) * self::PER_PAGE;
|
||||||
|
$results = $events->search($q ?: null, $category, $when, self::PER_PAGE, $offset, $locationIds);
|
||||||
|
$total = $events->countSearch($q ?: null, $category, $when, $locationIds);
|
||||||
|
|
||||||
|
return $this->render('search.html.twig', [
|
||||||
|
'activeNav' => 'search',
|
||||||
|
'q' => $q,
|
||||||
|
'events' => $results,
|
||||||
|
'categories' => $categories->findUsedNames(),
|
||||||
|
'activeCategory' => $category,
|
||||||
|
'when' => $when,
|
||||||
|
'hasLocation' => $hasLocation,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lon' => $lon,
|
||||||
|
'rangeKm' => $rangeKm,
|
||||||
|
'rangeOptions' => RangeDefaults::OPTIONS_KM,
|
||||||
|
'distances' => $distances,
|
||||||
|
'page' => $page,
|
||||||
|
'totalPages' => max(1, (int) ceil($total / self::PER_PAGE)),
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\CategoryRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
|
||||||
|
#[ORM\Table(name: 'categories')]
|
||||||
|
class Category
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EventRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EventRepository::class)]
|
||||||
|
#[ORM\Table(name: 'events')]
|
||||||
|
class Event
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'external_id', type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $externalId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $title = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $city = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $street = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $country = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $type = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $zip = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $phone = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $web = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $author = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', nullable: true)]
|
||||||
|
private ?float $latitude = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', nullable: true)]
|
||||||
|
private ?float $longitude = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $teaser = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'start_date', type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $startDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'end_date', type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime', nullable: true)]
|
||||||
|
private ?\DateTimeInterface $deletedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'event_categories')]
|
||||||
|
#[ORM\JoinColumn(name: 'event_id', referencedColumnName: 'id')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id')]
|
||||||
|
private Collection $categories;
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: MediaObject::class)]
|
||||||
|
#[ORM\JoinTable(name: 'event_media_objects')]
|
||||||
|
#[ORM\JoinColumn(name: 'event_id', referencedColumnName: 'id')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'media_object_id', referencedColumnName: 'id')]
|
||||||
|
private Collection $mediaObjects;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->categories = new ArrayCollection();
|
||||||
|
$this->mediaObjects = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExternalId(): ?string
|
||||||
|
{
|
||||||
|
return $this->externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCity(): ?string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStreet(): ?string
|
||||||
|
{
|
||||||
|
return $this->street;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountry(): ?string
|
||||||
|
{
|
||||||
|
return $this->country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): ?string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getZip(): ?string
|
||||||
|
{
|
||||||
|
return $this->zip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPhone(): ?string
|
||||||
|
{
|
||||||
|
return $this->phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeb(): ?string
|
||||||
|
{
|
||||||
|
return $this->web;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthor(): ?string
|
||||||
|
{
|
||||||
|
return $this->author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLatitude(): ?float
|
||||||
|
{
|
||||||
|
return $this->latitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLongitude(): ?float
|
||||||
|
{
|
||||||
|
return $this->longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTeaser(): ?string
|
||||||
|
{
|
||||||
|
return $this->teaser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): ?string
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?string
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Category> */
|
||||||
|
public function getCategories(): Collection
|
||||||
|
{
|
||||||
|
return $this->categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
public function getCategoryNames(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn (Category $category) => $category->getName(),
|
||||||
|
$this->categories->toArray()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, MediaObject> */
|
||||||
|
public function getMediaObjects(): Collection
|
||||||
|
{
|
||||||
|
return $this->mediaObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrimaryImage(): ?MediaObject
|
||||||
|
{
|
||||||
|
foreach ($this->mediaObjects as $mediaObject) {
|
||||||
|
if ($mediaObject->isImage()) {
|
||||||
|
return $mediaObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCoordinates(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->latitude
|
||||||
|
&& null !== $this->longitude
|
||||||
|
&& (0.0 !== $this->latitude || 0.0 !== $this->longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDateTime(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (!$this->startDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new \DateTimeImmutable($this->startDate);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDateTime(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (!$this->endDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new \DateTimeImmutable($this->endDate);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'media_objects')]
|
||||||
|
class MediaObject
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $url = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $alt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $rel = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $type = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'ref_id', type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $refId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $source = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
private ?int $prio = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $license = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'ai_optimized', type: 'boolean', nullable: true)]
|
||||||
|
private ?bool $aiOptimized = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $ext = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $hash = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlt(): ?string
|
||||||
|
{
|
||||||
|
return $this->alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): ?string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSource(): ?string
|
||||||
|
{
|
||||||
|
return $this->source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLicense(): ?string
|
||||||
|
{
|
||||||
|
return $this->license;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExt(): ?string
|
||||||
|
{
|
||||||
|
return $this->ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHash(): ?string
|
||||||
|
{
|
||||||
|
return $this->hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isImage(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($this->type ?? '', 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the path EventRepository.saveMediaFile writes to in the Go
|
||||||
|
* importer: ./public/storage/<mediatype>/<hash><ext>.
|
||||||
|
*/
|
||||||
|
public function getStoragePath(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->hash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaType = strtolower((string) (strstr($this->type ?? '', '/', true) ?: 'other'));
|
||||||
|
|
||||||
|
return sprintf('/storage/%s/%s%s', $mediaType, $this->hash, $this->ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Category;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class CategoryRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category names actually attached to at least one non-deleted event —
|
||||||
|
* the plain categories table can contain names no current event uses.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function findUsedNames(): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT DISTINCT c.name FROM categories c
|
||||||
|
INNER JOIN event_categories ec ON ec.category_id = c.id
|
||||||
|
INNER JOIN events e ON e.id = ec.event_id AND e.deleted_at IS NULL
|
||||||
|
WHERE c.name IS NOT NULL AND c.name <> \'\'
|
||||||
|
ORDER BY c.name ASC';
|
||||||
|
|
||||||
|
return $this->getEntityManager()->getConnection()->executeQuery($sql)->fetchFirstColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category names ordered by how many non-deleted events use them —
|
||||||
|
* for a curated "browse by category" section, where alphabetical order
|
||||||
|
* would surface obscure tags ahead of common ones.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function findMostUsedNames(int $limit = 12): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT c.name FROM categories c
|
||||||
|
INNER JOIN event_categories ec ON ec.category_id = c.id
|
||||||
|
INNER JOIN events e ON e.id = ec.event_id AND e.deleted_at IS NULL
|
||||||
|
WHERE c.name IS NOT NULL AND c.name <> \'\'
|
||||||
|
GROUP BY c.name
|
||||||
|
ORDER BY COUNT(*) DESC, c.name ASC
|
||||||
|
LIMIT :limit';
|
||||||
|
|
||||||
|
return $this->getEntityManager()->getConnection()
|
||||||
|
->executeQuery($sql, ['limit' => $limit], ['limit' => ParameterType::INTEGER])
|
||||||
|
->fetchFirstColumn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Event;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class EventRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneNotDeleted(int $id): ?Event
|
||||||
|
{
|
||||||
|
return $this->notDeleted()
|
||||||
|
->andWhere('e.id = :id')
|
||||||
|
->setParameter('id', $id)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Event[] */
|
||||||
|
public function findRecent(int $limit): array
|
||||||
|
{
|
||||||
|
return $this->orderByStartDate($this->notDeleted())
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Event[] */
|
||||||
|
public function findPage(int $limit, int $offset, ?string $category = null): array
|
||||||
|
{
|
||||||
|
$qb = $this->orderByStartDate($this->notDeleted())
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->setFirstResult($offset);
|
||||||
|
|
||||||
|
$this->filterByCategory($qb, $category);
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countPage(?string $category = null): int
|
||||||
|
{
|
||||||
|
$qb = $this->notDeleted()->select('COUNT(DISTINCT e.id)');
|
||||||
|
$this->filterByCategory($qb, $category);
|
||||||
|
|
||||||
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Event[] */
|
||||||
|
public function findWithCoordinates(int $limit = 500): array
|
||||||
|
{
|
||||||
|
return $this->notDeleted()
|
||||||
|
->andWhere('e.latitude IS NOT NULL AND e.longitude IS NOT NULL')
|
||||||
|
->andWhere('e.latitude <> 0 OR e.longitude <> 0')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance in km to every event within `radiusKm` of (lat, lon),
|
||||||
|
* nearest first, keyed by event id — the location+range based
|
||||||
|
* replacement for a flat "first N" cap. No PostGIS here, so distance
|
||||||
|
* is a plain Haversine formula in raw SQL (DQL has no trig
|
||||||
|
* functions); a computed column can't be filtered in WHERE directly,
|
||||||
|
* hence the subquery.
|
||||||
|
*
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT id, distance_km FROM (
|
||||||
|
SELECT e.id,
|
||||||
|
(6371 * acos(least(1, greatest(-1,
|
||||||
|
cos(radians(:lat)) * cos(radians(e.latitude)) * cos(radians(e.longitude) - radians(:lon))
|
||||||
|
+ sin(radians(:lat)) * sin(radians(e.latitude))
|
||||||
|
)))) AS distance_km
|
||||||
|
FROM events e
|
||||||
|
WHERE e.deleted_at IS NULL
|
||||||
|
AND e.latitude IS NOT NULL AND e.longitude IS NOT NULL
|
||||||
|
AND (e.latitude <> 0 OR e.longitude <> 0)
|
||||||
|
) sub
|
||||||
|
WHERE distance_km <= :radius
|
||||||
|
ORDER BY distance_km ASC
|
||||||
|
LIMIT :limit';
|
||||||
|
|
||||||
|
$rows = $this->getEntityManager()->getConnection()->executeQuery(
|
||||||
|
$sql,
|
||||||
|
['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit],
|
||||||
|
['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
|
||||||
|
$distances = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$distances[(int) $row['id']] = (float) $row['distance_km'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $distances;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events within `radiusKm` of (lat, lon), nearest first.
|
||||||
|
*
|
||||||
|
* @return Event[]
|
||||||
|
*/
|
||||||
|
public function findWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
|
||||||
|
{
|
||||||
|
return $this->hydrateInOrder(array_keys($this->distancesWithinRadius($lat, $lon, $radiusKm, $limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text/category search, paginated, optionally restricted to (and
|
||||||
|
* ordered by) a pre-computed set of location-filtered ids — e.g. from
|
||||||
|
* distancesWithinRadius() — so "markets near Düsseldorf" combines a
|
||||||
|
* normal LIKE/category filter with the radius query rather than
|
||||||
|
* needing a second, separate search path. Without $locationIds this
|
||||||
|
* behaves like a plain text/category search, ordered chronologically.
|
||||||
|
*
|
||||||
|
* Distance order only exists in PHP (the $locationIds array), not as
|
||||||
|
* a sortable DB column, so paging has to slice that ordered id list
|
||||||
|
* *after* intersecting it with the text/category matches — pushing
|
||||||
|
* LIMIT/OFFSET into SQL here would paginate an arbitrary DB-ordered
|
||||||
|
* subset instead of the correct "25th-50th nearest" slice.
|
||||||
|
*
|
||||||
|
* @param int[]|null $locationIds
|
||||||
|
*
|
||||||
|
* @return Event[]
|
||||||
|
*/
|
||||||
|
public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null): array
|
||||||
|
{
|
||||||
|
if (null === $locationIds) {
|
||||||
|
return $this->orderByStartDate($this->baseSearchQb($q, $category, $when))
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->setFirstResult($offset)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$locationIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchingIds = array_map('intval', array_column(
|
||||||
|
$this->baseSearchQb($q, $category, $when)
|
||||||
|
->select('e.id')
|
||||||
|
->andWhere('e.id IN (:locationIds)')
|
||||||
|
->setParameter('locationIds', $locationIds)
|
||||||
|
->getQuery()
|
||||||
|
->getScalarResult(),
|
||||||
|
'id'
|
||||||
|
));
|
||||||
|
|
||||||
|
$matchingIdSet = array_flip($matchingIds);
|
||||||
|
$orderedIds = array_values(array_filter($locationIds, static fn ($id) => isset($matchingIdSet[$id])));
|
||||||
|
|
||||||
|
return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total count matching the same filters as search(), for pagination.
|
||||||
|
*
|
||||||
|
* @param int[]|null $locationIds
|
||||||
|
*/
|
||||||
|
public function countSearch(?string $q, ?string $category, string $when, ?array $locationIds = null): int
|
||||||
|
{
|
||||||
|
if (null !== $locationIds && !$locationIds) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->baseSearchQb($q, $category, $when)->select('COUNT(DISTINCT e.id)');
|
||||||
|
|
||||||
|
if (null !== $locationIds) {
|
||||||
|
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseSearchQb(?string $q, ?string $category, string $when): QueryBuilder
|
||||||
|
{
|
||||||
|
$qb = $this->notDeleted();
|
||||||
|
|
||||||
|
if ($q) {
|
||||||
|
$qb->andWhere('LOWER(e.title) LIKE :q OR LOWER(e.city) LIKE :q OR LOWER(e.street) LIKE :q')
|
||||||
|
->setParameter('q', '%'.mb_strtolower($q).'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->filterByCategory($qb, $category);
|
||||||
|
$this->filterByWhen($qb, $when);
|
||||||
|
|
||||||
|
return $qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startDate is a raw ISO-8601-ish string ("2026-09-12T18:00:00+02:00"),
|
||||||
|
* consistently formatted across the imported dataset, so a plain
|
||||||
|
* lexicographic string comparison against a "Y-m-d" boundary sorts
|
||||||
|
* correctly without needing to parse it as a real date/time column.
|
||||||
|
* Applied in SQL (not PHP, post-fetch) so it composes correctly with
|
||||||
|
* pagination and COUNT — filtering after LIMIT/OFFSET would make the
|
||||||
|
* page size and total count lie.
|
||||||
|
*/
|
||||||
|
private function filterByWhen(QueryBuilder $qb, string $when): void
|
||||||
|
{
|
||||||
|
if ('any' === $when) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit timezone, not the container's (UTC): the imported dates
|
||||||
|
// and the site's audience are both German-local, so "today" needs
|
||||||
|
// to mean the German calendar day, not whatever day it happens to
|
||||||
|
// be in UTC — those disagree for two hours after every UTC
|
||||||
|
// midnight.
|
||||||
|
$start = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin'));
|
||||||
|
$end = match ($when) {
|
||||||
|
'today' => $start->modify('+1 day'),
|
||||||
|
'week' => $start->modify('+7 days'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!$end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->andWhere('e.startDate >= :whenStart AND e.startDate < :whenEnd')
|
||||||
|
->setParameter('whenStart', $start->format('Y-m-d'))
|
||||||
|
->setParameter('whenEnd', $end->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates entities for the given ids and returns them in that exact
|
||||||
|
* order — Doctrine's IN(...) doesn't preserve it.
|
||||||
|
*
|
||||||
|
* @param int[] $ids
|
||||||
|
*
|
||||||
|
* @return Event[]
|
||||||
|
*/
|
||||||
|
private function hydrateInOrder(array $ids): array
|
||||||
|
{
|
||||||
|
if (!$ids) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $this->createQueryBuilder('e')
|
||||||
|
->andWhere('e.id IN (:ids)')
|
||||||
|
->setParameter('ids', $ids)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$order = array_flip($ids);
|
||||||
|
usort($events, static fn (Event $a, Event $b) => $order[$a->getId()] <=> $order[$b->getId()]);
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterByCategory(QueryBuilder $qb, ?string $category): void
|
||||||
|
{
|
||||||
|
if (!$category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->join('e.categories', 'c')
|
||||||
|
->andWhere('c.name = :category')
|
||||||
|
->setParameter('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notDeleted(): QueryBuilder
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('e')
|
||||||
|
->andWhere('e.deletedAt IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startDate is a raw ISO-ish string column (may be empty when the
|
||||||
|
* source had no time interval); push undated events to the end
|
||||||
|
* instead of letting '' sort first.
|
||||||
|
*/
|
||||||
|
private function orderByStartDate(QueryBuilder $qb): QueryBuilder
|
||||||
|
{
|
||||||
|
return $qb->addSelect("CASE WHEN e.startDate = '' THEN 1 ELSE 0 END AS HIDDEN has_no_date")
|
||||||
|
->orderBy('has_no_date', 'ASC')
|
||||||
|
->addOrderBy('e.startDate', 'ASC');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Köln — the site-wide fallback map center used whenever no real location
|
||||||
|
* (browser geolocation, a place search, or a URL) is available yet. Shared
|
||||||
|
* by HomeController and MapController so the "nearest 20" light map and
|
||||||
|
* the full /map explorer agree on the same default.
|
||||||
|
*/
|
||||||
|
final class DefaultLocation
|
||||||
|
{
|
||||||
|
public const LAT = 50.9375;
|
||||||
|
public const LON = 6.9603;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Event;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes Event entities into the plain JSON array the Leaflet stage
|
||||||
|
* (public/js/map.js) reads — used by Home, Map, and Event::show, so pulled
|
||||||
|
* out once rather than duplicated per controller.
|
||||||
|
*/
|
||||||
|
class EventMapSerializer
|
||||||
|
{
|
||||||
|
public function __construct(private UrlGeneratorInterface $urlGenerator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Event[] $events */
|
||||||
|
public function toJson(array $events): string
|
||||||
|
{
|
||||||
|
return json_encode(
|
||||||
|
array_map($this->toPoint(...), $events),
|
||||||
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toPoint(Event $event): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $event->getId(),
|
||||||
|
'title' => $event->getTitle(),
|
||||||
|
'venue' => $event->getCity(),
|
||||||
|
'category' => $event->getCategoryNames()[0] ?? null,
|
||||||
|
'lat' => $event->getLatitude(),
|
||||||
|
'lon' => $event->getLongitude(),
|
||||||
|
'url' => $this->urlGenerator->generate('event_show', ['id' => $event->getId()]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared radius options for every location-based filter (the /map
|
||||||
|
* explorer and /search's "near a location" filter) — one set of
|
||||||
|
* dropdown choices and bounds instead of each controller inventing its
|
||||||
|
* own.
|
||||||
|
*/
|
||||||
|
final class RangeDefaults
|
||||||
|
{
|
||||||
|
public const DEFAULT_KM = 25;
|
||||||
|
public const MAX_KM = 100;
|
||||||
|
public const OPTIONS_KM = [5, 10, 25, 50, 100];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Twig;
|
||||||
|
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real ODH category names are German tourism-taxonomy strings, not a fixed
|
||||||
|
* set of slugs, so colors are derived deterministically from the name
|
||||||
|
* instead of a hardcoded lookup table.
|
||||||
|
*/
|
||||||
|
class CategoryColorExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function getFilters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFilter('category_color', $this->categoryColor(...)),
|
||||||
|
new TwigFilter('category_bg', $this->categoryBg(...)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoryColor(?string $name): string
|
||||||
|
{
|
||||||
|
if (!$name) {
|
||||||
|
return 'hsl(220, 10%, 55%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('hsl(%d, 62%%, 42%%)', $this->hue($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoryBg(?string $name): string
|
||||||
|
{
|
||||||
|
if (!$name) {
|
||||||
|
return 'hsl(220, 15%, 92%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('hsl(%d, 55%%, 92%%)', $this->hue($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hue(string $name): int
|
||||||
|
{
|
||||||
|
return crc32($name) % 360;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"doctrine/deprecations": {
|
||||||
|
"version": "1.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-bundle": {
|
||||||
|
"version": "2.19.0"
|
||||||
|
},
|
||||||
|
"symfony/console": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"version": "5.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"symfony/flex": {
|
||||||
|
"version": "2.11",
|
||||||
|
"recipe": {
|
||||||
|
"version": "2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"symfony/framework-bundle": {
|
||||||
|
"version": "v7.4.16"
|
||||||
|
},
|
||||||
|
"symfony/routing": {
|
||||||
|
"version": "7.4",
|
||||||
|
"recipe": {
|
||||||
|
"version": "7.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"symfony/twig-bundle": {
|
||||||
|
"version": "v7.4.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{# Leaflet + plugin stylesheets, shared by every page that renders a map. #}
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.min.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-gesture-handling@1.2.2/dist/leaflet-gesture-handling.min.css">
|
||||||
|
{# OpenFreeMap serves vector tiles only — MapLibre GL renders them, bridged into our Leaflet map via maplibre-gl-leaflet. #}
|
||||||
|
<link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet.awesome-markers@2.0.5/dist/leaflet.awesome-markers.css">
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{# Leaflet + plugins + our map.js wrapper, shared by every page that renders a map. #}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||||
|
{# Requires a two-finger drag / Ctrl+scroll to pan/zoom on touch/trackpad, with an on-map hint, so the map doesn't trap page scroll. #}
|
||||||
|
<script src="https://unpkg.com/leaflet-gesture-handling@1.2.2/dist/leaflet-gesture-handling.min.js"></script>
|
||||||
|
{# OpenFreeMap vector tiles: MapLibre GL does the rendering, maplibre-gl-leaflet bridges it into our Leaflet map as an ordinary layer. #}
|
||||||
|
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet/leaflet-maplibre-gl.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/leaflet.awesome-markers@2.0.5/dist/leaflet.awesome-markers.min.js"></script>
|
||||||
|
<script src="{{ asset('js/map.js') }}"></script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{#
|
||||||
|
Reusable interactive map stage: pin map, event search box, "use my
|
||||||
|
location" button, and a nearby-events panel. Include on any page that
|
||||||
|
has defined `mapEventsJson` and `categories` in scope, then call
|
||||||
|
Vindn.initMapStage() (home) or Vindn.initRadiusMapStage() (/map) from
|
||||||
|
that page's scripts block.
|
||||||
|
|
||||||
|
Pass `compact: true` for the lighter home-page hero variant: hides the
|
||||||
|
category chips/legend and the location-search/range controls, and uses
|
||||||
|
a contained card height instead of the full-viewport /map takeover.
|
||||||
|
#}
|
||||||
|
{% set compact = compact|default(false) %}
|
||||||
|
<section class="stage{{ compact ? ' stage-compact' : '' }}">
|
||||||
|
<div id="mapCanvas" role="application" aria-label="Map of events"></div>
|
||||||
|
|
||||||
|
<div class="map-overlay-top">
|
||||||
|
<div class="float-card search-card">
|
||||||
|
<form action="{{ path('search') }}">
|
||||||
|
<label for="mapSearch" style="display:none;">Search events</label>
|
||||||
|
<input id="mapSearch" name="q" type="text" placeholder="Search events…">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
{% if not compact %}
|
||||||
|
<div class="chip-row-tight" id="catChips">
|
||||||
|
<button class="chip-sm is-active" data-cat="all">All</button>
|
||||||
|
{% for category in categories %}
|
||||||
|
<button class="chip-sm" data-cat="{{ category }}">{{ category }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="float-card locate-card">
|
||||||
|
{% if not compact %}
|
||||||
|
<form id="locationSearchForm">
|
||||||
|
<label for="locationSearch" style="display:none;">Search a place</label>
|
||||||
|
<input id="locationSearch" type="text" placeholder="Search a place…" autocomplete="off">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Center map on this place">Go</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<button class="locate-btn" id="locateBtn" type="button">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>
|
||||||
|
Use my location
|
||||||
|
</button>
|
||||||
|
{% if not compact %}
|
||||||
|
<label class="range-label">
|
||||||
|
Range
|
||||||
|
<select id="rangeSelect">
|
||||||
|
{% for km in rangeOptions %}
|
||||||
|
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<p class="locate-status" id="locateStatus">{{ compact ? 'Showing events near Nordrhein-Westfalen' : 'Showing events within ' ~ rangeKm ~ ' km' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not compact %}
|
||||||
|
<div class="legend float-card" id="legend"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<aside class="panel board" aria-label="Events">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="eyebrow" style="color:var(--board-muted);">{{ compact ? 'Nearest to you' : 'Events' }}</span>
|
||||||
|
<span class="count" id="resultCount">— events</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-list" id="panelList"></div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{#
|
||||||
|
Compact pager: first / previous / current / next / last — deliberately
|
||||||
|
not a full list of page numbers, since result sets here can run into
|
||||||
|
the hundreds of pages.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
currentPage, totalPages: integers
|
||||||
|
routeName: the route to link back to
|
||||||
|
routeParams: all params to preserve *except* `page` (e.g. category,
|
||||||
|
q, when, lat, lon, range)
|
||||||
|
#}
|
||||||
|
{% if totalPages > 1 %}
|
||||||
|
<nav class="pager" aria-label="Pagination">
|
||||||
|
{% if currentPage > 1 %}
|
||||||
|
<a href="{{ path(routeName, routeParams|merge({page: 1})) }}" aria-label="First page">« First</a>
|
||||||
|
<a href="{{ path(routeName, routeParams|merge({page: currentPage - 1})) }}" aria-label="Previous page">‹ Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="is-active">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||||
|
{% if currentPage < totalPages %}
|
||||||
|
<a href="{{ path(routeName, routeParams|merge({page: currentPage + 1})) }}" aria-label="Next page">Next ›</a>
|
||||||
|
<a href="{{ path(routeName, routeParams|merge({page: totalPages})) }}" aria-label="Last page">Last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}VINDN — Find what's on{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ asset('styles.css') }}">
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#main" class="skip-link">Skip to content</a>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a href="{{ path('home') }}" class="brand" aria-label="VINDN home">
|
||||||
|
<img src="{{ asset('images/vindn_logo_adobe.svg') }}" alt="VINDN">
|
||||||
|
</a>
|
||||||
|
<nav class="main-nav" aria-label="Primary">
|
||||||
|
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
|
||||||
|
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
|
||||||
|
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
|
||||||
|
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
|
||||||
|
</nav>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<form class="nav-search" action="{{ path('search') }}" role="search">
|
||||||
|
<button type="submit" aria-label="Search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
</button>
|
||||||
|
<label for="navSearchInput" style="display:none;">Search events</label>
|
||||||
|
<input id="navSearchInput" type="text" name="q" placeholder="Search events…" autocomplete="off" value="{{ app.request.query.get('q') }}">
|
||||||
|
</form>
|
||||||
|
<button class="nav-burger" aria-label="Open menu" aria-expanded="false" aria-controls="mobileMenu"><span></span><span></span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-menu" id="mobileMenu" hidden>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
|
||||||
|
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
|
||||||
|
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
|
||||||
|
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
|
||||||
|
</nav>
|
||||||
|
<form class="mobile-search" action="{{ path('search') }}" role="search">
|
||||||
|
<label for="mobileSearchInput" style="display:none;">Search events</label>
|
||||||
|
<input id="mobileSearchInput" type="text" name="q" placeholder="Search events…" autocomplete="off" value="{{ app.request.query.get('q') }}">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
{% block footer %}
|
||||||
|
<div class="footer-bottom" style="border-top:none; margin-top:0; padding-top:0;">
|
||||||
|
<span>© 2026 VINDN</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="{{ asset('js/nav.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% set category = event.categoryNames|first %}
|
||||||
|
{% set image = event.primaryImage %}
|
||||||
|
<a class="event-card" href="{{ path('event_show', {id: event.id}) }}">
|
||||||
|
{% if image %}
|
||||||
|
<div class="glyph-tile" style="padding:0;">
|
||||||
|
<img src="{{ image.storagePath }}" alt="" loading="lazy" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover;">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="glyph-tile" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="body">
|
||||||
|
{% if category %}
|
||||||
|
<span class="tag" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">{{ category }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<h3>{{ event.title }}</h3>
|
||||||
|
<p class="when">{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i') : 'Date TBD' }}</p>
|
||||||
|
<p class="place">{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Browse events — VINDN{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.toolbar{ display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:20px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<section class="section" style="padding-bottom:0;">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="eyebrow" style="margin-bottom:10px;">All events</p>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h1 style="font-size:clamp(28px,4vw,40px);">What's on{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||||||
|
<p class="mono" style="font-size:13px; color:var(--muted);">{{ total }} event{{ total == 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" id="catsToggle" class="chip-toggle-checkbox">
|
||||||
|
<div class="chip-row chip-row-collapsible">
|
||||||
|
<a href="{{ path('event_list') }}" class="chip {{ activeCategory is null ? 'is-active' : '' }}">All</a>
|
||||||
|
{% for category in categories %}
|
||||||
|
<a href="{{ path('event_list', {category: category}) }}" class="chip {{ activeCategory == category ? 'is-active' : '' }}">{{ category }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<label for="catsToggle" class="chip-toggle-btn" style="margin-bottom:32px;">
|
||||||
|
<span class="show-more">Show all categories ↑</span>
|
||||||
|
<span class="show-less">Show fewer categories ↑</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" style="padding-top:0;">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for event in events %}
|
||||||
|
{% include 'events/_card.html.twig' with { event: event } %}
|
||||||
|
{% else %}
|
||||||
|
<div class="no-results" style="grid-column:1/-1; padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card);">
|
||||||
|
<p class="eyebrow" style="margin-bottom:8px;">No events</p>
|
||||||
|
<p>Nothing matches this filter yet — try "All" or check back once the importer has run.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include '_pager.html.twig' with {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: totalPages,
|
||||||
|
routeName: 'event_list',
|
||||||
|
routeParams: { category: activeCategory }
|
||||||
|
} %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% set category = event.categoryNames|first %}
|
||||||
|
{% set image = event.primaryImage %}
|
||||||
|
|
||||||
|
{% block title %}{{ event.title }} — VINDN{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
{% include '_leaflet_head.html.twig' %}
|
||||||
|
<style>
|
||||||
|
.breadcrumb{ font-family:var(--f-mono); font-size:12px; color:var(--muted); margin-bottom:18px; }
|
||||||
|
.breadcrumb a:hover{ color:var(--accent); }
|
||||||
|
.hero-tile{
|
||||||
|
aspect-ratio: 16/6; border-radius:var(--r-card); position:relative; overflow:hidden;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.hero-tile img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
||||||
|
.hero-tile svg{ width:18%; opacity:.9; }
|
||||||
|
.detail-grid{ display:grid; grid-template-columns: 1fr 360px; gap:44px; margin-top:36px; align-items:start; }
|
||||||
|
.detail-grid h2{ font-size:22px; margin-bottom:14px; }
|
||||||
|
.prose p{ color:var(--ink-soft); margin:0 0 16px; font-size:15.5px; line-height:1.7; }
|
||||||
|
.info-board{ background:var(--board-bg); border-radius:var(--r-card); color:var(--board-text); padding:24px; position:sticky; top:96px; }
|
||||||
|
.info-board .row{ display:flex; justify-content:space-between; gap:12px; padding:13px 0; border-bottom:1px solid #2B2B22; font-size:13px; }
|
||||||
|
.info-board .row:first-child{ padding-top:0; }
|
||||||
|
.info-board .row:last-child{ border-bottom:none; }
|
||||||
|
.info-board .row .k{ font-family:var(--f-mono); color:var(--board-muted); flex:none; }
|
||||||
|
.info-board .row .v{ font-family:var(--f-mono); text-align:right; }
|
||||||
|
.info-board .row .v a{ color:inherit; }
|
||||||
|
#eventMapCanvas{ aspect-ratio:16/8; border-radius:var(--r-card); }
|
||||||
|
@media (max-width:900px){ .detail-grid{ grid-template-columns:1fr; } .info-board{ position:static; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<p class="breadcrumb">
|
||||||
|
<a href="{{ path('event_list') }}">Browse events</a>
|
||||||
|
{% if category %} / <a href="{{ path('event_list', {category: category}) }}">{{ category }}</a>{% endif %}
|
||||||
|
/ {{ event.title }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hero-tile" style="background:{{ category|category_bg }};">
|
||||||
|
{% if image %}
|
||||||
|
<img src="{{ image.storagePath }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="{{ category|category_color }}" stroke-width="1.4"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-top:28px; flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
{% if category %}<span class="tag" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">{{ category }}</span>{% endif %}
|
||||||
|
<h1 style="font-size:clamp(28px,4vw,42px); margin-top:14px; max-width:640px;">{{ event.title }}</h1>
|
||||||
|
<p class="mono" style="color:var(--muted); margin-top:10px; font-size:13px;">
|
||||||
|
{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i')|upper : 'DATE TBD' }}
|
||||||
|
{% if event.city %} · {{ event.city|upper }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div>
|
||||||
|
{% if event.description %}
|
||||||
|
<div class="prose">
|
||||||
|
<h2>About this event</h2>
|
||||||
|
<p>{{ event.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% elseif event.teaser %}
|
||||||
|
<div class="prose">
|
||||||
|
<h2>About this event</h2>
|
||||||
|
<p>{{ event.teaser }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if event.hasCoordinates %}
|
||||||
|
<h2 style="margin-top:36px;">Location</h2>
|
||||||
|
<div id="eventMapCanvas" role="application" aria-label="Map showing the event location"></div>
|
||||||
|
{% endif %}
|
||||||
|
<p style="margin-top:14px; font-size:14px; color:var(--muted);">
|
||||||
|
{{ [event.street, (event.zip ~ ' ' ~ event.city)|trim, event.country]|filter(v => v)|join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- sticky info board -->
|
||||||
|
<aside class="info-board" aria-label="Event details">
|
||||||
|
<div class="row"><span class="k">Starts</span><span class="v">{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i') : 'TBD' }}</span></div>
|
||||||
|
{% if event.endDateTime %}
|
||||||
|
<div class="row"><span class="k">Ends</span><span class="v">{{ event.endDateTime|date('D, M j · H:i') }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if event.city %}<div class="row"><span class="k">City</span><span class="v">{{ event.city }}</span></div>{% endif %}
|
||||||
|
{% if event.type %}<div class="row"><span class="k">Type</span><span class="v">{{ event.type }}</span></div>{% endif %}
|
||||||
|
{% if event.phone %}<div class="row"><span class="k">Phone</span><span class="v"><a href="tel:{{ event.phone }}">{{ event.phone }}</a></span></div>{% endif %}
|
||||||
|
{% if event.email %}<div class="row"><span class="k">Email</span><span class="v"><a href="mailto:{{ event.email }}">{{ event.email }}</a></span></div>{% endif %}
|
||||||
|
{% if event.web %}
|
||||||
|
<div class="row" style="border-bottom:none;"><span class="k">Website</span><span class="v"><a href="{{ event.web }}" target="_blank" rel="noopener">Visit site →</a></span></div>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if event.hasCoordinates %}
|
||||||
|
{% include '_leaflet_scripts.html.twig' %}
|
||||||
|
<script type="application/json" id="event-map-data">{{ mapEventJson|raw }}</script>
|
||||||
|
<script>
|
||||||
|
Vindn.renderPinMap('eventMapCanvas', Vindn.readEmbeddedJson('event-map-data'), { zoom: 14, zoomControl: true, attributionControl: true });
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}VINDN — Find what's on{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
{% include '_leaflet_head.html.twig' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<!-- MAP HERO — light: nearest ~20 events, refines once you share your location -->
|
||||||
|
<section class="section" style="padding:0;">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="eyebrow" style="margin-bottom:8px;">Nordrhein-Westfalen — updated every hour</p>
|
||||||
|
<div class="section-head">
|
||||||
|
<h1 style="font-size:clamp(28px,4vw,40px);">What's on near you</h1>
|
||||||
|
<a href="{{ path('map') }}" class="btn btn-primary btn-sm">Open full map →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section" style="padding:0;">
|
||||||
|
{% include '_map_stage.html.twig' with { compact: true } %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BROWSE EVENTS -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" style="margin-bottom:8px;">Browse</p>
|
||||||
|
<h2>Find events by category</h2>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row">
|
||||||
|
{% for category in popularCategories %}
|
||||||
|
<a href="{{ path('event_list', {category: category}) }}" class="chip">{{ category }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- THIS WEEK -->
|
||||||
|
<section class="section" style="background:var(--paper-dim);">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" style="margin-bottom:8px;">Live from the importer</p>
|
||||||
|
<h2>Worth planning around</h2>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for event in upcomingEvents %}
|
||||||
|
{% include 'events/_card.html.twig' with { event: event } %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--muted);">No events imported yet — check back once the backend's import job has run.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div>
|
||||||
|
<img src="{{ asset('images/vindn_logo_adobe.svg') }}" alt="VINDN" style="height:24px; margin-bottom:14px;">
|
||||||
|
<p style="max-width:260px; font-size:14px; color:var(--muted);">The board for everything on, wherever you are.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Explore</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ path('event_list') }}">Browse events</a></li>
|
||||||
|
<li><a href="{{ path('search') }}">Search</a></li>
|
||||||
|
<li><a href="{{ path('map') }}">Map</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Organizers</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#">List an event</a></li>
|
||||||
|
<li><a href="#">Pricing</a></li>
|
||||||
|
<li><a href="#">Guidelines</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Company</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#">About</a></li>
|
||||||
|
<li><a href="#">Contact</a></li>
|
||||||
|
<li><a href="#">Privacy</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<span>© 2026 VINDN</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include '_leaflet_scripts.html.twig' %}
|
||||||
|
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
|
||||||
|
<script>
|
||||||
|
Vindn.initMapStage({
|
||||||
|
events: Vindn.readEmbeddedJson('map-data'),
|
||||||
|
limit: 20,
|
||||||
|
defaultCenter: [{{ centerLat }}, {{ centerLon }}]
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Map — VINDN{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
{% include '_leaflet_head.html.twig' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% include '_map_stage.html.twig' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include '_leaflet_scripts.html.twig' %}
|
||||||
|
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
|
||||||
|
<script>
|
||||||
|
Vindn.initRadiusMapStage({
|
||||||
|
events: Vindn.readEmbeddedJson('map-data'),
|
||||||
|
center: [{{ centerLat }}, {{ centerLon }}],
|
||||||
|
rangeKm: {{ rangeKm }},
|
||||||
|
maxRangeKm: {{ maxRangeKm }}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Search — VINDN{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.search-shell{ display:grid; grid-template-columns: 260px 1fr; gap:36px; align-items:start; }
|
||||||
|
.filter-group{ padding-bottom:22px; margin-bottom:22px; border-bottom:1px solid var(--line); }
|
||||||
|
.filter-group:last-child{ border-bottom:none; }
|
||||||
|
.filter-group h4{ font-family:var(--f-mono); font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 14px; }
|
||||||
|
.filter-option{ display:flex; align-items:center; gap:10px; font-size:14px; padding:6px 0; cursor:pointer; }
|
||||||
|
.filter-option input{ accent-color: var(--accent); width:16px; height:16px; }
|
||||||
|
.results-toolbar{ display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; flex-wrap:wrap; gap:12px; }
|
||||||
|
.search-input-bar{ display:flex; gap:10px; margin-bottom:28px; }
|
||||||
|
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
|
||||||
|
.no-results{ padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card); }
|
||||||
|
#locationSearchForm{ display:flex; gap:6px; margin-bottom:10px; }
|
||||||
|
#locationSearchForm input{ flex:1; min-width:0; padding:9px 10px; border:1px solid var(--line-strong); border-radius:6px; font-size:13px; }
|
||||||
|
.range-row{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; font-size:13px; }
|
||||||
|
.range-row select{ padding:6px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; }
|
||||||
|
#locationStatus{ font-family:var(--f-mono); font-size:11px; color:var(--muted); margin-top:8px; }
|
||||||
|
@media (max-width: 860px){ .search-shell{ grid-template-columns:1fr; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<section class="section" style="padding-bottom:0;">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="eyebrow" style="margin-bottom:10px;">Search</p>
|
||||||
|
<h1 style="font-size:clamp(28px,4vw,40px); margin-bottom:24px;">
|
||||||
|
{{ q ? 'Searching: "' ~ q ~ '"' : 'Search events' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form class="search-input-bar" method="get" action="{{ path('search') }}">
|
||||||
|
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% endif %}
|
||||||
|
{% if hasLocation %}
|
||||||
|
<input type="hidden" name="lat" value="{{ lat }}">
|
||||||
|
<input type="hidden" name="lon" value="{{ lon }}">
|
||||||
|
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||||
|
{% endif %}
|
||||||
|
<label for="q" style="display:none;">Search events</label>
|
||||||
|
<input id="q" name="q" type="text" value="{{ q }}" placeholder="Search by event, venue or city…">
|
||||||
|
<button class="btn btn-primary" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" style="padding-top:0;">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="search-shell">
|
||||||
|
|
||||||
|
<!-- FILTERS -->
|
||||||
|
<aside aria-label="Filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<h4>Near a location</h4>
|
||||||
|
<form id="locationSearchForm">
|
||||||
|
<label for="locationSearchInput" style="display:none;">Search a place</label>
|
||||||
|
<input id="locationSearchInput" type="text" placeholder="Search a place…" autocomplete="off">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Search near this place">Go</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn" style="width:100%; justify-content:center;">Use my location</button>
|
||||||
|
{% if hasLocation %}
|
||||||
|
<div class="range-row">
|
||||||
|
<span>Within</span>
|
||||||
|
<select id="rangeSelect">
|
||||||
|
{% for km in rangeOptions %}
|
||||||
|
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p id="locationStatus">
|
||||||
|
Near {{ lat|number_format(3) }}, {{ lon|number_format(3) }} —
|
||||||
|
<a href="{{ path('search', {q: q ?: null, category: activeCategory, when: when != 'any' ? when : null}) }}" style="color:var(--accent); text-decoration:underline;">clear</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p id="locationStatus"></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="{{ path('search') }}" id="filterForm">
|
||||||
|
<input type="hidden" name="q" value="{{ q }}">
|
||||||
|
{% if hasLocation %}
|
||||||
|
<input type="hidden" name="lat" value="{{ lat }}">
|
||||||
|
<input type="hidden" name="lon" value="{{ lon }}">
|
||||||
|
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="filter-group">
|
||||||
|
<h4>Category</h4>
|
||||||
|
<label class="filter-option">
|
||||||
|
<input type="radio" name="category" value="" {{ activeCategory ? '' : 'checked' }} onchange="this.form.submit()"> All
|
||||||
|
</label>
|
||||||
|
{% for category in categories %}
|
||||||
|
<label class="filter-option">
|
||||||
|
<input type="radio" name="category" value="{{ category }}" {{ activeCategory == category ? 'checked' : '' }} onchange="this.form.submit()"> {{ category }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<h4>When</h4>
|
||||||
|
<label class="filter-option"><input type="radio" name="when" value="any" {{ when == 'any' ? 'checked' : '' }} onchange="this.form.submit()"> Any date</label>
|
||||||
|
<label class="filter-option"><input type="radio" name="when" value="today" {{ when == 'today' ? 'checked' : '' }} onchange="this.form.submit()"> Today</label>
|
||||||
|
<label class="filter-option"><input type="radio" name="when" value="week" {{ when == 'week' ? 'checked' : '' }} onchange="this.form.submit()"> Next 7 days</label>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path('search') }}" class="btn btn-ghost btn-sm" style="width:100%; justify-content:center;">Clear filters</a>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- RESULTS -->
|
||||||
|
<div>
|
||||||
|
<div class="results-toolbar">
|
||||||
|
<p class="mono" style="font-size:13px; color:var(--muted);">
|
||||||
|
{{ total }} EVENT{{ total == 1 ? '' : 'S' }} MATCH{{ hasLocation ? ' · SORTED BY DISTANCE' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if events|length %}
|
||||||
|
<div class="board" aria-label="Search results">
|
||||||
|
{% for event in events %}
|
||||||
|
<a class="board-row" href="{{ path('event_show', {id: event.id}) }}" aria-label="View {{ event.title }}">
|
||||||
|
<div class="board-date">
|
||||||
|
{{ event.startDateTime ? event.startDateTime|date('M j') : '—' }}
|
||||||
|
<span class="dow">{{ event.startDateTime ? event.startDateTime|date('D') : 'TBD' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="board-name">
|
||||||
|
{{ event.title }}
|
||||||
|
<span class="where">
|
||||||
|
{{ event.city }}{% if event.categoryNames|first %} · {{ event.categoryNames|first }}{% endif %}
|
||||||
|
{% if hasLocation and distances[event.id] is defined %}
|
||||||
|
{% set dist = distances[event.id] %}
|
||||||
|
· {{ dist < 1 ? (dist * 1000)|round ~ ' m' : (dist < 10 ? dist|round(1) : dist|round) ~ ' km' }} away
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="board-status">{{ event.startDateTime ? event.startDateTime|date('H:i') : '' }}</div>
|
||||||
|
<div class="board-go">→</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-results">
|
||||||
|
<p class="eyebrow" style="margin-bottom:8px;">No matches</p>
|
||||||
|
<p>Nothing fits those filters yet. Try clearing a filter or a different search term.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% include '_pager.html.twig' with {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: totalPages,
|
||||||
|
routeName: 'search',
|
||||||
|
routeParams: {
|
||||||
|
q: q ?: null,
|
||||||
|
category: activeCategory,
|
||||||
|
when: when != 'any' ? when : null,
|
||||||
|
lat: hasLocation ? lat : null,
|
||||||
|
lon: hasLocation ? lon : null,
|
||||||
|
range: hasLocation ? rangeKm : null
|
||||||
|
}
|
||||||
|
} %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var currentLat = {{ hasLocation ? lat : 'null' }};
|
||||||
|
var currentLon = {{ hasLocation ? lon : 'null' }};
|
||||||
|
|
||||||
|
function navigateWithLocation(lat, lon, rangeKm) {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('lat', lat.toFixed(5));
|
||||||
|
url.searchParams.set('lon', lon.toFixed(5));
|
||||||
|
url.searchParams.set('range', rangeKm || url.searchParams.get('range') || 25);
|
||||||
|
url.searchParams.delete('page'); // the result set changed, page N may no longer exist
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationForm = document.getElementById('locationSearchForm');
|
||||||
|
if (locationForm) {
|
||||||
|
locationForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var input = document.getElementById('locationSearchInput');
|
||||||
|
var query = (input.value || '').trim();
|
||||||
|
if (!query) return;
|
||||||
|
var statusEl = document.getElementById('locationStatus');
|
||||||
|
if (statusEl) statusEl.textContent = 'Looking up "' + query + '"…';
|
||||||
|
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(query))
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (results) {
|
||||||
|
if (!results || !results.length) {
|
||||||
|
if (statusEl) statusEl.textContent = 'Could not find "' + query + '"';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateWithLocation(parseFloat(results[0].lat), parseFloat(results[0].lon));
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (statusEl) statusEl.textContent = 'Location search failed';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
|
||||||
|
if (useMyLocationBtn) {
|
||||||
|
useMyLocationBtn.addEventListener('click', function () {
|
||||||
|
var statusEl = document.getElementById('locationStatus');
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
if (statusEl) statusEl.textContent = 'Location not supported by your browser';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusEl) statusEl.textContent = 'Finding you…';
|
||||||
|
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||||
|
navigateWithLocation(pos.coords.latitude, pos.coords.longitude);
|
||||||
|
}, function () {
|
||||||
|
if (statusEl) statusEl.textContent = "Couldn't get your location";
|
||||||
|
}, { timeout: 8000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var rangeSelect = document.getElementById('rangeSelect');
|
||||||
|
if (rangeSelect) {
|
||||||
|
rangeSelect.addEventListener('change', function () {
|
||||||
|
if (currentLat === null || currentLon === null) return;
|
||||||
|
navigateWithLocation(currentLat, currentLon, parseInt(rangeSelect.value, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user