adds list search
This commit is contained in:
3
.github/workflows/release.yaml
vendored
3
.github/workflows/release.yaml
vendored
@@ -78,9 +78,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.versioning.outputs.version }}
|
VERSION: ${{ needs.versioning.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
# Build search image
|
|
||||||
docker buildx build search/ --no-cache -t flomp/wanderer-search:$VERSION -t flomp/wanderer-search:latest --platform=linux/amd64,linux/arm64 --push
|
|
||||||
|
|
||||||
# Build db image
|
# Build db image
|
||||||
cd db
|
cd db
|
||||||
env GOOS=linux GOARCH=arm64 go build -o pocketbase_arm64
|
env GOOS=linux GOARCH=arm64 go build -o pocketbase_arm64
|
||||||
|
|||||||
106
db/main.go
106
db/main.go
@@ -61,9 +61,13 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
|
|||||||
app.OnRecordAfterCreateRequest("trail_share").Add(createTrailShareHandler(app, client))
|
app.OnRecordAfterCreateRequest("trail_share").Add(createTrailShareHandler(app, client))
|
||||||
app.OnRecordAfterDeleteRequest("trail_share").Add(deleteTrailShareHandler(client))
|
app.OnRecordAfterDeleteRequest("trail_share").Add(deleteTrailShareHandler(client))
|
||||||
|
|
||||||
app.OnRecordAfterCreateRequest("list_share").Add(createListShareHandler(app))
|
app.OnRecordAfterCreateRequest("lists").Add(createListHandler(app, client))
|
||||||
|
app.OnRecordAfterUpdateRequest("lists").Add(updateListHandler(client))
|
||||||
|
app.OnRecordAfterDeleteRequest("lists").Add(deleteListHandler(client))
|
||||||
|
|
||||||
|
app.OnRecordAfterCreateRequest("list_share").Add(createListShareHandler(app, client))
|
||||||
|
app.OnRecordAfterDeleteRequest("list_share").Add(deleteListShareHandler(client))
|
||||||
|
|
||||||
app.OnRecordAfterCreateRequest("lists").Add(createListHandler(app))
|
|
||||||
app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app))
|
app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app))
|
||||||
app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app))
|
app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app))
|
||||||
|
|
||||||
@@ -77,7 +81,9 @@ func createUserHandler(app *pocketbase.PocketBase, client meilisearch.ServiceMan
|
|||||||
userId := record.GetId()
|
userId := record.GetId()
|
||||||
|
|
||||||
searchRules := map[string]interface{}{
|
searchRules := map[string]interface{}{
|
||||||
"cities500": map[string]string{},
|
"lists": map[string]string{
|
||||||
|
"filter": "public = true OR author = " + userId + " OR shares = " + userId,
|
||||||
|
},
|
||||||
"trails": map[string]string{
|
"trails": map[string]string{
|
||||||
"filter": "public = true OR author = " + userId + " OR shares = " + userId,
|
"filter": "public = true OR author = " + userId + " OR shares = " + userId,
|
||||||
},
|
},
|
||||||
@@ -156,7 +162,11 @@ func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.Serv
|
|||||||
for i, r := range shares {
|
for i, r := range shares {
|
||||||
userIds[i] = r.GetString("user")
|
userIds[i] = r.GetString("user")
|
||||||
}
|
}
|
||||||
util.UpdateTrailShares(trailId, userIds, client)
|
err = util.UpdateTrailShares(trailId, userIds, client)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "trail.author"}, nil); len(errs) > 0 {
|
if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "trail.author"}, nil); len(errs) > 0 {
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
return fmt.Errorf("failed to expand: %v", errs)
|
||||||
@@ -178,8 +188,66 @@ func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.Serv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createListShareHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
|
func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
|
||||||
|
return func(e *core.RecordDeleteEvent) error {
|
||||||
|
trailId := e.Record.GetString("trail")
|
||||||
|
return util.UpdateTrailShares(trailId, []string{}, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createListHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error {
|
||||||
return func(e *core.RecordCreateEvent) error {
|
return func(e *core.RecordCreateEvent) error {
|
||||||
|
if err := util.IndexList(e.Record, client); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !e.Record.GetBool("public") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
notification := util.Notification{
|
||||||
|
Type: util.ListCreate,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"id": e.Record.Id,
|
||||||
|
"list": e.Record.GetString("name"),
|
||||||
|
},
|
||||||
|
Seen: false,
|
||||||
|
Author: e.Record.GetString("author"),
|
||||||
|
}
|
||||||
|
return util.SendNotificationToFollowers(app, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateListHandler(client meilisearch.ServiceManager) func(e *core.RecordUpdateEvent) error {
|
||||||
|
return func(e *core.RecordUpdateEvent) error {
|
||||||
|
return util.UpdateList(e.Record, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteListHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
|
||||||
|
return func(e *core.RecordDeleteEvent) error {
|
||||||
|
_, err := client.Index("lists").DeleteDocument(e.Record.Id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createListShareHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error {
|
||||||
|
return func(e *core.RecordCreateEvent) error {
|
||||||
|
listId := e.Record.GetString("list")
|
||||||
|
shares, err := app.Dao().FindRecordsByExpr("list_share",
|
||||||
|
dbx.NewExp("list = {:listId}", dbx.Params{"listId": listId}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userIds := make([]string, len(shares))
|
||||||
|
for i, r := range shares {
|
||||||
|
userIds[i] = r.GetString("user")
|
||||||
|
}
|
||||||
|
err = util.UpdateListShares(listId, userIds, client)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if errs := app.Dao().ExpandRecord(e.Record, []string{"list", "list.author"}, nil); len(errs) > 0 {
|
if errs := app.Dao().ExpandRecord(e.Record, []string{"list", "list.author"}, nil); len(errs) > 0 {
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
return fmt.Errorf("failed to expand: %v", errs)
|
||||||
}
|
}
|
||||||
@@ -200,28 +268,10 @@ func createListShareHandler(app *pocketbase.PocketBase) func(e *core.RecordCreat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
|
func deleteListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
|
||||||
return func(e *core.RecordDeleteEvent) error {
|
return func(e *core.RecordDeleteEvent) error {
|
||||||
trailId := e.Record.GetString("trail")
|
listId := e.Record.GetString("list")
|
||||||
return util.UpdateTrailShares(trailId, []string{}, client)
|
return util.UpdateListShares(listId, []string{}, client)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createListHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
|
|
||||||
return func(e *core.RecordCreateEvent) error {
|
|
||||||
if !e.Record.GetBool("public") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
notification := util.Notification{
|
|
||||||
Type: util.ListCreate,
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"id": e.Record.Id,
|
|
||||||
"list": e.Record.GetString("name"),
|
|
||||||
},
|
|
||||||
Seen: false,
|
|
||||||
Author: e.Record.GetString("author"),
|
|
||||||
}
|
|
||||||
return util.SendNotificationToFollowers(app, notification)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +343,9 @@ func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.Service
|
|||||||
func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
|
func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
|
||||||
e.Router.GET("/public/search/token", func(c echo.Context) error {
|
e.Router.GET("/public/search/token", func(c echo.Context) error {
|
||||||
searchRules := map[string]interface{}{
|
searchRules := map[string]interface{}{
|
||||||
"cities500": map[string]string{},
|
"lists": map[string]string{
|
||||||
|
"filter": "public = true",
|
||||||
|
},
|
||||||
"trails": map[string]string{
|
"trails": map[string]string{
|
||||||
"filter": "public = true",
|
"filter": "public = true",
|
||||||
},
|
},
|
||||||
|
|||||||
117
db/migrations/1737819003_meilisearch_add_lists_index.go
Normal file
117
db/migrations/1737819003_meilisearch_add_lists_index.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"pocketbase/util"
|
||||||
|
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
client := meilisearch.New(os.Getenv("MEILI_URL"), meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")))
|
||||||
|
|
||||||
|
m.Register(func(db dbx.Builder) error {
|
||||||
|
|
||||||
|
_, err := client.CreateIndex(&meilisearch.IndexConfig{
|
||||||
|
Uid: "lists",
|
||||||
|
PrimaryKey: "id",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Index("lists").UpdateSortableAttributes(&[]string{
|
||||||
|
"created", "name",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Index("lists").UpdateFilterableAttributes(&[]string{
|
||||||
|
"author", "public", "shares",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dao := daos.New(db)
|
||||||
|
|
||||||
|
lists, err := dao.FindRecordsByExpr("lists", dbx.NewExp("true"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range lists {
|
||||||
|
err = util.IndexList(l, client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
shares, err := dao.FindRecordsByExpr("list_share",
|
||||||
|
dbx.NewExp("list = {:listId}", dbx.Params{"listId": l.Id}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userIds := make([]string, len(shares))
|
||||||
|
for i, r := range shares {
|
||||||
|
userIds[i] = r.GetString("user")
|
||||||
|
}
|
||||||
|
err = util.UpdateListShares(l.Id, userIds, client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var usernames []string
|
||||||
|
err = db.NewQuery("SELECT username FROM users").Column(&usernames)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, username := range usernames {
|
||||||
|
|
||||||
|
record, err := dao.FindAuthRecordByUsername("users", username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchRules := map[string]interface{}{
|
||||||
|
"lists": map[string]string{
|
||||||
|
"filter": "public = true OR author = " + record.Id + " OR shares = " + record.Id,
|
||||||
|
},
|
||||||
|
"trails": map[string]string{
|
||||||
|
"filter": "public = true OR author = " + record.Id + " OR shares = " + record.Id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
record.Set("token", token)
|
||||||
|
|
||||||
|
if err := dao.SaveRecord(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.DeleteIndex("cities500")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, func(db dbx.Builder) error {
|
||||||
|
_, err := client.DeleteIndex("lists")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,6 +38,24 @@ func documentFromTrailRecord(r *models.Record, includeShares bool) map[string]in
|
|||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func documentFromListRecord(r *models.Record, includeShares bool) map[string]interface{} {
|
||||||
|
document := map[string]interface{}{
|
||||||
|
"id": r.Id,
|
||||||
|
"author": r.GetString("author"),
|
||||||
|
"name": r.GetString("name"),
|
||||||
|
"description": r.GetString("description"),
|
||||||
|
"public": r.GetBool("public"),
|
||||||
|
"created": r.GetDateTime("created").Time().Unix(),
|
||||||
|
"trails": r.GetStringSlice("trails"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeShares {
|
||||||
|
document["shares"] = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
func IndexTrail(r *models.Record, client meilisearch.ServiceManager) error {
|
func IndexTrail(r *models.Record, client meilisearch.ServiceManager) error {
|
||||||
documents := []map[string]interface{}{documentFromTrailRecord(r, true)}
|
documents := []map[string]interface{}{documentFromTrailRecord(r, true)}
|
||||||
|
|
||||||
@@ -71,6 +89,39 @@ func UpdateTrailShares(trailId string, shares []string, client meilisearch.Servi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IndexList(r *models.Record, client meilisearch.ServiceManager) error {
|
||||||
|
documents := []map[string]interface{}{documentFromListRecord(r, true)}
|
||||||
|
|
||||||
|
if _, err := client.Index("lists").AddDocuments(documents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateList(r *models.Record, client meilisearch.ServiceManager) error {
|
||||||
|
documents := documentFromListRecord(r, false)
|
||||||
|
|
||||||
|
if _, err := client.Index("lists").UpdateDocuments(documents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateListShares(listId string, shares []string, client meilisearch.ServiceManager) error {
|
||||||
|
documents := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": listId,
|
||||||
|
"shares": shares,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := client.Index("lists").UpdateDocuments(documents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func GenerateMeilisearchToken(rules map[string]interface{}, client meilisearch.ServiceManager) (resp string, err error) {
|
func GenerateMeilisearchToken(rules map[string]interface{}, client meilisearch.ServiceManager) (resp string, err error) {
|
||||||
apiKeyUid := ""
|
apiKeyUid := ""
|
||||||
apiKey := ""
|
apiKey := ""
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ services:
|
|||||||
UPLOAD_USER:
|
UPLOAD_USER:
|
||||||
UPLOAD_PASSWORD:
|
UPLOAD_PASSWORD:
|
||||||
PUBLIC_VALHALLA_URL: https://valhalla1.openstreetmap.de
|
PUBLIC_VALHALLA_URL: https://valhalla1.openstreetmap.de
|
||||||
|
PUBLIC_NOMINATIM_URL: https://nominatim.openstreetmap.org
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/uploads:/app/uploads
|
- ./data/uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Since we use an unmodified installation of meilisearch you can use all variables
|
|||||||
| PUBLIC_POCKETBASE_URL | IP or hostname (including the port) of your wanderer instance | http://db:8090 |
|
| PUBLIC_POCKETBASE_URL | IP or hostname (including the port) of your wanderer instance | http://db:8090 |
|
||||||
| PUBLIC_DISABLE_SIGNUP | Disables signup option for new users | false |
|
| PUBLIC_DISABLE_SIGNUP | Disables signup option for new users | false |
|
||||||
| PUBLIC_VALHALLA_URL | Public IP or hostname (including the port) of a valhalla instance | https://valhalla1.openstreetmap.de |
|
| PUBLIC_VALHALLA_URL | Public IP or hostname (including the port) of a valhalla instance | https://valhalla1.openstreetmap.de |
|
||||||
|
| PUBLIC_NOMINATIM_URL | Public IP or hostname (including the port) of a nominatim instance | https://nominatim.openstreetmap.org|
|
||||||
| UPLOAD_FOLDER | Folder from which wanderer auto-uploads trails | /app/uploads |
|
| UPLOAD_FOLDER | Folder from which wanderer auto-uploads trails | /app/uploads |
|
||||||
| UPLOAD_USER | Username for the account with which wanderer auto-uploads trails | |
|
| UPLOAD_USER | Username for the account with which wanderer auto-uploads trails | |
|
||||||
| UPLOAD_PASSWORD | Password for the account with which wanderer auto-uploads trails | |
|
| UPLOAD_PASSWORD | Password for the account with which wanderer auto-uploads trails | |
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ services:
|
|||||||
UPLOAD_USER:
|
UPLOAD_USER:
|
||||||
UPLOAD_PASSWORD:
|
UPLOAD_PASSWORD:
|
||||||
PUBLIC_VALHALLA_URL: https://valhalla1.openstreetmap.de
|
PUBLIC_VALHALLA_URL: https://valhalla1.openstreetmap.de
|
||||||
|
PUBLIC_NOMINATIM_URL: https://nominatim.openstreetmap.org
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/uploads:/app/uploads
|
- ./data/uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
@@ -190,7 +191,3 @@ To update wanderer to the newest version simply run `git pull origin main` and r
|
|||||||
## Verify the installation
|
## Verify the installation
|
||||||
No matter which installation method you chose, you should now be able to access wanderer on localhost:3000.
|
No matter which installation method you chose, you should now be able to access wanderer on localhost:3000.
|
||||||
|
|
||||||
:::note
|
|
||||||
On the first launch, wanderer will create a rather large city index with over 200,000 entries in meilisearch. This process happens automatically but can take up to 2 minutes to complete. During this time the search functionality might not yet work properly.
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
{#if dropDownOpen}
|
{#if dropDownOpen}
|
||||||
<ul
|
<ul
|
||||||
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-hidden w-full"
|
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-x-hidden overflow-y-scroll max-h-72 w-full"
|
||||||
class:none={!dropDownOpen}
|
class:none={!dropDownOpen}
|
||||||
style="z-index: 1001"
|
style="z-index: 1001"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
{ text: "Home", value: "/" },
|
{ text: "Home", value: "/" },
|
||||||
{ text: $_("trail", { values: { n: 2 } }), value: "/trails" },
|
{ text: $_("trail", { values: { n: 2 } }), value: "/trails" },
|
||||||
{ text: $_("map"), value: "/map" },
|
{ text: $_("map"), value: "/map" },
|
||||||
|
{ text: $_("list", { values: { n: 2 } }), value: "/lists" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
@@ -112,11 +113,6 @@
|
|||||||
{#each navBarItems as item}
|
{#each navBarItems as item}
|
||||||
<a class="font-semibold text-xl" href={item.value}>{item.text}</a>
|
<a class="font-semibold text-xl" href={item.value}>{item.text}</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if $currentUser}
|
|
||||||
<a class="font-semibold text-xl" href="/lists"
|
|
||||||
>{$_("list", { values: { n: 2 } })}</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<hr class="my-6 border-input-border" />
|
<hr class="my-6 border-input-border" />
|
||||||
<div class="flex flex-col basis-full">
|
<div class="flex flex-col basis-full">
|
||||||
@@ -176,11 +172,6 @@
|
|||||||
{#each navBarItems as item}
|
{#each navBarItems as item}
|
||||||
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
|
<a class="font-semibold z-10" href={item.value}>{item.text}</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if user}
|
|
||||||
<a class="font-semibold z-10" href="/lists"
|
|
||||||
>{$_("list", { values: { n: 2 } })}</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</menu>
|
</menu>
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="hidden lg:flex gap-6 items-center">
|
<div class="hidden lg:flex gap-6 items-center">
|
||||||
@@ -200,7 +191,6 @@
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
items={dropdownItems}
|
items={dropdownItems}
|
||||||
onchange={(item) => handleDropdownClick(item)}
|
onchange={(item) => handleDropdownClick(item)}
|
||||||
|
|
||||||
>
|
>
|
||||||
{#snippet children({ toggleMenu: openDropdown })}
|
{#snippet children({ toggleMenu: openDropdown })}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
"link-copied": "Link kopiert",
|
"link-copied": "Link kopiert",
|
||||||
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
||||||
"list-not-shared": "Mit niemandem geteilt",
|
"list-not-shared": "Mit niemandem geteilt",
|
||||||
"list-public-warning": "Alle routen in dieser Liste werden veröffentlicht.",
|
"list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.",
|
||||||
"list-saved-successfully": "Liste gespeichert",
|
"list-saved-successfully": "Liste gespeichert",
|
||||||
"list-share-warning": "Durch das Teilen einer Liste werden automatisch alle darin enthaltenen Routen freigegeben.",
|
"list-share-warning": "Durch das Teilen einer Liste werden automatisch alle darin enthaltenen Routen freigegeben.",
|
||||||
"list-share-warning-update": "Hinzugefügte Routen werden mit allen geteilt, die Zugriff auf diese Liste haben.",
|
"list-share-warning-update": "Hinzugefügte Routen werden mit allen geteilt, die Zugriff auf diese Liste haben.",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"save-trail": "Route speichern",
|
"save-trail": "Route speichern",
|
||||||
"save-your-trail-first": "Route zuerst speichern",
|
"save-your-trail-first": "Route zuerst speichern",
|
||||||
"search-cities": "Städte suchen",
|
"search-cities": "Städte suchen",
|
||||||
"search-for-trails-places": "Suche nach Routen, Orten",
|
"search-for-trails-places": "Suche nach Routen, Listen, Orten",
|
||||||
"search-places": "Orte suchen",
|
"search-places": "Orte suchen",
|
||||||
"search-trails": "Route suchen",
|
"search-trails": "Route suchen",
|
||||||
"select-list": "Liste auswählen",
|
"select-list": "Liste auswählen",
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
"save-trail": "Save Trail",
|
"save-trail": "Save Trail",
|
||||||
"save-your-trail-first": "Save your trail first",
|
"save-your-trail-first": "Save your trail first",
|
||||||
"search-cities": "Search cities",
|
"search-cities": "Search cities",
|
||||||
"search-for-trails-places": "Search for trails, places",
|
"search-for-trails-places": "Search for trails, lists, places",
|
||||||
"search-places": "Search places",
|
"search-places": "Search places",
|
||||||
"search-trails": "Search trails",
|
"search-trails": "Search trails",
|
||||||
"select-list": "Select List",
|
"select-list": "Select List",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Language, type Settings } from "../settings";
|
|||||||
const SettingsCreateSchema = z.object({
|
const SettingsCreateSchema = z.object({
|
||||||
unit: z.enum(["metric", "imperial"]).optional(),
|
unit: z.enum(["metric", "imperial"]).optional(),
|
||||||
language: z.enum(Object.values(Language) as [Language, ...Language[]]).optional(),
|
language: z.enum(Object.values(Language) as [Language, ...Language[]]).optional(),
|
||||||
bio: z.string().optional(),
|
bio: z.string().optional().nullable(),
|
||||||
mapFocus: z.enum(["trails", "location"]).optional(),
|
mapFocus: z.enum(["trails", "location"]).optional(),
|
||||||
location: z.object({
|
location: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -14,14 +14,14 @@ const SettingsCreateSchema = z.object({
|
|||||||
}).optional(),
|
}).optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
tilesets: z.array(z.object({ name: z.string(), url: z.string().url() })).optional(),
|
tilesets: z.array(z.object({ name: z.string(), url: z.string().url() })).optional(),
|
||||||
terrain: z.object({ terrain: z.string().url(), hillshading: z.string().url() }).optional(),
|
terrain: z.object({ terrain: z.string().url(), hillshading: z.string().url() }).optional().nullable(),
|
||||||
user: z.string().optional(),
|
user: z.string().optional(),
|
||||||
privacy: z.object({
|
privacy: z.object({
|
||||||
account: z.enum(["public", "private"]),
|
account: z.enum(["public", "private"]),
|
||||||
trails: z.enum(["public", "private"]),
|
trails: z.enum(["public", "private"]),
|
||||||
lists: z.enum(["public", "private"])
|
lists: z.enum(["public", "private"])
|
||||||
}).optional(),
|
}).optional().nullable(),
|
||||||
notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional()
|
notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional().nullable()
|
||||||
|
|
||||||
}) satisfies ZodType<Settings>
|
}) satisfies ZodType<Settings>
|
||||||
ZodType<Partial<Comment>>
|
ZodType<Partial<Comment>>
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ export enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
id?: string;
|
id?: string | null;
|
||||||
unit?: "metric" | "imperial";
|
unit?: "metric" | "imperial";
|
||||||
language?: Language;
|
language?: Language;
|
||||||
bio?: string;
|
bio?: string | null;
|
||||||
mapFocus?: "trails" | "location";
|
mapFocus?: "trails" | "location";
|
||||||
location?: { name: string, lat: number, lon: number };
|
location?: { name: string, lat: number, lon: number };
|
||||||
category?: string;
|
category?: string;
|
||||||
tilesets?: { name: string, url: string }[]
|
tilesets?: ({ name: string, url: string }[]) | null
|
||||||
terrain?: { terrain: string, hillshading: string };
|
terrain?: { terrain: string, hillshading: string } | null;
|
||||||
user?: string;
|
user?: string;
|
||||||
privacy?: { account: "public" | "private", trails: "public" | "private", lists: "public" | "private" }
|
privacy?: { account: "public" | "private", trails: "public" | "private", lists: "public" | "private" } | null
|
||||||
notifications?: Record<NotificationType, { web: boolean, email: boolean }>
|
notifications?: Record<NotificationType, { web: boolean, email: boolean }> | null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
unit: "metric" | "imperial",
|
unit: "metric" | "imperial",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { type ListResult } from "pocketbase";
|
|||||||
import { writable, type Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
import { fetchGPX } from "./trail_store";
|
import { fetchGPX } from "./trail_store";
|
||||||
import { APIError } from "$lib/util/api_util";
|
import { APIError } from "$lib/util/api_util";
|
||||||
|
import type { Hits } from "meilisearch";
|
||||||
|
|
||||||
let lists: List[] = []
|
let lists: List[] = []
|
||||||
export const list: Writable<List | null> = writable(null)
|
export const list: Writable<List | null> = writable(null)
|
||||||
@@ -34,6 +35,59 @@ export async function lists_index(filter?: ListFilter, page: number = 1, perPage
|
|||||||
|
|
||||||
lists = result;
|
lists = result;
|
||||||
|
|
||||||
|
return { ...fetchedLists, items: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function lists_search_filter(filter: ListFilter, page: number = 1, perPage: number = 5, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<ListResult<List>> {
|
||||||
|
|
||||||
|
const filterText = buildSearchFilterText(filter)
|
||||||
|
|
||||||
|
let r = await f("/api/v1/search/lists", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
q: filter.q,
|
||||||
|
options: {
|
||||||
|
filter: filterText, sort: filter.sort && filter.sortOrder ? [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`] : [],
|
||||||
|
hitsPerPage: perPage,
|
||||||
|
page: page
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult: { page: number, totalPages: number, hits: Hits<Record<string, any>> } = await r.json();
|
||||||
|
|
||||||
|
|
||||||
|
const listIds = searchResult.hits.map((h: Record<string, any>) => h.id);
|
||||||
|
|
||||||
|
if (listIds.length == 0) {
|
||||||
|
return { items: [], page: searchResult.page, perPage, totalItems: 0, totalPages: searchResult.totalPages };
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await f('/api/v1/list?' + new URLSearchParams({
|
||||||
|
expand: "trails,trails.waypoints,trails.category,list_share_via_list",
|
||||||
|
filter: `'${listIds.join(',')}'~id`,
|
||||||
|
sort: filter.sort && filter.sortOrder ? `${filter.sortOrder}${filter.sort}` : ''
|
||||||
|
}), {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedLists: ListResult<List> = await r.json();
|
||||||
|
|
||||||
|
const result = page > 1 ? [...lists, ...fetchedLists.items] : fetchedLists.items
|
||||||
|
|
||||||
|
lists = result;
|
||||||
|
|
||||||
return { ...fetchedLists, items: result };
|
return { ...fetchedLists, items: result };
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -181,17 +235,12 @@ export async function lists_delete(list: List) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function buildFilterText(filter: ListFilter): string {
|
function buildFilterText(filter: ListFilter): string {
|
||||||
|
|
||||||
|
|
||||||
let filterText = `(name~"${filter.q}"||description~"${filter.q}")`
|
let filterText = `(name~"${filter.q}"||description~"${filter.q}")`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.author?.length) {
|
if (filter.author?.length) {
|
||||||
filterText += `&&author="${filter.author}"`
|
filterText += `&&author="${filter.author}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pb.authStore.model) {
|
if (pb.authStore.model) {
|
||||||
if (filter.public === false && filter.shared === false) {
|
if (filter.public === false && filter.shared === false) {
|
||||||
filterText += `&&author="${pb.authStore.model.id}"`
|
filterText += `&&author="${pb.authStore.model.id}"`
|
||||||
@@ -201,6 +250,40 @@ function buildFilterText(filter: ListFilter): string {
|
|||||||
filterText += `&&(public=false||list_share_via_list.user="${pb.authStore.model.id}"||author="${pb.authStore.model.id}")`
|
filterText += `&&(public=false||list_share_via_list.user="${pb.authStore.model.id}"||author="${pb.authStore.model.id}")`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return filterText
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchFilterText(filter: ListFilter): string {
|
||||||
|
let filterText: string = "";
|
||||||
|
|
||||||
|
if (filter.author?.length) {
|
||||||
|
filterText += `author = ${filter.author}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.public !== undefined || filter.shared !== undefined) {
|
||||||
|
if (filterText.length) {
|
||||||
|
filterText += " AND "
|
||||||
|
}
|
||||||
|
filterText += "("
|
||||||
|
if (filter.public !== undefined) {
|
||||||
|
filterText += `(public = ${filter.public}`
|
||||||
|
|
||||||
|
if (!filter.author?.length || filter.author == pb.authStore.model?.id) {
|
||||||
|
filterText += ` OR author = ${pb.authStore.model?.id}`
|
||||||
|
}
|
||||||
|
filterText += ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.shared !== undefined) {
|
||||||
|
if (filter.shared === true) {
|
||||||
|
filterText += ` OR shares = ${pb.authStore.model?.id}`
|
||||||
|
} else {
|
||||||
|
filterText += ` AND NOT shares = ${pb.authStore.model?.id}`
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterText += ")"
|
||||||
|
}
|
||||||
|
|
||||||
return filterText
|
return filterText
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ export type TrailSearchResult = {
|
|||||||
lat: number,
|
lat: number,
|
||||||
lon: number
|
lon: number
|
||||||
}
|
}
|
||||||
auhtor: string;
|
author: string;
|
||||||
category: string;
|
category: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
created: number;
|
created: number;
|
||||||
@@ -33,6 +33,16 @@ export type TrailSearchResult = {
|
|||||||
public: boolean;
|
public: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListSearchResult = {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
created: number;
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
trails: string[]
|
||||||
|
}
|
||||||
|
|
||||||
type NominatimResponse = {
|
type NominatimResponse = {
|
||||||
type: string
|
type: string
|
||||||
licence: string
|
licence: string
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const privateRoutes = [
|
const privateRoutes = [
|
||||||
"/settings",
|
"/settings",
|
||||||
"/lists",
|
|
||||||
"/trail/edit/new",
|
"/trail/edit/new",
|
||||||
"/profile"
|
"/profile"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { categories } from "$lib/stores/category_store";
|
import { categories } from "$lib/stores/category_store";
|
||||||
import {
|
import {
|
||||||
searchMulti,
|
searchMulti,
|
||||||
|
type ListSearchResult,
|
||||||
type LocationSearchResult,
|
type LocationSearchResult,
|
||||||
type TrailSearchResult,
|
type TrailSearchResult,
|
||||||
} from "$lib/stores/search_store.js";
|
} from "$lib/stores/search_store.js";
|
||||||
@@ -32,6 +33,11 @@
|
|||||||
q: q,
|
q: q,
|
||||||
limit: 3,
|
limit: 3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
indexUid: "lists",
|
||||||
|
q: q,
|
||||||
|
limit: 3,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
indexUid: "locations",
|
indexUid: "locations",
|
||||||
q: q,
|
q: q,
|
||||||
@@ -46,19 +52,27 @@
|
|||||||
value: t.id,
|
value: t.id,
|
||||||
icon: "route",
|
icon: "route",
|
||||||
}));
|
}));
|
||||||
const cityItems = r[1].hits.map((c: LocationSearchResult) => ({
|
const listItems = r[1].hits.map((t: ListSearchResult) => ({
|
||||||
|
text: t.name,
|
||||||
|
description: `List, ${t.trails.length} ${$_("trail", { values: { n: t.trails.length } })}`,
|
||||||
|
value: t.id,
|
||||||
|
icon: "layer-group",
|
||||||
|
}));
|
||||||
|
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
|
||||||
text: c.name,
|
text: c.name,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
value: c,
|
value: c,
|
||||||
icon: getIconForLocation(c),
|
icon: getIconForLocation(c),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
searchDropdownItems = [...trailItems, ...cityItems];
|
searchDropdownItems = [...trailItems, ...listItems, ...cityItems];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchClick(item: SearchItem) {
|
function handleSearchClick(item: SearchItem) {
|
||||||
if (item.icon == "route") {
|
if (item.icon == "route") {
|
||||||
goto(`/trail/view/${item.value}`);
|
goto(`/trail/view/${item.value}`);
|
||||||
|
} else if (item.icon == "layer-group") {
|
||||||
|
goto(`/lists?list=${item.value}`);
|
||||||
} else {
|
} else {
|
||||||
goto(`/map/?lat=${item.value.lat}&lon=${item.value.lon}`);
|
goto(`/map/?lat=${item.value.lat}&lon=${item.value.lon}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
import { env } from "$env/dynamic/private";
|
|
||||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
|
||||||
const data = await event.request.json()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await event.fetch(`${env.NOMINATIM_URL}/search?q=${data.q}&format=geocodejson&limit=${data.limit}`)
|
|
||||||
return json(r);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(e);
|
|
||||||
|
|
||||||
throw error(e.httpStatus, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,11 @@
|
|||||||
import UserSearch from "$lib/components/user_search.svelte";
|
import UserSearch from "$lib/components/user_search.svelte";
|
||||||
import { List, type ListFilter } from "$lib/models/list";
|
import { List, type ListFilter } from "$lib/models/list";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { lists_delete, lists_index } from "$lib/stores/list_store";
|
import {
|
||||||
|
lists_delete,
|
||||||
|
lists_index,
|
||||||
|
lists_search_filter,
|
||||||
|
} from "$lib/stores/list_store";
|
||||||
import { fetchGPX } from "$lib/stores/trail_store";
|
import { fetchGPX } from "$lib/stores/trail_store";
|
||||||
import { currentUser } from "$lib/stores/user_store";
|
import { currentUser } from "$lib/stores/user_store";
|
||||||
import * as M from "maplibre-gl";
|
import * as M from "maplibre-gl";
|
||||||
@@ -53,7 +57,7 @@
|
|||||||
let showMap: boolean = true;
|
let showMap: boolean = true;
|
||||||
|
|
||||||
let selectedList: List | null = $state(
|
let selectedList: List | null = $state(
|
||||||
page.url.searchParams.get("list") ? lists.items[0] : null,
|
page.url.searchParams.get("list") ? data.lists.items[0] : null,
|
||||||
);
|
);
|
||||||
let selectedTrail: Trail | null = $state(null);
|
let selectedTrail: Trail | null = $state(null);
|
||||||
|
|
||||||
@@ -191,8 +195,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pagination.page = 0;
|
pagination.page = 1;
|
||||||
lists = await lists_index(filter, pagination.page);
|
lists = await lists_search_filter(filter, pagination.page);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { categories } from "$lib/stores/category_store";
|
import { categories } from "$lib/stores/category_store";
|
||||||
import {
|
import {
|
||||||
searchMulti,
|
searchMulti,
|
||||||
|
type ListSearchResult,
|
||||||
type LocationSearchResult,
|
type LocationSearchResult,
|
||||||
type TrailSearchResult,
|
type TrailSearchResult,
|
||||||
} from "$lib/stores/search_store";
|
} from "$lib/stores/search_store";
|
||||||
@@ -56,6 +57,11 @@
|
|||||||
q: q,
|
q: q,
|
||||||
limit: 3,
|
limit: 3,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
indexUid: "lists",
|
||||||
|
q: q,
|
||||||
|
limit: 3,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
indexUid: "locations",
|
indexUid: "locations",
|
||||||
q: q,
|
q: q,
|
||||||
@@ -70,20 +76,30 @@
|
|||||||
value: t,
|
value: t,
|
||||||
icon: "route",
|
icon: "route",
|
||||||
}));
|
}));
|
||||||
const cityItems = r[1].hits.map((c: LocationSearchResult) => ({
|
const listItems = r[1].hits.map((t: ListSearchResult) => ({
|
||||||
|
text: t.name,
|
||||||
|
description: `List, ${t.trails.length} ${$_("trail", { values: { n: t.trails.length } })}`,
|
||||||
|
value: t.id,
|
||||||
|
icon: "layer-group",
|
||||||
|
}));
|
||||||
|
const cityItems = r[2].hits.map((c: LocationSearchResult) => ({
|
||||||
text: c.name,
|
text: c.name,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
value: c,
|
value: c,
|
||||||
icon: getIconForLocation(c),
|
icon: getIconForLocation(c),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
searchDropdownItems = [...trailItems, ...cityItems];
|
searchDropdownItems = [...trailItems, ...listItems, ...cityItems];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchClick(item: SearchItem) {
|
function handleSearchClick(item: SearchItem) {
|
||||||
|
if (item.icon === "layer-group") {
|
||||||
|
goto(`/lists?list=${item.value}`);
|
||||||
|
} else {
|
||||||
map?.setCenter([item.value.lon, item.value.lat]);
|
map?.setCenter([item.value.lon, item.value.lat]);
|
||||||
map?.setZoom(14);
|
map?.setZoom(14);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
|
async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user