adds notifications

This commit is contained in:
Christian Beutel
2024-12-21 03:24:36 +01:00
parent 034f6873c1
commit 9140b523da
39 changed files with 1613 additions and 142 deletions

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -23,15 +24,52 @@ import (
func main() { func main() {
app := pocketbase.New() app := pocketbase.New()
client := initializeMeiliSearch()
registerMigrations(app)
setupEventHandlers(app, client)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func initializeMeiliSearch() meilisearch.ServiceManager {
return meilisearch.New(
os.Getenv("MEILI_URL"),
meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")),
)
}
func registerMigrations(app *pocketbase.PocketBase) {
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
Dir: "migrations", Dir: "migrations",
Automigrate: true, Automigrate: true,
}) })
}
client := meilisearch.New(os.Getenv("MEILI_URL"), meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY"))) func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
app.OnModelAfterCreate("users").Add(createUserHandler(app, client))
app.OnModelAfterCreate("users").Add(func(e *core.ModelEvent) error { app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app, client))
app.OnRecordAfterUpdateRequest("trails").Add(updateTrailHandler(client))
app.OnRecordAfterDeleteRequest("trails").Add(deleteTrailHandler(client))
app.OnRecordAfterCreateRequest("trail_share").Add(createTrailShareHandler(app, client))
app.OnRecordAfterDeleteRequest("trail_share").Add(deleteTrailShareHandler(client))
app.OnRecordAfterCreateRequest("list_share").Add(createListShareHandler(app))
app.OnRecordAfterCreateRequest("lists").Add(createListHandler(app))
app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app))
app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app))
app.OnRecordBeforeRequestEmailChangeRequest("users").Add(changeUserEmailHandler(app))
app.OnBeforeServe().Add(onBeforeServeHandler(app, client))
}
func createUserHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ModelEvent) error {
return func(e *core.ModelEvent) error {
record := e.Model.(*models.Record) record := e.Model.(*models.Record)
userId := record.GetId() userId := record.GetId()
@@ -42,57 +80,68 @@ func main() {
}, },
} }
if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil { token, err := util.GenerateMeilisearchToken(searchRules, client)
return err
} else {
record.Set("token", token)
if err := app.Dao().SaveRecord(record); err != nil {
return err
}
}
collection, err := app.Dao().FindCollectionByNameOrId("settings")
if err != nil { if err != nil {
return err return err
} }
record.Set("token", token)
settings := models.NewRecord(collection) if err := app.Dao().SaveRecord(record); err != nil {
settings.Set("language", "en")
settings.Set("unit", "metric")
settings.Set("mapFocus", "trails")
settings.Set("user", record.Id)
if err := app.Dao().SaveRecord(settings); err != nil {
return err return err
} }
return nil return createDefaultUserSettings(app, record.Id)
}) }
}
app.OnRecordAfterCreateRequest("trails").Add(func(e *core.RecordCreateEvent) error { func createDefaultUserSettings(app *pocketbase.PocketBase, userId string) error {
collection, err := app.Dao().FindCollectionByNameOrId("settings")
if err != nil {
return err
}
settings := models.NewRecord(collection)
settings.Set("language", "en")
settings.Set("unit", "metric")
settings.Set("mapFocus", "trails")
settings.Set("user", userId)
return app.Dao().SaveRecord(settings)
}
func createTrailHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
if err := util.IndexTrail(e.Record, client); err != nil { if err := util.IndexTrail(e.Record, client); err != nil {
return err return err
} }
return nil if e.Record.GetBool("public") {
}) notification := util.Notification{
Type: util.TrailCreate,
app.OnRecordAfterUpdateRequest("trails").Add(func(e *core.RecordUpdateEvent) error { Metadata: map[string]interface{}{
if err := util.UpdateTrail(e.Record, client); err != nil { "id": e.Record.Id,
return err "trail": e.Record.GetString("name"),
},
Seen: false,
Author: e.Record.GetString("author"),
}
return util.SendNotificationToFollowers(app, notification)
} }
return nil return nil
}) }
}
app.OnRecordAfterDeleteRequest("trails").Add(func(e *core.RecordDeleteEvent) error { func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordUpdateEvent) error {
if _, err := client.Index("trails").DeleteDocument(e.Record.Id); err != nil { return func(e *core.RecordUpdateEvent) error {
return err return util.UpdateTrail(e.Record, client)
} }
return nil }
})
app.OnRecordAfterCreateRequest("trail_share").Add(func(e *core.RecordCreateEvent) error { func deleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
return func(e *core.RecordDeleteEvent) error {
_, err := client.Index("trails").DeleteDocument(e.Record.Id)
return err
}
}
func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
trailId := e.Record.GetString("trail") trailId := e.Record.GetString("trail")
shares, err := app.Dao().FindRecordsByExpr("trail_share", shares, err := app.Dao().FindRecordsByExpr("trail_share",
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}), dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
@@ -100,100 +149,205 @@ func main() {
if err != nil { if err != nil {
return err return err
} }
userIds := []string{} userIds := make([]string, len(shares))
for _, r := range shares { for i, r := range shares {
userIds = append(userIds, r.GetString("user")) userIds[i] = r.GetString("user")
} }
util.UpdateTrailShares(trailId, userIds, client)
if err := util.UpdateTrailShares(trailId, userIds, client); err != nil { if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "trail.author"}, nil); len(errs) > 0 {
return err return fmt.Errorf("failed to expand: %v", errs)
} }
return nil shareTrail := e.Record.ExpandedOne("trail")
}) shareTrailAuthor := shareTrail.ExpandedOne("author")
app.OnRecordAfterDeleteRequest("trail_share").Add(func(e *core.RecordDeleteEvent) error { notification := util.Notification{
Type: util.TrailShare,
Metadata: map[string]interface{}{
"id": shareTrail.Id,
"trail": shareTrail.GetString("name"),
"author": shareTrailAuthor.GetString("username"),
},
Seen: false,
Author: shareTrailAuthor.Id,
}
return util.SendNotification(app, notification, e.Record.GetString("user"))
}
}
func createListShareHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
if errs := app.Dao().ExpandRecord(e.Record, []string{"list", "list.author"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
shareList := e.Record.ExpandedOne("list")
shareListAuthor := shareList.ExpandedOne("author")
notification := util.Notification{
Type: util.ListShare,
Metadata: map[string]interface{}{
"id": shareList.Id,
"list": shareList.GetString("name"),
"author": shareListAuthor.GetString("username"),
},
Seen: false,
Author: shareListAuthor.Id,
}
return util.SendNotification(app, notification, e.Record.GetString("user"))
}
}
func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error {
return func(e *core.RecordDeleteEvent) error {
trailId := e.Record.GetString("trail") trailId := e.Record.GetString("trail")
return util.UpdateTrailShares(trailId, []string{}, client)
}
}
if err := util.UpdateTrailShares(trailId, []string{}, client); err != nil { func createListHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
return err return func(e *core.RecordCreateEvent) error {
if !e.Record.GetBool("public") {
return nil
} }
return nil notification := util.Notification{
}) Type: util.ListCreate,
Metadata: map[string]interface{}{
"id": e.Record.Id,
"list": e.Record.GetString("name"),
},
Seen: false,
Author: e.Record.GetString("author"),
}
return util.SendNotificationToFollowers(app, notification)
}
}
app.OnRecordBeforeRequestEmailChangeRequest("users").Add(func(e *core.RecordRequestEmailChangeEvent) error { func createFollowHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
if errs := app.Dao().ExpandRecord(e.Record, []string{"follower"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
follower := e.Record.ExpandedOne("follower")
notification := util.Notification{
Type: util.NewFollower,
Metadata: map[string]interface{}{
"follower": follower.GetString("username"),
},
Seen: false,
Author: e.Record.GetString("follower"),
}
return util.SendNotification(app, notification, e.Record.GetString("followee"))
}
}
func createCommentHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "author"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
commentAuthor := e.Record.ExpandedOne("author")
commentTrail := e.Record.ExpandedOne("trail")
notification := util.Notification{
Type: util.TrailComment,
Metadata: map[string]interface{}{
"id": commentTrail.Id,
"author": commentAuthor.GetString("username"),
"trail": commentTrail.GetString("name"),
"comment": e.Record.GetString("text")[:128],
},
Seen: false,
Author: e.Record.GetString("author"),
}
return util.SendNotification(app, notification, commentTrail.GetString("author"))
}
}
func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordRequestEmailChangeEvent) error {
return func(e *core.RecordRequestEmailChangeEvent) error {
form := forms.NewRecordEmailChangeRequest(app, e.Record) form := forms.NewRecordEmailChangeRequest(app, e.Record)
if err := e.HttpContext.Bind(form); err != nil { if err := e.HttpContext.Bind(form); err != nil {
return err return err
} }
e.Record.Set("email", form.NewEmail) e.Record.Set("email", form.NewEmail)
if err := app.Dao().SaveRecord(e.Record); err != nil { if err := app.Dao().SaveRecord(e.Record); err != nil {
return err return err
} }
return hook.StopPropagation return hook.StopPropagation
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.GET("/public/search/token", func(c echo.Context) error {
searchRules := map[string]interface{}{
"cities500": map[string]string{},
"trails": map[string]string{
"filter": "public = true",
},
}
if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil {
return err
} else {
return c.JSON(http.StatusOK, map[string]string{"token": token})
}
})
// bootstrap pocketbase
query := app.Dao().RecordQuery("categories")
records := []*models.Record{}
if err := query.All(&records); err != nil {
return err
}
if len(records) == 0 {
collection, _ := app.Dao().FindCollectionByNameOrId("categories")
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"}
for _, element := range categories {
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
form.LoadData(map[string]any{
"name": element,
})
f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
form.AddFiles("img", f)
form.Submit()
}
}
// bootstrap meilisearch
query = app.Dao().RecordQuery("trails")
trails := []*models.Record{}
if err := query.All(&trails); err != nil {
return err
}
for _, trail := range trails {
log.Println(trail)
if err := util.UpdateTrail(trail, client); err != nil {
return err
}
}
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
} }
} }
func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ServeEvent) error {
return func(e *core.ServeEvent) error {
registerRoutes(e, client)
return bootstrapData(app, client)
}
}
func registerRoutes(e *core.ServeEvent, client meilisearch.ServiceManager) {
e.Router.GET("/public/search/token", func(c echo.Context) error {
searchRules := map[string]interface{}{
"cities500": map[string]string{},
"trails": map[string]string{
"filter": "public = true",
},
}
token, err := util.GenerateMeilisearchToken(searchRules, client)
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"token": token})
})
}
func bootstrapData(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error {
bootstrapCategories(app)
bootstrapMeilisearchTrails(app, client)
return nil
}
func bootstrapCategories(app *pocketbase.PocketBase) error {
query := app.Dao().RecordQuery("categories")
records := []*models.Record{}
if err := query.All(&records); err != nil {
return err
}
if len(records) == 0 {
collection, _ := app.Dao().FindCollectionByNameOrId("categories")
categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"}
for _, element := range categories {
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
form.LoadData(map[string]any{
"name": element,
})
f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
form.AddFiles("img", f)
form.Submit()
}
}
return nil
}
func bootstrapMeilisearchTrails(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error {
query := app.Dao().RecordQuery("trails")
trails := []*models.Record{}
if err := query.All(&trails); err != nil {
return err
}
for _, trail := range trails {
log.Println(trail)
if err := util.UpdateTrail(trail, client); err != nil {
return err
}
}
return nil
}

View File

@@ -2,14 +2,13 @@ package migrations
import ( import (
"os" "os"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
"pocketbase/util"
) )
func init() { func init() {

View File

@@ -170,7 +170,7 @@ func init() {
return daos.New(db).SaveCollection(collection) return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error { }, func(db dbx.Builder) error {
dao := daos.New(db); dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("t9lphichi5xwyeu") collection, err := dao.FindCollectionByNameOrId("t9lphichi5xwyeu")
if err != nil { if err != nil {

View File

@@ -68,7 +68,7 @@ func init() {
return daos.New(db).SaveCollection(collection) return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error { }, func(db dbx.Builder) error {
dao := daos.New(db); dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("8obn1ukumze565i") collection, err := dao.FindCollectionByNameOrId("8obn1ukumze565i")
if err != nil { if err != nil {

View File

@@ -62,7 +62,7 @@ func init() {
return daos.New(db).SaveCollection(collection) return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error { }, func(db dbx.Builder) error {
dao := daos.New(db); dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("j6w72f0kb5ivd7x") collection, err := dao.FindCollectionByNameOrId("j6w72f0kb5ivd7x")
if err != nil { if err != nil {

View File

@@ -0,0 +1,120 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `{
"id": "khrcci2uqknny8h",
"created": "2024-12-20 17:33:49.887Z",
"updated": "2024-12-20 17:33:49.887Z",
"name": "notifications",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"list_create",
"new_follower",
"trail_comment"
]
}
},
{
"system": false,
"id": "1i2ycgle",
"name": "metadata",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "pyimxu85",
"name": "seen",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "tmghd4vo",
"name": "recipient",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "exqo1whj",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
}`
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
return dao.DeleteCollection(collection)
})
}

View File

@@ -0,0 +1,120 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `{
"id": "khrcci2uqknny8h",
"created": "2024-12-20 17:33:49.887Z",
"updated": "2024-12-20 20:25:40.071Z",
"name": "notifications",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"list_create",
"new_follower",
"trail_comment"
]
}
},
{
"system": false,
"id": "1i2ycgle",
"name": "metadata",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "pyimxu85",
"name": "seen",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "tmghd4vo",
"name": "recipient",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "exqo1whj",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": "@request.auth.id = recipient",
"viewRule": "@request.auth.id = recipient",
"createRule": null,
"updateRule": "@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author",
"deleteRule": null,
"options": {}
}`
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
return dao.DeleteCollection(collection)
})
}

View File

@@ -0,0 +1,82 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
// update
edit_type := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"t list_create",
"new_follower",
"trail_comment",
"trail_share",
"list_share"
]
}
}`), edit_type); err != nil {
return err
}
collection.Schema.AddField(edit_type)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
// update
edit_type := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"list_create",
"new_follower",
"trail_comment"
]
}
}`), edit_type); err != nil {
return err
}
collection.Schema.AddField(edit_type)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,53 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
if err != nil {
return err
}
// add
new_notifications := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "sbwmk0q2",
"name": "notifications",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}`), new_notifications); err != nil {
return err
}
collection.Schema.AddField(new_notifications)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("sbwmk0q2")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,34 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
collection.UpdateRule = types.Pointer("@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author && @request.data.seen = true")
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
collection.UpdateRule = types.Pointer("@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,76 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("1kot7t9na3hi0gl")
if err != nil {
return err
}
// update
edit_list := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "luqrtipy",
"name": "list",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "r6gu2ajyidy1x69",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}`), edit_list); err != nil {
return err
}
collection.Schema.AddField(edit_list)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("1kot7t9na3hi0gl")
if err != nil {
return err
}
// update
edit_list := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "luqrtipy",
"name": "list",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "r6gu2ajyidy1x69",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}`), edit_list); err != nil {
return err
}
collection.Schema.AddField(edit_list)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,84 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
// update
edit_type := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"list_create",
"new_follower",
"trail_comment",
"trail_share",
"list_share"
]
}
}`), edit_type); err != nil {
return err
}
collection.Schema.AddField(edit_type)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h")
if err != nil {
return err
}
// update
edit_type := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "b57prsbu",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"trail_create",
"t list_create",
"new_follower",
"trail_comment",
"trail_share",
"list_share"
]
}
}`), edit_type); err != nil {
return err
}
collection.Schema.AddField(edit_type)
return dao.SaveCollection(collection)
})
}

116
db/util/notification.go Normal file
View File

@@ -0,0 +1,116 @@
package util
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models"
)
type NotificationType string
const (
TrailCreate NotificationType = "trail_create"
TrailShare NotificationType = "trail_share"
ListCreate NotificationType = "list_create"
ListShare NotificationType = "list_share"
NewFollower NotificationType = "new_follower"
TrailComment NotificationType = "trail_comment"
)
type Notification struct {
Type NotificationType `json:"type"`
Metadata interface{} `json:"metadata,omitempty"`
Seen bool `json:"seen"`
Author string `json:"author"`
}
type NotificationSettings struct {
Web bool `json:"web"`
Email bool `json:"email"`
}
func getNotificationPermissions(app *pocketbase.PocketBase, user string, notificationType NotificationType) (*NotificationSettings, error) {
settings, err := app.Dao().FindFirstRecordByFilter("settings", "user={:user}", dbx.Params{"user": user})
if err != nil {
return nil, err
}
var notificationPreferences map[NotificationType]NotificationSettings
err = settings.UnmarshalJSONField("notifications", &notificationPreferences)
if err != nil {
return nil, err
}
settingsForType, exists := notificationPreferences[notificationType]
if !exists {
return nil, fmt.Errorf("notification type '%s' not found", notificationType)
}
return &settingsForType, nil
}
func SendNotification(app *pocketbase.PocketBase, notification Notification, recipient string) error {
if notification.Author == recipient {
return nil
}
permissions, err := getNotificationPermissions(app, recipient, notification.Type)
if err != nil {
return err
}
if !permissions.Web {
return nil
}
notifications, err := app.Dao().FindCollectionByNameOrId("notifications")
if err != nil {
return err
}
n := models.NewRecord(notifications)
n.Set("type", string(notification.Type))
n.Set("metadata", notification.Metadata)
n.Set("seen", notification.Seen)
n.Set("recipient", recipient)
n.Set("author", notification.Author)
if err := app.Dao().SaveRecord(n); err != nil {
return err
}
return nil
}
func SendNotificationToFollowers(app *pocketbase.PocketBase, notification Notification) error {
followers, err := app.Dao().FindRecordsByFilter("follows", "followee={:user}", "", -1, 0, dbx.Params{"user": notification.Author})
if err != nil {
return err
}
notifications, err := app.Dao().FindCollectionByNameOrId("notifications")
if err != nil {
return err
}
for _, f := range followers {
recipient := f.GetString("follower")
if notification.Author == recipient {
continue
}
permissions, err := getNotificationPermissions(app, recipient, notification.Type)
if err != nil {
continue
}
if !permissions.Web {
continue
}
n := models.NewRecord(notifications)
n.Set("type", string(notification.Type))
n.Set("metadata", notification.Metadata)
n.Set("seen", notification.Seen)
n.Set("recipient", recipient)
n.Set("author", notification.Author)
if err := app.Dao().SaveRecord(n); err != nil {
continue
}
}
return nil
}

View File

@@ -0,0 +1,14 @@
<div
class="skeleton-notification-card animate-pulse flex items-center gap-x-3 px-3 py-2 m-2 bg-menu-background rounded-xl"
>
<!-- Avatar placeholder -->
<div
class="h-8 w-8 bg-menu-item-background-focus rounded-full shrink-0"
></div>
<!-- Text placeholders -->
<div class="basis-full space-y-2">
<div class="h-4 bg-menu-item-background-focus rounded"></div>
<div class="h-4 bg-menu-item-background-focus rounded w-2/3"></div>
</div>
</div>

View File

@@ -1,8 +1,16 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
export let name: string = ""; export let name: string = "";
export let value: boolean = false; export let value: boolean = false;
export let label: string = ""; export let label: string = "";
export let error: string = ""; export let error: string = "";
const dispatch = createEventDispatcher();
function handleToggleChange() {
dispatch("change", value);
}
</script> </script>
<label class="relative my-2 inline-flex items-center cursor-pointer"> <label class="relative my-2 inline-flex items-center cursor-pointer">
@@ -12,6 +20,7 @@
type="checkbox" type="checkbox"
class="sr-only peer" class="sr-only peer"
value="1" value="1"
on:change={handleToggleChange}
/> />
<div <div
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary" class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"

View File

@@ -35,20 +35,20 @@
</script> </script>
<div <div
class="flex gap-4 items-center" class="flex gap-4 items-start"
in:fade={{ duration: 150 }} in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }} out:fade={{ duration: 150 }}
> >
{#if comment.expand?.author.private} {#if comment.expand?.author.private}
<img <img
class="rounded-full w-10 aspect-square" class="rounded-full w-10 aspect-square shrink-0"
src={avatarSrc} src={avatarSrc}
alt="avatar" alt="avatar"
/> />
{:else} {:else}
<a <a
href="/profile/{comment.expand?.author.id}" href="/profile/{comment.expand?.author.id}"
class="text-sm font-semibold" class="text-sm font-semibold shrink-0"
> >
<img <img
class="rounded-full w-10 aspect-square" class="rounded-full w-10 aspect-square"
@@ -97,7 +97,7 @@
<TextField extraClasses="mt-2" bind:value={editedComment} <TextField extraClasses="mt-2" bind:value={editedComment}
></TextField> ></TextField>
{:else} {:else}
<p>{comment.text}</p> <p class="whitespace-pre-wrap text-sm">{comment.text}</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -4,12 +4,15 @@
import { theme, toggleTheme } from "$lib/stores/theme_store"; import { theme, toggleTheme } from "$lib/stores/theme_store";
import { currentUser, logout } from "$lib/stores/user_store"; import { currentUser, logout } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import { backInOut, cubicOut } from "svelte/easing"; import { backInOut, cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion"; import { tweened } from "svelte/motion";
import Drawer from "./base/drawer.svelte"; import Drawer from "./base/drawer.svelte";
import Dropdown from "./base/dropdown.svelte"; import Dropdown from "./base/dropdown.svelte";
import LogoTextLight from "./logo/logo_text_light.svelte"; import LogoTextLight from "./logo/logo_text_light.svelte";
import { _, format } from "svelte-i18n"; import { page } from "$app/stores";
import NotificationCard from "./notification/notification_card.svelte";
import NotificationDropdown from "./notification/notification_dropdown.svelte";
let navBarItems = [ let navBarItems = [
{ text: "Home", value: "/" }, { text: "Home", value: "/" },
@@ -200,6 +203,7 @@
<a class="btn-primary btn-large" href="/trail/edit/new" <a class="btn-primary btn-large" href="/trail/edit/new"
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a ><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
> >
<NotificationDropdown></NotificationDropdown>
<Dropdown <Dropdown
items={dropdownItems} items={dropdownItems}
on:change={(e) => handleDropdownClick(e.detail)} on:change={(e) => handleDropdownClick(e.detail)}

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import {
NotificationType,
type Notification,
} from "$lib/models/notification";
import { getFileURL } from "$lib/util/file_util";
import { formatTimeSince } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
export let notification: Notification;
const dispatch = createEventDispatcher();
const avatarSrc = notification.expand?.author.avatar
? getFileURL(
notification.expand.author,
notification.expand.author.avatar,
)
: `https://api.dicebear.com/7.x/initials/svg?seed=${notification.expand?.author.username ?? ""}&backgroundType=gradientLinear`;
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
$: title = getTitle(notification);
$: description = getDescription(notification);
$: link = getLink(notification);
function getTitle(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return $_("notification-list-create", {
values: { user: n.expand.author.username },
});
case NotificationType.listShare:
return $_("notification-list-share", {
values: { user: n.expand.author.username },
});
case NotificationType.newFollower:
return $_("notification-new-follower");
case NotificationType.trailComment:
return $_("notification-trail-comment", {
values: {
user: n.expand.author.username,
trail: n.metadata?.trail,
},
});
case NotificationType.trailCreate:
return $_("notification-new-trail", {
values: { user: n.expand.author.username },
});
case NotificationType.trailShare:
return $_("notification-trail-share", {
values: { user: n.expand.author.username },
});
}
}
function getDescription(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return n.metadata?.list ?? "";
case NotificationType.listShare:
return n.metadata?.list ?? "";
case NotificationType.newFollower:
return n.expand.author.username ?? "";
case NotificationType.trailComment:
return n.metadata?.comment ?? "";
case NotificationType.trailCreate:
return n.metadata?.trail ?? "";
case NotificationType.trailShare:
return n.metadata?.trail ?? "";
}
}
function getLink(n: Notification) {
switch (n.type) {
case NotificationType.listCreate:
return `/lists?list=${n.metadata?.id}`;
case NotificationType.listShare:
return `/lists?list=${n.metadata?.id}`;
case NotificationType.newFollower:
return n.expand.author.private === true
? null
: `/profile/${n.author}`;
case NotificationType.trailComment:
return `/trail/view/${n.metadata?.id}`;
case NotificationType.trailCreate:
return `/trail/view/${n.metadata?.id}`;
case NotificationType.trailShare:
return `/trail/view/${n.metadata?.id}`;
}
}
function handleItemClick() {
dispatch("click", { notification, link });
}
</script>
<li
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
role="presentation"
on:click={handleItemClick}
>
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
<div>
<p
class="text-sm {notification.seen
? 'font-medium'
: 'font-semibold'} mr-3"
>
{title}:
</p>
<p class="text-sm line-clamp-1">{description}</p>
<p class="text-xs text-gray-500">
{$_(`n-${timeSince.unit}-ago`, {
values: { n: timeSince.value },
})}
</p>
</div>
{#if !notification.seen}
<div
class="bg-content w-[6px] aspect-square rounded-full absolute top-3 right-3"
></div>
{/if}
</li>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import type { Notification } from "$lib/models/notification";
import { fly } from "svelte/transition";
import NotificationCard from "./notification_card.svelte";
import { page } from "$app/stores";
import {
notifications_index,
notifications_mark_as_seen,
} from "$lib/stores/notification_store";
import { currentUser } from "$lib/stores/user_store";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import SkeletonListItem from "../base/skeleton_list_item.svelte";
import SkeletonNotificationCard from "../base/skeleton_notification_card.svelte";
let notifications: Notification[] = [];
const pagination = {
page: $page.data.notifications.page,
totalPages: $page.data.notifications.totalPages,
};
let loadingNextPage: boolean = false;
let isOpen = false;
$: unreadCount = notifications.reduce(
(value, n) => (value += n.seen ? 0 : 1),
0,
);
onMount(() => {
if (!notifications.length && $page.data.notifications?.items?.length) {
notifications = $page.data.notifications.items;
}
});
async function toggleMenu(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
isOpen = !isOpen;
pagination.page = 0;
await loadNextPage();
}
function handleWindowClick(e: MouseEvent) {
if (
(e.target as HTMLElement).parentElement?.classList.contains(
"dropdown-toggle",
)
) {
return;
}
isOpen = false;
}
async function onListScroll(e: Event) {
const container = e.target as HTMLDivElement;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
if (
scrollTop + clientHeight >= scrollHeight * 0.8 &&
pagination.page !== pagination.totalPages &&
!loadingNextPage
) {
await loadNextPage();
}
}
async function loadNextPage() {
loadingNextPage = true;
if (!$currentUser) {
return;
}
pagination.page += 1;
const result = await notifications_index(
{ recipient: $currentUser.id },
pagination.page,
);
notifications = result.items;
loadingNextPage = false;
}
async function handleNotificationClick(e: CustomEvent) {
await notifications_mark_as_seen(e.detail.notification);
e.detail.notification.seen = true;
notifications = notifications;
if (e.detail.link) {
goto(e.detail.link);
}
}
</script>
<svelte:window on:mouseup={handleWindowClick} />
<div class="dropdown relative">
{#if unreadCount > 0}
<div
class="absolute -top-1 -right-1 text-sm rounded-full bg-content text-content-inverse w-4 aspect-square text-center"
>
{unreadCount}
</div>
{/if}
<div class="dropdown-toggle">
<button on:click={toggleMenu} class="btn-icon">
<i class="fa fa-bell"></i>
</button>
</div>
{#if isOpen}
<ul
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-scroll mt-4 max-h-96 w-64"
class:none={isOpen}
on:scroll={onListScroll}
style="z-index: 1001"
in:fly={{ y: -10, duration: 150 }}
out:fly={{ y: -10, duration: 150 }}
>
{#if loadingNextPage}
{#each { length: 5 } as _, index}
<SkeletonNotificationCard></SkeletonNotificationCard>
{/each}
{:else}
{#each notifications as notification}
<NotificationCard
on:click={handleNotificationClick}
{notification}
></NotificationCard>
{/each}
{/if}
</ul>
{/if}
</div>
<style>
</style>

View File

@@ -140,7 +140,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.",
@@ -177,6 +177,13 @@
"no-results": "Keine Ergebnisse gefunden", "no-results": "Keine Ergebnisse gefunden",
"not-a-valid-email-address": "Keine gültige Email-Adresse", "not-a-valid-email-address": "Keine gültige Email-Adresse",
"not-completed": "Nicht abgeschlossen", "not-completed": "Nicht abgeschlossen",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "Aus", "off": "Aus",
"only-me": "", "only-me": "",
"or": "oder", "or": "oder",
@@ -213,6 +220,12 @@
"search-trails": "Route suchen", "search-trails": "Route suchen",
"select-list": "Liste auswählen", "select-list": "Liste auswählen",
"settings": "Einstellungen", "settings": "Einstellungen",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "No results found", "no-results": "No results found",
"not-a-valid-email-address": "Not a valid email address", "not-a-valid-email-address": "Not a valid email address",
"not-completed": "Not completed", "not-completed": "Not completed",
"notification-list-create": "{user} created a new list",
"notification-list-share": "{user} shared a list with you",
"notification-new-follower": "You have a new follower",
"notification-new-trail": "{user} created a new trail",
"notification-trail-comment": "{user} left a comment on your trail \"{trail}\"",
"notification-trail-share": "{user} shared a trail with you",
"notifications": "Notifications",
"off": "Off", "off": "Off",
"only-me": "Only me", "only-me": "Only me",
"or": "or", "or": "or",
@@ -213,6 +220,12 @@
"search-trails": "Search trails", "search-trails": "Search trails",
"select-list": "Select List", "select-list": "Select List",
"settings": "Settings", "settings": "Settings",
"settings-notification-list-create": "A user who you follow has created a list",
"settings-notification-list-share": "Someone shared a list with you",
"settings-notification-new-follower": "You have a new follower",
"settings-notification-trail-comment": "Someone left a comment on your trail",
"settings-notification-trail-create": "A user who you follow has created a trail",
"settings-notification-trail-share": "Someone shared a trail with you",
"settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.", "settings-privacy-account-private": "Only you can see your profile. You will not appear in search results. Other users cannot follow you or share trails with you. You can still publish trails or lists.",
"settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.", "settings-privacy-account-public": "Everyone can see your profile. You appear in search results. Other users can follow you and share trails with you.",
"settings-privacy-lists-private": "Your lists are private by default. No one except you will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-private": "Your lists are private by default. No one except you will be able to see them. You can change this setting at any point for individual lists.",

View File

@@ -177,6 +177,13 @@
"no-results": "Pas de résultat", "no-results": "Pas de résultat",
"not-a-valid-email-address": "Adresse email invalide", "not-a-valid-email-address": "Adresse email invalide",
"not-completed": "Pas terminé", "not-completed": "Pas terminé",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "ou", "or": "ou",
@@ -213,6 +220,12 @@
"search-trails": "Chercher un itinéraire", "search-trails": "Chercher un itinéraire",
"select-list": "Liste de choix", "select-list": "Liste de choix",
"settings": "Paramètres", "settings": "Paramètres",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Nincs eredmény", "no-results": "Nincs eredmény",
"not-a-valid-email-address": "Érvénytelen e-mail cím", "not-a-valid-email-address": "Érvénytelen e-mail cím",
"not-completed": "Nem teljesített", "not-completed": "Nem teljesített",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "vagy", "or": "vagy",
@@ -213,6 +220,12 @@
"search-trails": "Nyomvonalak keresése", "search-trails": "Nyomvonalak keresése",
"select-list": "Lista kiválasztása", "select-list": "Lista kiválasztása",
"settings": "Beállítások", "settings": "Beállítások",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Nessun risultato trovato", "no-results": "Nessun risultato trovato",
"not-a-valid-email-address": "Indirizzo email non valido", "not-a-valid-email-address": "Indirizzo email non valido",
"not-completed": "Non completato", "not-completed": "Non completato",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "Spento", "off": "Spento",
"only-me": "", "only-me": "",
"or": "o", "or": "o",
@@ -213,6 +220,12 @@
"search-trails": "Cerca percorsi", "search-trails": "Cerca percorsi",
"select-list": "Seleziona lista", "select-list": "Seleziona lista",
"settings": "Impostazioni", "settings": "Impostazioni",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Er zijn geen zoekresultaten", "no-results": "Er zijn geen zoekresultaten",
"not-a-valid-email-address": "Het e-mailadres is ongeldig", "not-a-valid-email-address": "Het e-mailadres is ongeldig",
"not-completed": "Niet voltooid", "not-completed": "Niet voltooid",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "of", "or": "of",
@@ -213,6 +220,12 @@
"search-trails": "Zoeken naar wandelroutes", "search-trails": "Zoeken naar wandelroutes",
"select-list": "Kies een lijst", "select-list": "Kies een lijst",
"settings": "Instellingen", "settings": "Instellingen",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Brak wyników", "no-results": "Brak wyników",
"not-a-valid-email-address": "Nieprawidłowy adres email", "not-a-valid-email-address": "Nieprawidłowy adres email",
"not-completed": "Nie dokończono", "not-completed": "Nie dokończono",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "lub", "or": "lub",
@@ -213,6 +220,12 @@
"search-trails": "Szukaj ścieżek", "search-trails": "Szukaj ścieżek",
"select-list": "Wybierz Listę", "select-list": "Wybierz Listę",
"settings": "Ustawienia", "settings": "Ustawienia",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Nenhum resultado encontrado", "no-results": "Nenhum resultado encontrado",
"not-a-valid-email-address": "Não um endereço de e-mail válido", "not-a-valid-email-address": "Não um endereço de e-mail válido",
"not-completed": "Não preenchido", "not-completed": "Não preenchido",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "ou", "or": "ou",
@@ -213,6 +220,12 @@
"search-trails": "Procurar trilhos", "search-trails": "Procurar trilhos",
"select-list": "Selecionar lista", "select-list": "Selecionar lista",
"settings": "Definições", "settings": "Definições",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "没有找到结果", "no-results": "没有找到结果",
"not-a-valid-email-address": "无效电子邮箱地址", "not-a-valid-email-address": "无效电子邮箱地址",
"not-completed": "未完成", "not-completed": "未完成",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "", "off": "",
"only-me": "", "only-me": "",
"or": "或", "or": "或",
@@ -213,6 +220,12 @@
"search-trails": "搜索路线", "search-trails": "搜索路线",
"select-list": "选择列表", "select-list": "选择列表",
"settings": "设置", "settings": "设置",
"settings-notification-list-create": "",
"settings-notification-list-share": "",
"settings-notification-new-follower": "",
"settings-notification-trail-comment": "",
"settings-notification-trail-create": "",
"settings-notification-trail-share": "",
"settings-privacy-account-private": "", "settings-privacy-account-private": "",
"settings-privacy-account-public": "", "settings-privacy-account-public": "",
"settings-privacy-lists-private": "", "settings-privacy-lists-private": "",

View File

@@ -0,0 +1,26 @@
import type { UserAnonymous } from "./user";
enum NotificationType {
trailCreate = "trail_create",
trailShare = "trail_share",
listCreate = "list_create",
listShare = "list_share",
newFollower = "new_follower",
trailComment = "trail_comment"
};
interface Notification {
id: string;
type: NotificationType;
metadata?: Record<string, any>;
seen: boolean;
recipient: string
author: string
created: string;
expand: {
recipient: UserAnonymous;
author: UserAnonymous;
}
}
export { type Notification, NotificationType }

View File

@@ -1,26 +1,28 @@
import type { NotificationType } from "./notification";
class Settings { class Settings {
id?: string; id?: string;
unit?: "metric" | "imperial"; unit?: "metric" | "imperial";
language?: "en" | "de" | "fr" | "hu"| "it" | "nl" | "pl" | "pt" | "zh"; language?: "en" | "de" | "fr" | "hu" | "it" | "nl" | "pl" | "pt" | "zh";
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 }[]
terrain?: { terrain: string, hillshading: string}; terrain?: { terrain: string, hillshading: string };
user?: string; user?: string;
privacy?: {account: "public" | "private", trails: "public" | "private", lists: "public" | "private"} privacy?: { account: "public" | "private", trails: "public" | "private", lists: "public" | "private" }
notifications?: Record<NotificationType, { web: boolean, email: boolean }>
constructor( constructor(
unit: "metric" | "imperial", unit: "metric" | "imperial",
language: "en" | "de" | "fr" | "hu"| "it" | "nl" | "pl" | "pt" | "zh", language: "en" | "de" | "fr" | "hu" | "it" | "nl" | "pl" | "pt" | "zh",
mapFocus: "trails" | "location", mapFocus: "trails" | "location",
user: string, user: string,
params?: { params?: {
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 }[]
terrain?: { terrain: string, hillshading: string}; terrain?: { terrain: string, hillshading: string };
} }
) { ) {
this.unit = unit; this.unit = unit;

View File

@@ -0,0 +1,38 @@
import type { Notification } from "$lib/models/notification";
import { ClientResponseError, type ListResult } from "pocketbase";
let notifications: Notification[] = [];
export async function notifications_index(data: { recipient: string, seen?: boolean }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/notification?' + new URLSearchParams({
filter: `created>=@month&&recipient='${data.recipient}'` + (data.seen !== undefined ? `&&seen=${data.seen}` : ''),
sort: '+seen,-created',
page: page.toString(),
"per-page": perPage.toString()
}), {
method: 'GET',
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
const fetchedNotifications: ListResult<Notification> = await r.json();
const result = page > 1 ? [...notifications, ...fetchedNotifications.items] : fetchedNotifications.items
notifications = result;
return { ...fetchedNotifications, items: result };
}
export async function notifications_mark_as_seen(notification: Notification) {
let r = await fetch('/api/v1/notification/' + notification.id, {
method: 'POST',
body: JSON.stringify(notification),
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
}

View File

@@ -3,7 +3,13 @@ import '$lib/i18n';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { env } from "$env/dynamic/private"; import { env } from "$env/dynamic/private";
import type { Settings } from '$lib/models/settings'; import type { Settings } from '$lib/models/settings';
import { notifications_index } from '$lib/stores/notification_store';
export const load: LayoutServerLoad = async ({ locals, url }) => { export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
return { settings: locals.settings as Settings, origin: env.ORIGIN }
let notifications
if (locals.user) {
notifications = await notifications_index({ recipient: locals.user.id }, 1, 10, fetch);
}
return { settings: locals.settings as Settings, notifications, origin: env.ORIGIN }
} }

View File

@@ -1,5 +1,5 @@
import type { Follow } from '$lib/models/follow'; import type { Follow } from '$lib/models/follow';
import type { User } from '$lib/models/user'; import type { UserAnonymous } from '$lib/models/user';
import { pb } from '$lib/pocketbase'; import { pb } from '$lib/pocketbase';
import { error, json, type RequestEvent } from '@sveltejs/kit'; import { error, json, type RequestEvent } from '@sveltejs/kit';
@@ -21,8 +21,8 @@ export async function GET(event: RequestEvent) {
.getList<Follow>(parseInt(page), parseInt(perPage), { sort: sort ?? "", filter: filter ?? "", requestKey: filter }) .getList<Follow>(parseInt(page), parseInt(perPage), { sort: sort ?? "", filter: filter ?? "", requestKey: filter })
} }
for (const follow of r.items) { for (const follow of r.items) {
const follower = await pb.collection('users_anonymous').getOne<User>(follow.follower, {requestKey: filter}) const follower = await pb.collection('users_anonymous').getOne<UserAnonymous>(follow.follower, { requestKey: filter })
const followee = await pb.collection('users_anonymous').getOne<User>(follow.followee, {requestKey: filter}) const followee = await pb.collection('users_anonymous').getOne<UserAnonymous>(follow.followee, { requestKey: filter })
follow.expand = { follow.expand = {
follower, followee follower, followee
} }

View File

@@ -0,0 +1,34 @@
import type { Notification } from '$lib/models/notification';
import type { UserAnonymous } from '$lib/models/user';
import { pb } from '$lib/pocketbase';
import { error, json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
const page = event.url.searchParams.get("page") ?? "0";
const perPage = event.url.searchParams.get("per-page") ?? "10";
const sort = event.url.searchParams.get('sort') ?? ""
const filter = event.url.searchParams.get("filter") ?? "";
try {
let r;
if (parseInt(perPage) < 0) {
r = {
items: await pb.collection('notifications')
.getFullList<Notification>({ sort: sort, filter: filter, requestKey: filter })
}
} else {
r = await pb.collection('notifications')
.getList<Notification>(parseInt(page), parseInt(perPage), { sort: sort ?? "", filter: filter ?? "", requestKey: filter })
}
for (const notification of r.items) {
const recipient = await pb.collection('users_anonymous').getOne<UserAnonymous>(notification.recipient, { requestKey: filter })
const author = await pb.collection('users_anonymous').getOne<UserAnonymous>(notification.author, { requestKey: filter })
notification.expand = {
recipient, author
}
}
return json(r)
} catch (e: any) {
throw error(e.status, e);
}
}

View File

@@ -0,0 +1,15 @@
import type { Follow } from "$lib/models/follow";
import { pb } from "$lib/pocketbase";
import { error, json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
const data = await event.request.json()
try {
const r = await pb.collection('notifications').update<Notification>(event.params.id as string, { ...data, seen: true })
return json(r);
} catch (e: any) {
throw error(e.status, e)
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto, invalidate, invalidateAll } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
@@ -45,7 +45,7 @@
loading = true; loading = true;
try { try {
await login(newUser); await login(newUser);
goto($page.url.searchParams.get("r") ?? "/"); window.location.href = $page.url.searchParams.get("r") ?? "/";
} catch (e) { } catch (e) {
if ( if (
e instanceof ClientResponseError && e instanceof ClientResponseError &&

View File

@@ -13,6 +13,7 @@
text: $_("language") + " & " + $_("units"), text: $_("language") + " & " + $_("units"),
value: "/settings/language", value: "/settings/language",
}, },
{ text: $_("notifications"), value: "/settings/notifications" },
{ text: $_("map"), value: "/settings/map" }, { text: $_("map"), value: "/settings/map" },
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{ {

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { page } from "$app/stores";
import Toggle from "$lib/components/base/toggle.svelte";
import { NotificationType } from "$lib/models/notification";
import type { Settings } from "$lib/models/settings";
import { settings_update } from "$lib/stores/settings_store";
import { _ } from "svelte-i18n";
const notifications = ($page.data.settings as Settings)?.notifications ?? {
list_create: {
web: true,
email: true,
},
list_share: {
web: true,
email: true,
},
trail_create: {
web: true,
email: true,
},
trail_share: {
web: true,
email: true,
},
new_follower: {
web: true,
email: true,
},
trail_comment: {
web: true,
email: true,
},
};
const notificationItems: { text: string; key: NotificationType }[] = [
{
text: $_("notification-trail-comment"),
key: NotificationType.trailComment,
},
{
text: $_("notification-new-follower"),
key: NotificationType.newFollower,
},
{
text: $_("notification-trail-create"),
key: NotificationType.trailCreate,
},
{
text: $_("notification-trail-share"),
key: NotificationType.trailShare,
},
{
text: $_("notification-list-create"),
key: NotificationType.listCreate,
},
{
text: $_("notification-list-share"),
key: NotificationType.listShare,
},
];
async function updateNotificationSettings() {
await settings_update({
id: $page.data.settings!.id,
notifications,
});
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h2 class="text-2xl font-semibold">{$_("notifications")}</h2>
<hr class="mt-4 mb-6 border-input-border" />
<div
class="grid gap-4"
style="grid-template-columns: 1fr min-content min-content;"
>
<div></div>
<span class="text-sm font-medium">Web</span>
<span class="text-sm font-medium">Email</span>
{#each notificationItems as item}
<p>{item.text}</p>
<div>
<Toggle
on:change={updateNotificationSettings}
bind:value={notifications[item.key].web}
></Toggle>
</div>
<div>
<Toggle
on:change={updateNotificationSettings}
bind:value={notifications[item.key].email}
></Toggle>
</div>
{/each}
</div>

View File

@@ -257,6 +257,7 @@
$form.id = prevId; $form.id = prevId;
$form.expand.gpx_data = gpxData; $form.expand.gpx_data = gpxData;
$form.category = $page.data.settings.category || $categories[0].id; $form.category = $page.data.settings.category || $categories[0].id;
$form.public = $page.data.settings?.privacy.trails === "public";
const log = new SummitLog(parseResult.trail.date as string, { const log = new SummitLog(parseResult.trail.date as string, {
distance: $form.distance, distance: $form.distance,