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
import (
"fmt"
"log"
"net/http"
"os"
@@ -23,15 +24,52 @@ import (
func main() {
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{
Dir: "migrations",
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)
userId := record.GetId()
@@ -42,57 +80,68 @@ func main() {
},
}
if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil {
token, err := util.GenerateMeilisearchToken(searchRules, client)
if err != nil {
return err
} else {
}
record.Set("token", token)
if err := app.Dao().SaveRecord(record); err != nil {
return err
}
return createDefaultUserSettings(app, record.Id)
}
}
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", record.Id)
if err := app.Dao().SaveRecord(settings); err != nil {
return err
settings.Set("user", userId)
return app.Dao().SaveRecord(settings)
}
return nil
})
app.OnRecordAfterCreateRequest("trails").Add(func(e *core.RecordCreateEvent) error {
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 {
return err
}
return nil
})
app.OnRecordAfterUpdateRequest("trails").Add(func(e *core.RecordUpdateEvent) error {
if err := util.UpdateTrail(e.Record, client); err != nil {
return err
if e.Record.GetBool("public") {
notification := util.Notification{
Type: util.TrailCreate,
Metadata: map[string]interface{}{
"id": e.Record.Id,
"trail": e.Record.GetString("name"),
},
Seen: false,
Author: e.Record.GetString("author"),
}
return util.SendNotificationToFollowers(app, notification)
}
return nil
})
}
}
app.OnRecordAfterDeleteRequest("trails").Add(func(e *core.RecordDeleteEvent) error {
if _, err := client.Index("trails").DeleteDocument(e.Record.Id); err != nil {
func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordUpdateEvent) error {
return func(e *core.RecordUpdateEvent) error {
return util.UpdateTrail(e.Record, client)
}
}
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
}
return nil
})
}
app.OnRecordAfterCreateRequest("trail_share").Add(func(e *core.RecordCreateEvent) error {
func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
trailId := e.Record.GetString("trail")
shares, err := app.Dao().FindRecordsByExpr("trail_share",
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
@@ -100,41 +149,145 @@ func main() {
if err != nil {
return err
}
userIds := []string{}
for _, r := range shares {
userIds = append(userIds, r.GetString("user"))
userIds := make([]string, len(shares))
for i, r := range shares {
userIds[i] = r.GetString("user")
}
util.UpdateTrailShares(trailId, userIds, client)
if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "trail.author"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
shareTrail := e.Record.ExpandedOne("trail")
shareTrailAuthor := shareTrail.ExpandedOne("author")
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"))
}
}
if err := util.UpdateTrailShares(trailId, userIds, client); err != nil {
return err
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)
}
return nil
})
shareList := e.Record.ExpandedOne("list")
shareListAuthor := shareList.ExpandedOne("author")
app.OnRecordAfterDeleteRequest("trail_share").Add(func(e *core.RecordDeleteEvent) error {
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")
if err := util.UpdateTrailShares(trailId, []string{}, client); err != nil {
return err
return util.UpdateTrailShares(trailId, []string{}, client)
}
}
return nil
})
app.OnRecordBeforeRequestEmailChangeRequest("users").Add(func(e *core.RecordRequestEmailChangeEvent) error {
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]interface{}{
"id": e.Record.Id,
"list": e.Record.GetString("name"),
},
Seen: false,
Author: e.Record.GetString("author"),
}
return util.SendNotificationToFollowers(app, notification)
}
}
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)
if err := e.HttpContext.Bind(form); err != nil {
return err
}
e.Record.Set("email", form.NewEmail)
if err := app.Dao().SaveRecord(e.Record); err != nil {
return err
}
return hook.StopPropagation
})
}
}
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
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{},
@@ -142,16 +295,21 @@ func main() {
"filter": "public = true",
},
}
if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil {
token, err := util.GenerateMeilisearchToken(searchRules, client)
if err != nil {
return err
} else {
}
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
}
// bootstrap pocketbase
func bootstrapCategories(app *pocketbase.PocketBase) error {
query := app.Dao().RecordQuery("categories")
records := []*models.Record{}
@@ -173,9 +331,11 @@ func main() {
form.Submit()
}
}
return nil
}
// bootstrap meilisearch
query = app.Dao().RecordQuery("trails")
func bootstrapMeilisearchTrails(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error {
query := app.Dao().RecordQuery("trails")
trails := []*models.Record{}
if err := query.All(&trails); err != nil {
@@ -189,11 +349,5 @@ func main() {
return err
}
}
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}

View File

@@ -2,14 +2,13 @@ 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"
"pocketbase/util"
)
func init() {

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ func init() {
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("j6w72f0kb5ivd7x")
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">
import { createEventDispatcher } from "svelte";
export let name: string = "";
export let value: boolean = false;
export let label: string = "";
export let error: string = "";
const dispatch = createEventDispatcher();
function handleToggleChange() {
dispatch("change", value);
}
</script>
<label class="relative my-2 inline-flex items-center cursor-pointer">
@@ -12,6 +20,7 @@
type="checkbox"
class="sr-only peer"
value="1"
on:change={handleToggleChange}
/>
<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"

View File

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

View File

@@ -4,12 +4,15 @@
import { theme, toggleTheme } from "$lib/stores/theme_store";
import { currentUser, logout } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import { backInOut, cubicOut } from "svelte/easing";
import { tweened } from "svelte/motion";
import Drawer from "./base/drawer.svelte";
import Dropdown from "./base/dropdown.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 = [
{ text: "Home", value: "/" },
@@ -200,6 +203,7 @@
<a class="btn-primary btn-large" href="/trail/edit/new"
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
>
<NotificationDropdown></NotificationDropdown>
<Dropdown
items={dropdownItems}
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",
"list": "{n, plural, =1 {Liste} other {Listen}}",
"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-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.",
@@ -177,6 +177,13 @@
"no-results": "Keine Ergebnisse gefunden",
"not-a-valid-email-address": "Keine gültige Email-Adresse",
"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",
"only-me": "",
"or": "oder",
@@ -213,6 +220,12 @@
"search-trails": "Route suchen",
"select-list": "Liste auswählen",
"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-public": "",
"settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "No results found",
"not-a-valid-email-address": "Not a valid email address",
"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",
"only-me": "Only me",
"or": "or",
@@ -213,6 +220,12 @@
"search-trails": "Search trails",
"select-list": "Select List",
"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-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.",

View File

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

View File

@@ -177,6 +177,13 @@
"no-results": "Nincs eredmény",
"not-a-valid-email-address": "Érvénytelen e-mail cím",
"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": "",
"only-me": "",
"or": "vagy",
@@ -213,6 +220,12 @@
"search-trails": "Nyomvonalak keresése",
"select-list": "Lista kiválasztása",
"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-public": "",
"settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Nessun risultato trovato",
"not-a-valid-email-address": "Indirizzo email non valido",
"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",
"only-me": "",
"or": "o",
@@ -213,6 +220,12 @@
"search-trails": "Cerca percorsi",
"select-list": "Seleziona lista",
"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-public": "",
"settings-privacy-lists-private": "",

View File

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

View File

@@ -177,6 +177,13 @@
"no-results": "Brak wyników",
"not-a-valid-email-address": "Nieprawidłowy adres email",
"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": "",
"only-me": "",
"or": "lub",
@@ -213,6 +220,12 @@
"search-trails": "Szukaj ścieżek",
"select-list": "Wybierz Listę",
"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-public": "",
"settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "Nenhum resultado encontrado",
"not-a-valid-email-address": "Não um endereço de e-mail válido",
"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": "",
"only-me": "",
"or": "ou",
@@ -213,6 +220,12 @@
"search-trails": "Procurar trilhos",
"select-list": "Selecionar lista",
"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-public": "",
"settings-privacy-lists-private": "",

View File

@@ -177,6 +177,13 @@
"no-results": "没有找到结果",
"not-a-valid-email-address": "无效电子邮箱地址",
"not-completed": "未完成",
"notification-list-create": "",
"notification-list-share": "",
"notification-new-follower": "",
"notification-new-trail": "",
"notification-trail-comment": "",
"notification-trail-share": "",
"notifications": "",
"off": "",
"only-me": "",
"or": "或",
@@ -213,6 +220,12 @@
"search-trails": "搜索路线",
"select-list": "选择列表",
"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-public": "",
"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,3 +1,4 @@
import type { NotificationType } from "./notification";
class Settings {
id?: string;
@@ -10,6 +11,7 @@ class Settings {
terrain?: { terrain: string, hillshading: string };
user?: string;
privacy?: { account: "public" | "private", trails: "public" | "private", lists: "public" | "private" }
notifications?: Record<NotificationType, { web: boolean, email: boolean }>
constructor(
unit: "metric" | "imperial",

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 { env } from "$env/dynamic/private";
import type { Settings } from '$lib/models/settings';
import { notifications_index } from '$lib/stores/notification_store';
export const load: LayoutServerLoad = async ({ locals, url }) => {
return { settings: locals.settings as Settings, origin: env.ORIGIN }
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
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 { User } from '$lib/models/user';
import type { UserAnonymous } from '$lib/models/user';
import { pb } from '$lib/pocketbase';
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 })
}
for (const follow of r.items) {
const follower = await pb.collection('users_anonymous').getOne<User>(follow.follower, {requestKey: filter})
const followee = await pb.collection('users_anonymous').getOne<User>(follow.followee, {requestKey: filter})
const follower = await pb.collection('users_anonymous').getOne<UserAnonymous>(follow.follower, { requestKey: filter })
const followee = await pb.collection('users_anonymous').getOne<UserAnonymous>(follow.followee, { requestKey: filter })
follow.expand = {
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">
import { goto } from "$app/navigation";
import { goto, invalidate, invalidateAll } from "$app/navigation";
import { page } from "$app/stores";
import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte";
@@ -45,7 +45,7 @@
loading = true;
try {
await login(newUser);
goto($page.url.searchParams.get("r") ?? "/");
window.location.href = $page.url.searchParams.get("r") ?? "/";
} catch (e) {
if (
e instanceof ClientResponseError &&

View File

@@ -13,6 +13,7 @@
text: $_("language") + " & " + $_("units"),
value: "/settings/language",
},
{ text: $_("notifications"), value: "/settings/notifications" },
{ text: $_("map"), value: "/settings/map" },
{ 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.expand.gpx_data = gpxData;
$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, {
distance: $form.distance,