adds komoot integration

This commit is contained in:
Christian Beutel
2025-02-04 21:23:54 +01:00
parent a0b6480854
commit 4d1d07922a
32 changed files with 1373 additions and 189 deletions

View File

@@ -0,0 +1,371 @@
package komoot
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/twpayne/go-gpx"
)
func SyncKomoot(app *pocketbase.PocketBase) error {
integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("true"))
if err != nil {
return err
}
for _, i := range integrations {
userId := i.GetString("user")
komootString := i.GetString("komoot")
var komootIntegration KomootIntegration
json.Unmarshal([]byte(komootString), &komootIntegration)
if !komootIntegration.Active || komootIntegration.Email == "" || komootIntegration.Password == "" {
continue
}
k := &KomootApi{}
err = k.login(komootIntegration.Email, komootIntegration.Password)
if err != nil {
warning := fmt.Sprintf("komoot login failed: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
continue
}
hasNewTours := true
page := 0
for hasNewTours {
tours, err := k.fetchTours(page)
if err != nil {
warning := fmt.Sprintf("error fetching tours from komoot: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
hasNewTours, err = syncTrailWithTours(app, k, userId, tours)
if err != nil {
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
page += 1
}
}
return nil
}
type BasicAuthToken struct {
Key string
Value string
}
func (b BasicAuthToken) Apply(req *http.Request) {
authStr := "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Key+":"+b.Value))
req.Header.Set("Authorization", authStr)
}
type KomootApi struct {
UserID string
Token string
}
func (k *KomootApi) buildHeader() *BasicAuthToken {
if k.UserID != "" && k.Token != "" {
return &BasicAuthToken{k.UserID, k.Token}
}
return nil
}
func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if auth != nil {
auth.Apply(req)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("error sending request to komoot (%d): %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (k *KomootApi) login(email, password string) error {
url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email)
body, err := sendRequest(url, &BasicAuthToken{email, password})
if err != nil {
return err
}
var data LoginResponse
json.Unmarshal(body, &data)
k.UserID = data.Username
k.Token = data.Password
return nil
}
func (k *KomootApi) fetchTours(page int) ([]KomootTour, error) {
currentUri := fmt.Sprintf("https://api.komoot.de/v007/users/%s/tours/?page=%d&sort_field=date&sort_direction=desc&limit=30", k.UserID, page)
body, err := sendRequest(currentUri, k.buildHeader())
if err != nil {
return nil, err
}
var data KomootToursResponse
json.Unmarshal(body, &data)
tours := data.Embedded.Tours
return tours, nil
}
func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, error) {
url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d?_embedded=coordinates,way_types,surfaces,directions,participants,timeline&directions=v2&fields=timeline&format=coordinate_array&timeline_highlights_fields=tips,recommenders", tour.ID)
body, err := sendRequest(url, k.buildHeader())
if err != nil {
return nil, err
}
var data *DetailedKomootTour
json.Unmarshal(body, &data)
return data, nil
}
func syncTrailWithTours(app *pocketbase.PocketBase, k *KomootApi, user string, tours []KomootTour) (bool, error) {
hasNewTours := false
for _, tour := range tours {
trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(tour.ID))})
if err != nil {
return hasNewTours, err
}
if len(trails) != 0 {
continue
}
hasNewTours = true
detailedTour, err := k.fetchDetailedTour(tour)
if err != nil {
return hasNewTours, err
}
gpx, err := generateTourGPX(detailedTour)
if err != nil {
return hasNewTours, err
}
wpIds, err := createWaypointsFromTour(app, detailedTour, user)
if err != nil {
return hasNewTours, err
}
err = createTrailFromTour(app, detailedTour, gpx, user, wpIds)
if err != nil {
return hasNewTours, err
}
}
return hasNewTours, nil
}
func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomootTour, gpx *filesystem.File, user string, wpIds []string) error {
collection, err := app.Dao().FindCollectionByNameOrId("trails")
if err != nil {
return err
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
categoryMap := map[string]string{
"hike": "Hiking",
"touringbicycle": "Biking",
"mtb": "Biking",
"racebike": "Biking",
"jogging": "Walking",
"mtb_easy": "Workout",
"mtb_advanced": "Walking",
"mountaineering": "Hiking",
}
category, _ := app.Dao().FindFirstRecordByData("categories", "name", categoryMap[detailedTour.Sport])
categoryId := ""
if category != nil {
categoryId = category.Id
}
photo, err := fetchPhoto(detailedTour.MapImage.Src, "", "")
if err != nil {
return err
}
form.LoadData(map[string]any{
"name": detailedTour.Name,
"public": detailedTour.Status == "public",
"distance": detailedTour.Distance,
"elevation_gain": detailedTour.ElevationUp,
"elevation_loss": detailedTour.ElevationDown,
"duration": detailedTour.Duration / 60,
"date": detailedTour.Date,
"external_provider": "komoot",
"external_id": strconv.Itoa(detailedTour.ID),
"lat": detailedTour.StartPoint.Lat,
"lon": detailedTour.StartPoint.Lng,
"difficulty": detailedTour.Difficulty.Grade,
"category": categoryId,
"waypoints": wpIds,
"author": user,
})
form.AddFiles("photos", photo)
form.AddFiles("gpx", gpx)
if err := form.Submit(); err != nil {
return err
}
return nil
}
func createWaypointsFromTour(app *pocketbase.PocketBase, tour *DetailedKomootTour, user string) ([]string, error) {
collection, err := app.Dao().FindCollectionByNameOrId("waypoints")
if err != nil {
return nil, err
}
wpIds := make([]string, len(tour.Embedded.Timeline.Embedded.Items))
for i, wp := range tour.Embedded.Timeline.Embedded.Items {
photos, err := fetchWaypointPhotos(wp)
if err != nil {
return nil, err
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
wpDescription := ""
if len(wp.Embedded.Reference.Embedded.Tips.Embedded.Items) > 0 {
wpDescription = wp.Embedded.Reference.Embedded.Tips.Embedded.Items[0].Text
}
wpLat := wp.Embedded.Reference.StartPoint.Lat
if wpLat == 0 {
wpLat = tour.StartPoint.Lat
}
wpLon := wp.Embedded.Reference.StartPoint.Lng
if wpLon == 0 {
wpLon = tour.StartPoint.Lng
}
form.LoadData(map[string]any{
"name": wp.Embedded.Reference.Name,
"description": wpDescription,
"lat": wpLat,
"lon": wpLon,
"icon": "circle",
"author": user,
"distance_from_start": 0,
})
for _, photo := range photos {
form.AddFiles("photos", photo)
}
if err := form.Submit(); err != nil {
return nil, err
}
wpIds[i] = record.Id
}
return wpIds, nil
}
func fetchWaypointPhotos(wp Item) ([]*filesystem.File, error) {
photos := make([]*filesystem.File, len(wp.Embedded.Reference.Embedded.Images.Embedded.Items))
for i, img := range wp.Embedded.Reference.Embedded.Images.Embedded.Items {
photo, err := fetchPhoto(img.Src, "", "")
if err != nil {
return nil, err
}
photos[i] = photo
}
return photos, nil
}
func fetchPhoto(url string, width string, height string) (*filesystem.File, error) {
url = strings.Replace(url, "{crop}", "false", 1)
url = strings.Replace(url, "{width}", width, 1)
url = strings.Replace(url, "{height}", height, 1)
bytes, err := sendRequest(url, nil)
if err != nil {
return nil, err
}
return filesystem.NewFileFromBytes(bytes, "photo")
}
func generateTourGPX(detailedTour *DetailedKomootTour) (*filesystem.File, error) {
var points []*gpx.WptType
for _, item := range detailedTour.Embedded.Coordinates.Items {
t := detailedTour.Date.Unix() + int64(item.T)
points = append(points, &gpx.WptType{Lat: item.Lat, Lon: item.Lng, Ele: item.Alt, Time: time.Unix(t, 0)})
}
gpx := &gpx.GPX{
Version: "1.1",
Creator: "Strava GPX Exporter",
Trk: []*gpx.TrkType{
{
Name: detailedTour.Name,
TrkSeg: []*gpx.TrkSegType{
{
TrkPt: points,
},
},
},
},
}
var buf bytes.Buffer
err := gpx.Write(&buf)
if err != nil {
return nil, err
}
gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), detailedTour.Name+".gpx")
if err != nil {
return nil, err
}
return gpxFile, nil
}

View File

@@ -0,0 +1,379 @@
package komoot
import "time"
type KomootIntegration struct {
Active bool `json:"active"`
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
Email string `json:"email"`
Password string `json:"password"`
User User `json:"user"`
Username string `json:"username"`
}
type Content struct {
HasImage bool `json:"hasImage"`
}
type Fitness struct {
Personalised bool `json:"personalised"`
}
type User struct {
Content Content `json:"content"`
CreatedAt string `json:"createdAt"`
Displayname string `json:"displayname"`
Fitness Fitness `json:"fitness"`
ImageURL string `json:"imageUrl"`
Locale string `json:"locale"`
Metric bool `json:"metric"`
Newsletter bool `json:"newsletter"`
State string `json:"state"`
Username string `json:"username"`
WelcomeMails bool `json:"welcomeMails"`
}
type KomootToursResponse struct {
Embedded Embedded `json:"_embedded"`
Links ResponseLinks `json:"_links"`
Page Page `json:"page"`
}
type StartPoint struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
}
type Surfaces struct {
Type string `json:"type"`
Amount float64 `json:"amount"`
}
type WayTypes struct {
Type string `json:"type"`
Amount float64 `json:"amount"`
}
type Summary struct {
Surfaces []Surfaces `json:"surfaces"`
WayTypes []WayTypes `json:"way_types"`
}
type Difficulty struct {
Grade string `json:"grade"`
ExplanationTechnical string `json:"explanation_technical"`
ExplanationFitness string `json:"explanation_fitness"`
}
type Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type Path struct {
Location Location `json:"location"`
Index int `json:"index"`
Reference string `json:"reference,omitempty"`
EndIndex int `json:"end_index,omitempty"`
SegmentType string `json:"segment_type,omitempty"`
}
type Segments struct {
Type string `json:"type"`
From int `json:"from"`
To int `json:"to"`
}
type MapImage struct {
Src string `json:"src"`
Templated bool `json:"templated"`
Type string `json:"type"`
Attribution string `json:"attribution"`
}
type MapImagePreview struct {
Src string `json:"src"`
Templated bool `json:"templated"`
Type string `json:"type"`
Attribution string `json:"attribution"`
}
type VectorMapImage struct {
Src string `json:"src"`
Templated bool `json:"templated"`
Type string `json:"type"`
Attribution string `json:"attribution"`
}
type VectorMapImagePreview struct {
Src string `json:"src"`
Templated bool `json:"templated"`
Type string `json:"type"`
Attribution string `json:"attribution"`
}
type Relation struct {
Href string `json:"href"`
Templated bool `json:"templated"`
}
type CreatorLinks struct {
Relation Relation `json:"relation"`
}
type LinksEmbedded struct {
Creator Creator `json:"creator"`
}
type LinksCreator struct {
Href string `json:"href"`
}
type LinksCoordinates struct {
Href string `json:"href"`
}
type LinksTourLine struct {
Href string `json:"href"`
}
type LinksParticipants struct {
Href string `json:"href"`
}
type LinksWayTypes struct {
Href string `json:"href"`
}
type LinksSurfaces struct {
Href string `json:"href"`
}
type LinksDirections struct {
Href string `json:"href"`
}
type LinksTimeline struct {
Href string `json:"href"`
}
type LinksTranslations struct {
Href string `json:"href"`
}
type LinksCoverImages struct {
Href string `json:"href"`
}
type LinksTourRating struct {
Href string `json:"href"`
}
type TourLinks struct {
Creator LinksCreator `json:"creator"`
Coordinates LinksCoordinates `json:"coordinates"`
TourLine LinksTourLine `json:"tour_line"`
Participants LinksParticipants `json:"participants"`
WayTypes LinksWayTypes `json:"way_types"`
Surfaces LinksSurfaces `json:"surfaces"`
Directions LinksDirections `json:"directions"`
Timeline LinksTimeline `json:"timeline"`
Translations LinksTranslations `json:"translations"`
CoverImages LinksCoverImages `json:"cover_images"`
TourRating LinksTourRating `json:"tour_rating"`
}
type KomootTour struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Source string `json:"source"`
RoutingVersion string `json:"routing_version"`
Status string `json:"status"`
Date time.Time `json:"date"`
KcalActive int `json:"kcal_active"`
KcalResting int `json:"kcal_resting"`
StartPoint StartPoint `json:"start_point"`
Distance float64 `json:"distance"`
Duration int `json:"duration"`
ElevationUp float64 `json:"elevation_up"`
ElevationDown float64 `json:"elevation_down"`
Sport string `json:"sport"`
Query string `json:"query"`
Constitution int `json:"constitution"`
Summary Summary `json:"summary"`
Difficulty Difficulty `json:"difficulty"`
TourInformation []any `json:"tour_information"`
Path []Path `json:"path"`
Segments []Segments `json:"segments"`
ChangedAt time.Time `json:"changed_at"`
MapImage MapImage `json:"map_image"`
MapImagePreview MapImagePreview `json:"map_image_preview"`
VectorMapImage VectorMapImage `json:"vector_map_image"`
VectorMapImagePreview VectorMapImagePreview `json:"vector_map_image_preview"`
PotentialRouteUpdate bool `json:"potential_route_update"`
Embedded Embedded `json:"_embedded"`
Links TourLinks `json:"_links"`
}
type Embedded struct {
Tours []KomootTour `json:"tours"`
}
type Next struct {
Href string `json:"href"`
}
type ResponseLinks struct {
Next Next `json:"next"`
}
type Page struct {
Size int `json:"size"`
TotalElements int `json:"totalElements"`
TotalPages int `json:"totalPages"`
Number int `json:"number"`
}
type DetailedKomootTour struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Status string `json:"status"`
Date time.Time `json:"date"`
KcalActive float64 `json:"kcal_active"`
KcalResting float64 `json:"kcal_resting"`
StartPoint StartPoint `json:"start_point"`
Distance float64 `json:"distance"`
Duration int `json:"duration"`
ElevationUp float64 `json:"elevation_up"`
ElevationDown float64 `json:"elevation_down"`
Sport string `json:"sport"`
MapImage MapImage `json:"map_image"`
Difficulty Difficulty `json:"difficulty"`
ChangedAt time.Time `json:"changed_at"`
Embedded DetailedTourEmbedded `json:"_embedded"`
}
type Items struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
T int `json:"t"`
}
type Coordinates struct {
Items []Items `json:"items"`
}
type DetailedTourEmbedded struct {
Coordinates Coordinates `json:"coordinates"`
Timeline Timeline `json:"timeline"`
}
type Timeline struct {
Embedded TimelineEmbedded `json:"_embedded"`
Links Links `json:"_links"`
Page Page `json:"page"`
}
type TimelineEmbedded struct {
Items []Item `json:"items"`
}
type Item struct {
Index int `json:"index"`
Cover int `json:"cover"`
Type string `json:"type"`
Embedded TimelineItemEmbedded `json:"_embedded"`
}
type TimelineItemEmbedded struct {
Reference Reference `json:"reference"`
}
type Reference struct {
ID int `json:"id"`
Type string `json:"type"`
BaseName string `json:"base_name"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Sport string `json:"sport"`
Routable bool `json:"routable"`
StartPoint Point `json:"start_point"`
MidPoint Point `json:"mid_point"`
EndPoint Point `json:"end_point"`
Distance float64 `json:"distance"`
ElevationUp float64 `json:"elevation_up"`
ElevationDown float64 `json:"elevation_down"`
Score float64 `json:"score"`
WikiPOIID string `json:"wiki_poi_id"`
PoorQuality bool `json:"poor_quality"`
Categories []string `json:"categories"`
Flagged bool `json:"flagged"`
Links Links `json:"_links"`
Embedded SubEmbedded `json:"_embedded"`
}
type Point struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
}
type Links struct {
Self Link `json:"self"`
}
type Link struct {
Href string `json:"href"`
Templated bool `json:"templated,omitempty"`
}
type SubEmbedded struct {
Creator Creator `json:"creator"`
Images Images `json:"images"`
Tips Tips `json:"tips"`
}
type Creator struct {
Username string `json:"username"`
Avatar Avatar `json:"avatar"`
Status string `json:"status"`
Links Links `json:"_links"`
DisplayName string `json:"display_name"`
IsPremium bool `json:"is_premium"`
}
type Avatar struct {
Src string `json:"src"`
Templated bool `json:"templated"`
Type string `json:"type"`
}
type Images struct {
Embedded ImagesEmbedded `json:"_embedded"`
Links Links `json:"_links"`
Page Page `json:"page"`
}
type ImagesEmbedded struct {
Items []ImageItem `json:"items"`
}
type ImageItem struct {
ID int `json:"id"`
Src string `json:"src"`
Rating Rating `json:"rating"`
Templated bool `json:"templated"`
HighlightID int `json:"highlight_id"`
ClientHash string `json:"client_hash,omitempty"`
Type string `json:"type"`
Links Links `json:"_links"`
Embedded SubEmbedded `json:"_embedded"`
}
type Rating struct {
Up int `json:"up"`
Down int `json:"down"`
}
type Tips struct {
Embedded TipsEmbedded `json:"_embedded"`
Links Links `json:"_links"`
Page Page `json:"page"`
}
type TipsEmbedded struct {
Items []TipItem `json:"items"`
}
type TipItem struct {
ID int `json:"id"`
Text string `json:"text"`
Rating Rating `json:"rating"`
CreatedAt time.Time `json:"created_at"`
TextLanguage string `json:"text_language"`
TranslatedText string `json:"translated_text"`
TranslatedTextLanguage string `json:"translated_text_language"`
Attribution string `json:"attribution"`
HighlightID int `json:"highlight_id"`
Links Links `json:"_links"`
Embedded SubEmbedded `json:"_embedded"`
}

View File

@@ -1,4 +1,4 @@
package cron package strava
import "time" import "time"
@@ -11,17 +11,17 @@ type RefreshTokenRequest struct {
type RefreshTokenResponse struct { type RefreshTokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresAt *int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
} }
type StravaIntegration struct { type StravaIntegration struct {
Active bool `json:"active"` Active bool `json:"active"`
Routes bool `json:"routes"` Routes bool `json:"routes"`
Activities bool `json:"activities"` Activities bool `json:"activities"`
ClientID int32 `json:"clientId"` ClientID int32 `json:"clientId"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
AccessToken *string `json:"accessToken,omitempty"` AccessToken string `json:"accessToken,omitempty"`
RefreshToken *string `json:"refreshToken,omitempty"` RefreshToken string `json:"refreshToken,omitempty"`
ExpiresAt *int64 `json:"expiresAt,omitempty"` ExpiresAt int64 `json:"expiresAt,omitempty"`
} }
type StravaRoute struct { type StravaRoute struct {
Athlete Athlete `json:"athlete"` Athlete Athlete `json:"athlete"`

View File

@@ -1,4 +1,4 @@
package cron package strava
import ( import (
"bytes" "bytes"
@@ -18,6 +18,10 @@ import (
"github.com/twpayne/go-polyline" "github.com/twpayne/go-polyline"
) )
type StravaApi struct {
AceessToken string
}
func SyncStrava(app *pocketbase.PocketBase) error { func SyncStrava(app *pocketbase.PocketBase) error {
integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("true")) integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("true"))
if err != nil { if err != nil {
@@ -30,17 +34,26 @@ func SyncStrava(app *pocketbase.PocketBase) error {
var stravaIntegration StravaIntegration var stravaIntegration StravaIntegration
json.Unmarshal([]byte(stravaString), &stravaIntegration) json.Unmarshal([]byte(stravaString), &stravaIntegration)
if !stravaIntegration.Active || stravaIntegration.RefreshToken == nil { if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" {
continue continue
} }
r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, *stravaIntegration.RefreshToken) r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, stravaIntegration.RefreshToken)
if err != nil { if err != nil {
return err warning := fmt.Sprintf("error refreshing strava access token: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
continue
}
if r.AccessToken != "" {
stravaIntegration.AccessToken = r.AccessToken
}
if r.RefreshToken != "" {
stravaIntegration.RefreshToken = r.RefreshToken
}
if r.AccessToken != "" {
stravaIntegration.ExpiresAt = r.ExpiresAt
} }
stravaIntegration.AccessToken = &r.AccessToken
stravaIntegration.RefreshToken = &r.RefreshToken
stravaIntegration.ExpiresAt = r.ExpiresAt
b, err := json.Marshal(stravaIntegration) b, err := json.Marshal(stravaIntegration)
if err != nil { if err != nil {
@@ -56,11 +69,17 @@ func SyncStrava(app *pocketbase.PocketBase) error {
routes, err := fetchStravaRoutes(r.AccessToken, page) routes, err := fetchStravaRoutes(r.AccessToken, page)
if err != nil { if err != nil {
return err warning := fmt.Sprintf("error fetching routes from strava: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
} }
hasNewRoutes, err = syncTrailsWithRoutes(app, r.AccessToken, userId, routes) hasNewRoutes, err = syncTrailsWithRoutes(app, r.AccessToken, userId, routes)
if err != nil { if err != nil {
return err warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
} }
page += 1 page += 1
} }
@@ -71,11 +90,17 @@ func SyncStrava(app *pocketbase.PocketBase) error {
for hasNewActivities { for hasNewActivities {
activities, err := fetchStravaActivities(r.AccessToken, page) activities, err := fetchStravaActivities(r.AccessToken, page)
if err != nil { if err != nil {
return err warning := fmt.Sprintf("error fetching activities from strava: %v", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
} }
hasNewActivities, err = syncTrailsWithActivities(app, r.AccessToken, userId, activities) hasNewActivities, err = syncTrailsWithActivities(app, r.AccessToken, userId, activities)
if err != nil { if err != nil {
return err warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
} }
page += 1 page += 1
} }
@@ -263,7 +288,7 @@ func createTrailFromRoute(app *pocketbase.PocketBase, route StravaRoute, gpx *fi
lat = coords[0][0] lat = coords[0][0]
lon = coords[0][1] lon = coords[0][1]
} else { } else {
fmt.Println("Warning: No coordinates available, setting lat/lon to 0") app.Logger().Warn("Warning: No coordinates available, setting lat/lon to 0")
lat, lon = 0, 0 lat, lon = 0, 0
} }
@@ -316,7 +341,7 @@ func createWaypointsFromRoute(app *pocketbase.PocketBase, route StravaRoute, use
for i, wp := range route.Waypoints { for i, wp := range route.Waypoints {
record := models.NewRecord(collection) record := models.NewRecord(collection)
record.Set("name", string(i)) record.Set("name", strconv.Itoa(i))
record.Set("description", wp.Description) record.Set("description", wp.Description)
record.Set("lat", wp.Latlng[0]) record.Set("lat", wp.Latlng[0])
record.Set("lon", wp.Latlng[1]) record.Set("lon", wp.Latlng[1])

View File

@@ -18,11 +18,12 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/plugins/migratecmd"
pbCron "github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"pocketbase/cron" "pocketbase/integrations/komoot"
"pocketbase/integrations/strava"
_ "pocketbase/migrations" _ "pocketbase/migrations"
"pocketbase/util" "pocketbase/util"
) )
@@ -404,12 +405,20 @@ func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meili
} }
func registerCronJobs(app *pocketbase.PocketBase) { func registerCronJobs(app *pocketbase.PocketBase) {
scheduler := pbCron.New() scheduler := cron.New()
scheduler.MustAdd("strava", "*/15 * * * *", func() { scheduler.MustAdd("integrations", "0 * * * *", func() {
err := cron.SyncStrava(app) err := strava.SyncStrava(app)
if err != nil { if err != nil {
app.Logger().Warn(fmt.Sprintf("Error syncing with strava: %v", err)) warning := fmt.Sprintf("Error syncing with strava: %v", err)
fmt.Print(warning)
app.Logger().Error(warning)
}
err = komoot.SyncKomoot(app)
if err != nil {
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
fmt.Print(warning)
app.Logger().Error(warning)
} }
}) })

View File

@@ -0,0 +1,75 @@
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("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_external_provider := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "htr35nha",
"name": "external_provider",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"strava",
"komoot"
]
}
}`), edit_external_provider); err != nil {
return err
}
collection.Schema.AddField(edit_external_provider)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_external_provider := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "htr35nha",
"name": "external_provider",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"strava"
]
}
}`), edit_external_provider); err != nil {
return err
}
collection.Schema.AddField(edit_external_provider)
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("iz4sezoehde64wp")
if err != nil {
return err
}
// add
new_komoot := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "6s0oxqgp",
"name": "komoot",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}`), new_komoot); err != nil {
return err
}
collection.Schema.AddField(new_komoot)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("6s0oxqgp")
return dao.SaveCollection(collection)
})
}

View File

@@ -72,7 +72,7 @@
</div> </div>
{#if activity.photos.length} {#if activity.photos.length}
<div <div
class="grid {activity.photos.length > 1 class="grid gap-[1px] {activity.photos.length > 1
? 'grid-cols-[8fr_5fr]' ? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'}" : 'grid-cols-1'}"
> >

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import Toggle from "$lib/components/base/toggle.svelte";
import { _ } from "svelte-i18n";
interface Props {
onclick: () => void;
ontoggle: (value: boolean) => void;
active: boolean;
disabled: boolean;
img: string;
title: string;
description: string;
}
let {
onclick,
ontoggle,
active = $bindable(),
disabled,
img,
title,
description,
}: Props = $props();
</script>
<div class="border border-input-border rounded-lg p-4 space-y-4">
<img class="h-20" src={img} alt="integration logo"/>
<div>
<h5 class="text-xl font-semibold">{title}</h5>
<p class="text-sm text-gray-500">
{description}
</p>
</div>
<div class="flex items-center justify-between">
<button class="btn-secondary" {onclick}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
>
<Toggle bind:value={active} onchange={ontoggle} {disabled}></Toggle>
</div>
</div>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import { KomootSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
KomootIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (komootIntegration: KomootIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
errors.set({})
modal.openModal();
}
const {
form,
errors,
data: d,
} = createForm({
initialValues: {
email: integration?.komoot?.email ?? "",
password: integration?.komoot?.password ?? "",
active: integration?.komoot?.active ?? false,
},
extend: validator({
schema: KomootSchema,
}),
onSubmit: async (form) => {
onsave?.(form);
modal.closeModal();
},
});
</script>
<Modal
id="komoot-settings-modal"
size="max-w-lg"
title={"komoot " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="komoot-settings-form" class="space-y-2" use:form>
<TextField
label={$_("email")}
placeholder="user@example.com"
name="email"
error={$errors.email}
></TextField>
<TextField
label={$_("password")}
name="password"
type="password"
error={$errors.password}
></TextField>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="komoot-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { StravaSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
StravaIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (stravaIntegration: StravaIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
errors.set({})
modal.openModal();
}
const {
form,
errors,
} = createForm({
initialValues: {
clientId: integration?.strava?.clientId ?? "",
clientSecret: integration?.strava?.clientSecret ?? "",
routes: integration?.strava?.routes ?? true,
activities: integration?.strava?.activities ?? true,
active: integration?.strava?.active ?? false,
},
extend: validator({
schema: StravaSchema,
}),
onSubmit: async (form) => {
onsave?.(form);
modal.closeModal();
},
});
</script>
<Modal
id="strava-settings-modal"
size="max-w-lg"
title={"strava " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="strava-settings-form" class="space-y-2" use:form>
<TextField
label="Client-ID"
placeholder="000000"
name="clientId"
error={$errors.clientId}
></TextField>
<TextField
label="Client Secret"
placeholder="de8b3789bd7116d..."
name="clientSecret"
type="password"
error={$errors.clientSecret}
></TextField>
<div class="flex gap-x-4">
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
></Toggle>
<Toggle
name="activities"
label={$_("activity", { values: { n: 2 } })}
></Toggle>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="strava-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -55,7 +55,7 @@
<div <div
class="relative w-full min-h-40 max-h-48 overflow-hidden rounded-t-2xl" class="relative w-full min-h-40 max-h-48 overflow-hidden rounded-t-2xl"
> >
<img width="100%" id="header-img" src={thumbnail} alt="" /> <img class="min-h-40" id="header-img" src={thumbnail} alt="" />
</div> </div>
{#if (trail.public || trailIsShared) && pb.authStore.model} {#if (trail.public || trailIsShared) && pb.authStore.model}
<div <div

View File

@@ -44,13 +44,20 @@
import TrailTimeline from "./trail_timeline.svelte"; import TrailTimeline from "./trail_timeline.svelte";
interface Props { interface Props {
trail: Trail; initTrail: Trail;
mode?: "overview" | "map" | "list"; mode?: "overview" | "map" | "list";
markers?: M.Marker[]; markers?: M.Marker[];
activeTab?: number; activeTab?: number;
} }
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props(); let {
initTrail,
mode = "map",
markers = [],
activeTab = 0,
}: Props = $props();
let trail = $state(initTrail);
const tabs = [ const tabs = [
$_("summit-book"), $_("summit-book"),
@@ -362,7 +369,9 @@
{:else} {:else}
<EmptyStateDescription></EmptyStateDescription> <EmptyStateDescription></EmptyStateDescription>
{/if} {/if}
<h4 class="text-2xl font-semibold mb-6 mt-12">{$_("route", { values: { n: 2 } })}</h4> <h4 class="text-2xl font-semibold mb-6 mt-12">
{$_("route", { values: { n: 2 } })}
</h4>
{#if mode === "overview"} {#if mode === "overview"}
<div <div
class="relative border border-input-border rounded-xl p-2 mb-6 text-xs" class="relative border border-input-border rounded-xl p-2 mb-6 text-xs"

View File

@@ -42,23 +42,26 @@
<div class="border border-input-border rounded-xl overflow-hidden"> <div class="border border-input-border rounded-xl overflow-hidden">
{#if wp.photos.length} {#if wp.photos.length}
<PhotoGallery photos={wp.photos.map((p) => getFileURL(wp, p))} bind:this={gallery[i]} <PhotoGallery
photos={wp.photos.map((p) => getFileURL(wp, p))}
bind:this={gallery[i]}
></PhotoGallery> ></PhotoGallery>
<div <div
class="grid {wp.photos.length > 1 class="grid gap-[1px] {wp.photos.length > 1
? 'grid-cols-[8fr_5fr]' ? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'} cursor-pointer" : 'grid-cols-1'} cursor-pointer"
> >
{#each wp.photos as photo, j} {#each wp.photos as photo, j}
<button onclick={() => gallery[i].openGallery(j)}> <img
<img onclick={() => gallery[i].openGallery(j)}
class="object-cover h-full max-h-80 w-full" role="presentation"
class:row-span-2={i == 0 && class="w-full object-cover {j == 0 &&
wp.photos.length > 2} wp.photos.length > 2
src={getFileURL(wp, photo)} ? 'row-span-2 h-80'
alt="" : 'h-[159.5px]'}"
/> src={getFileURL(wp, photo)}
</button> alt=""
/>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -45,7 +45,7 @@
</script> </script>
<div <div
class="flex gap-4 p-4 outline outline-1 outline-input-border rounded-md my-2 hover:outline-2" class="flex gap-4 p-4 outline outline-1 outline-input-border rounded-md my-2 hover:outline-2 items-start"
> >
{#if imgSrc.length} {#if imgSrc.length}
{#if mode == "show"} {#if mode == "show"}
@@ -61,7 +61,7 @@
> >
{#each imgSrc as img, i} {#each imgSrc as img, i}
<img <img
class="absolute h-full rounded-xl object-cover" class="absolute h-full rounded-xl object-cover aspect-square"
style="top: {6 * i}px; right: {6 * style="top: {6 * i}px; right: {6 *
i}px; transform: rotate(-{i * 5}deg)" i}px; transform: rotate(-{i * 5}deg)"
src={img} src={img}

View File

@@ -100,6 +100,7 @@
"error-during-login": "Fehler beim Login", "error-during-login": "Fehler beim Login",
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden", "error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
"error-exporting-trail": "Fehler beim Exportieren des Trails", "error-exporting-trail": "Fehler beim Exportieren des Trails",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Fehler beim Posten des Kommentars", "error-posting-comment": "Fehler beim Posten des Kommentars",
"error-printing-map": "Fehler beim Drucken der Karte", "error-printing-map": "Fehler beim Drucken der Karte",
"error-reading-file": "Fehler beim Lesen der Datei", "error-reading-file": "Fehler beim Lesen der Datei",
@@ -107,6 +108,7 @@
"error-saving-trail": "Fehler beim Speichern der Route", "error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Fehler beim Aktualisieren des Passworts", "error-updating-password": "Fehler beim Aktualisieren des Passworts",
"error-updating-strava-integration": "",
"est-duration": "Gesch. Dauer", "est-duration": "Gesch. Dauer",
"explore": "Erkunden", "explore": "Erkunden",
"explore-some-trails": "Erkunde einige Routen", "explore-some-trails": "Erkunde einige Routen",
@@ -143,6 +145,8 @@
"import": "Importieren", "import": "Importieren",
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...", "import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
"include-description": "Beschreibung übernehmen", "include-description": "Beschreibung übernehmen",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Ungültiges Datum", "invalid-date": "Ungültiges Datum",
"invalid-username": "Ungültiger Nutzername", "invalid-username": "Ungültiger Nutzername",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Deine Listen sind standardmäßig öffentlich. \nJeder kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Listen ändern.", "settings-privacy-lists-public": "Deine Listen sind standardmäßig öffentlich. \nJeder kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Listen ändern.",
"settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. \nNiemand außer Ihnen kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. \nNiemand außer Ihnen kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Routen ändern.",
"settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. \nJeder kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Routen ändern.", "settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. \nJeder kann sie sehen. \nDu kannst diese Einstellung jederzeit für einzelne Routen ändern.",
"settings-saved": "",
"share": "Teilen", "share": "Teilen",
"share-profile": "Profil teilen", "share-profile": "Profil teilen",
"share-this-list": "Diese Liste teilen", "share-this-list": "Diese Liste teilen",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Error during login", "error-during-login": "Error during login",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Error printing map", "error-printing-map": "Error printing map",
"error-reading-file": "Error reading file", "error-reading-file": "Error reading file",
@@ -107,6 +108,7 @@
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "Error setting up strava integration", "error-setting-up-strava-integration": "Error setting up strava integration",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"error-updating-strava-integration": "Error updating komoot integration",
"est-duration": "Est. duration", "est-duration": "Est. duration",
"explore": "Explore", "explore": "Explore",
"explore-some-trails": "Explore some trails", "explore-some-trails": "Explore some trails",
@@ -143,6 +145,8 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description", "include-description": "Include description",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integrations": "Integrations", "integrations": "Integrations",
"invalid-date": "Invalid Date", "invalid-date": "Invalid Date",
"invalid-username": "Invalid username", "invalid-username": "Invalid username",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "Settings saved",
"share": "Share", "share": "Share",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "Share this list", "share-this-list": "Share this list",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Error durante el acceso", "error-during-login": "Error durante el acceso",
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico", "error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
"error-exporting-trail": "Error exportando la ruta", "error-exporting-trail": "Error exportando la ruta",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error publicando el comentario", "error-posting-comment": "Error publicando el comentario",
"error-printing-map": "Error durante la impresión del mapa", "error-printing-map": "Error durante la impresión del mapa",
"error-reading-file": "Error leyendo el archivo", "error-reading-file": "Error leyendo el archivo",
@@ -107,6 +108,7 @@
"error-saving-trail": "Error guardando la ruta", "error-saving-trail": "Error guardando la ruta",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Error actualizando la contraseña", "error-updating-password": "Error actualizando la contraseña",
"error-updating-strava-integration": "",
"est-duration": "Duración estimada", "est-duration": "Duración estimada",
"explore": "Explora", "explore": "Explora",
"explore-some-trails": "Explora alguna ruta", "explore-some-trails": "Explora alguna ruta",
@@ -143,6 +145,8 @@
"import": "Importar", "import": "Importar",
"import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...", "import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...",
"include-description": "Incluir descripción", "include-description": "Incluir descripción",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Fecha no válida", "invalid-date": "Fecha no válida",
"invalid-username": "Usuario no válido", "invalid-username": "Usuario no válido",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Tus listas son públicas por defecto. Todos podrán verlas. Puedes cambiar esta configuración en cualquier momento para listas específicas.", "settings-privacy-lists-public": "Tus listas son públicas por defecto. Todos podrán verlas. Puedes cambiar esta configuración en cualquier momento para listas específicas.",
"settings-privacy-trails-private": "Tus rutas son privadas por defecto. Nadie excepto tú podrás verlas. Puedes cambiar esta configuración en cualquier momento para rutas específicas.", "settings-privacy-trails-private": "Tus rutas son privadas por defecto. Nadie excepto tú podrás verlas. Puedes cambiar esta configuración en cualquier momento para rutas específicas.",
"settings-privacy-trails-public": "Tus rutas son públicas por defecto. Todos podrán verlas. Puedes cambiar esta configuración en cualquier momento para rutas específicas.", "settings-privacy-trails-public": "Tus rutas son públicas por defecto. Todos podrán verlas. Puedes cambiar esta configuración en cualquier momento para rutas específicas.",
"settings-saved": "",
"share": "Compartir", "share": "Compartir",
"share-profile": "Compartir perfil", "share-profile": "Compartir perfil",
"share-this-list": "Compartir esta lista", "share-this-list": "Compartir esta lista",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Erreur durant la connexion", "error-during-login": "Erreur durant la connexion",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire", "error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Erreur d'impression de la carte", "error-printing-map": "Erreur d'impression de la carte",
"error-reading-file": "Erreur de lecture du fichier", "error-reading-file": "Erreur de lecture du fichier",
@@ -107,6 +108,7 @@
"error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire", "error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Erreur lors de la mise à jour du mot de passe", "error-updating-password": "Erreur lors de la mise à jour du mot de passe",
"error-updating-strava-integration": "",
"est-duration": "Temps estimé", "est-duration": "Temps estimé",
"explore": "Explorer", "explore": "Explorer",
"explore-some-trails": "Explorer les itinéraires", "explore-some-trails": "Explorer les itinéraires",
@@ -143,6 +145,8 @@
"import": "Importer", "import": "Importer",
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...", "import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
"include-description": "Inclure la description", "include-description": "Inclure la description",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Date invalide", "invalid-date": "Date invalide",
"invalid-username": "Nom d'utilisateur invalide", "invalid-username": "Nom d'utilisateur invalide",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "",
"share": "Partager", "share": "Partager",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "Partager cette liste", "share-this-list": "Partager cette liste",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Hiba bejelentkezés közben", "error-during-login": "Hiba bejelentkezés közben",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Error printing map", "error-printing-map": "Error printing map",
"error-reading-file": "Hiba a fájl olvasása közben", "error-reading-file": "Hiba a fájl olvasása közben",
@@ -107,6 +108,7 @@
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"error-updating-strava-integration": "",
"est-duration": "Becsült időtartam", "est-duration": "Becsült időtartam",
"explore": "Felfedezés", "explore": "Felfedezés",
"explore-some-trails": "Fedezzen fel néhány ösvényt", "explore-some-trails": "Fedezzen fel néhány ösvényt",
@@ -143,6 +145,8 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description", "include-description": "Include description",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Érvénytelen dátum", "invalid-date": "Érvénytelen dátum",
"invalid-username": "Érvénytelen felhasználó", "invalid-username": "Érvénytelen felhasználó",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "",
"share": "Share", "share": "Share",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "Share this list", "share-this-list": "Share this list",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Errore durante il login", "error-during-login": "Errore durante il login",
"error-during-password-reset": "Impossibile inviare email per ripristinare la password", "error-during-password-reset": "Impossibile inviare email per ripristinare la password",
"error-exporting-trail": "Errore durante l'esportazione del percorso", "error-exporting-trail": "Errore durante l'esportazione del percorso",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Errore pubblicando il commento", "error-posting-comment": "Errore pubblicando il commento",
"error-printing-map": "Errore durante la stampa della mappa", "error-printing-map": "Errore durante la stampa della mappa",
"error-reading-file": "Errore durante la lettura del file", "error-reading-file": "Errore durante la lettura del file",
@@ -107,6 +108,7 @@
"error-saving-trail": "Errore nel salvataggio del percorso", "error-saving-trail": "Errore nel salvataggio del percorso",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Errore nell'aggiornamento della password", "error-updating-password": "Errore nell'aggiornamento della password",
"error-updating-strava-integration": "",
"est-duration": "Durata stimata", "est-duration": "Durata stimata",
"explore": "Esplora", "explore": "Esplora",
"explore-some-trails": "Esplora alcuni percorsi", "explore-some-trails": "Esplora alcuni percorsi",
@@ -143,6 +145,8 @@
"import": "Importa", "import": "Importa",
"import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...", "import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...",
"include-description": "Adotta descrizione", "include-description": "Adotta descrizione",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Data non valida", "invalid-date": "Data non valida",
"invalid-username": "Nome utente non valido", "invalid-username": "Nome utente non valido",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Le tue liste sono pubbliche per impostazione predefinita. Tutto potrànno vederle. Puoi cambiare queste impostazione in qualsiasi momento per liste specifiche.", "settings-privacy-lists-public": "Le tue liste sono pubbliche per impostazione predefinita. Tutto potrànno vederle. Puoi cambiare queste impostazione in qualsiasi momento per liste specifiche.",
"settings-privacy-trails-private": "Le tuoi percorsi sono privati per impostazione predefinita. Nessuno tranne tu potrà vederli. Puoi cambiare queste impostazione in qualsiasi momento per percorsi specifici.", "settings-privacy-trails-private": "Le tuoi percorsi sono privati per impostazione predefinita. Nessuno tranne tu potrà vederli. Puoi cambiare queste impostazione in qualsiasi momento per percorsi specifici.",
"settings-privacy-trails-public": "Le tuoi percorsi sono pubblici per impostazione predefinita. Tutti potranno vederli. Puoi cambiare queste impostazione in qualsiasi momento per percorsi specifici.", "settings-privacy-trails-public": "Le tuoi percorsi sono pubblici per impostazione predefinita. Tutti potranno vederli. Puoi cambiare queste impostazione in qualsiasi momento per percorsi specifici.",
"settings-saved": "",
"share": "Condividi", "share": "Condividi",
"share-profile": "Condividere profilo", "share-profile": "Condividere profilo",
"share-this-list": "Condividi questa lista", "share-this-list": "Condividi questa lista",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Het inloggen is mislukt", "error-during-login": "Het inloggen is mislukt",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Fout bij afdrukken van kaart", "error-printing-map": "Fout bij afdrukken van kaart",
"error-reading-file": "Het bestand kan niet worden ingelezen", "error-reading-file": "Het bestand kan niet worden ingelezen",
@@ -107,6 +108,7 @@
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"error-updating-strava-integration": "",
"est-duration": "Geschatte duur", "est-duration": "Geschatte duur",
"explore": "Verkennen", "explore": "Verkennen",
"explore-some-trails": "Verken enkele wandelroutes", "explore-some-trails": "Verken enkele wandelroutes",
@@ -143,6 +145,8 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Inclusief beschrijving", "include-description": "Inclusief beschrijving",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Ongeldige datum", "invalid-date": "Ongeldige datum",
"invalid-username": "Ongeldige gebruikersnaam", "invalid-username": "Ongeldige gebruikersnaam",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "",
"share": "Share", "share": "Share",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "Share this list", "share-this-list": "Share this list",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Błąd podczas logowania", "error-during-login": "Błąd podczas logowania",
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła", "error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
"error-exporting-trail": "Błąd podczas eksportowania szlaku", "error-exporting-trail": "Błąd podczas eksportowania szlaku",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Błąd podczas drukowania mapy", "error-printing-map": "Błąd podczas drukowania mapy",
"error-reading-file": "Błąd wczytywania pliku", "error-reading-file": "Błąd wczytywania pliku",
@@ -107,6 +108,7 @@
"error-saving-trail": "Błąd podczas zapisywania szlaku", "error-saving-trail": "Błąd podczas zapisywania szlaku",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Błąd podczas aktualizacji hasła", "error-updating-password": "Błąd podczas aktualizacji hasła",
"error-updating-strava-integration": "",
"est-duration": "Szacowany czas", "est-duration": "Szacowany czas",
"explore": "Eksploruj", "explore": "Eksploruj",
"explore-some-trails": "Eksploruj różne szlaki", "explore-some-trails": "Eksploruj różne szlaki",
@@ -143,6 +145,8 @@
"import": "Importuj", "import": "Importuj",
"import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...", "import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...",
"include-description": "Dołącz opis", "include-description": "Dołącz opis",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Nieprawidłowa data", "invalid-date": "Nieprawidłowa data",
"invalid-username": "Błędna nazwa użytkownika", "invalid-username": "Błędna nazwa użytkownika",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Domyślnie, twoje listy są publiczne. Każdy będzie mógł je zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdej listy.", "settings-privacy-lists-public": "Domyślnie, twoje listy są publiczne. Każdy będzie mógł je zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdej listy.",
"settings-privacy-trails-private": "Domyślnie, twoje szlaki są prywatne. Nikt poza tobą nie będzie mógł ich zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdego szlaku.", "settings-privacy-trails-private": "Domyślnie, twoje szlaki są prywatne. Nikt poza tobą nie będzie mógł ich zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdego szlaku.",
"settings-privacy-trails-public": "Domyślnie, twoje szlaki są publiczne. Każdy będzie mógł je zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdego szlaku.", "settings-privacy-trails-public": "Domyślnie, twoje szlaki są publiczne. Każdy będzie mógł je zobaczyć. Zawsze możesz zmieniać to ustawienie, osobno dla każdego szlaku.",
"settings-saved": "",
"share": "Udostępnij", "share": "Udostępnij",
"share-profile": "Udostępnij profil", "share-profile": "Udostępnij profil",
"share-this-list": "Udostępnij tę listę", "share-this-list": "Udostępnij tę listę",

View File

@@ -100,6 +100,7 @@
"error-during-login": "Erro durante o ‘login’", "error-during-login": "Erro durante o ‘login’",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erro na exportação do percurso", "error-exporting-trail": "Erro na exportação do percurso",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "Erro na impressão do mapa", "error-printing-map": "Erro na impressão do mapa",
"error-reading-file": "Erro ao ler o arquivo", "error-reading-file": "Erro ao ler o arquivo",
@@ -107,6 +108,7 @@
"error-saving-trail": "Erro ao gravar percurso", "error-saving-trail": "Erro ao gravar percurso",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "Erro ao atualizar password", "error-updating-password": "Erro ao atualizar password",
"error-updating-strava-integration": "",
"est-duration": "Duração prevista", "est-duration": "Duração prevista",
"explore": "Explorar", "explore": "Explorar",
"explore-some-trails": "Explore algumas trilhas", "explore-some-trails": "Explore algumas trilhas",
@@ -143,6 +145,8 @@
"import": "Importar", "import": "Importar",
"import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...", "import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...",
"include-description": "Incluir descrição", "include-description": "Incluir descrição",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "Data inválida", "invalid-date": "Data inválida",
"invalid-username": "Nome de usuário inválido", "invalid-username": "Nome de usuário inválido",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "",
"share": "Partilhar", "share": "Partilhar",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "Partilhar esta lista", "share-this-list": "Partilhar esta lista",

View File

@@ -100,6 +100,7 @@
"error-during-login": "登录错误", "error-during-login": "登录错误",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "导出路线失败", "error-exporting-trail": "导出路线失败",
"error-logging-in-to-komoot": "",
"error-posting-comment": "Error posting comment", "error-posting-comment": "Error posting comment",
"error-printing-map": "打印地图失败", "error-printing-map": "打印地图失败",
"error-reading-file": "读取文件错误", "error-reading-file": "读取文件错误",
@@ -107,6 +108,7 @@
"error-saving-trail": "保存路线失败", "error-saving-trail": "保存路线失败",
"error-setting-up-strava-integration": "", "error-setting-up-strava-integration": "",
"error-updating-password": "更新密码失败", "error-updating-password": "更新密码失败",
"error-updating-strava-integration": "",
"est-duration": "预计时长", "est-duration": "预计时长",
"explore": "探索", "explore": "探索",
"explore-some-trails": "探索行程", "explore-some-trails": "探索行程",
@@ -143,6 +145,8 @@
"import": "导入", "import": "导入",
"import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...", "import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...",
"include-description": "包含描述", "include-description": "包含描述",
"integration-description-komoot": "",
"integration-description-strava": "",
"integrations": "", "integrations": "",
"invalid-date": "无效日期", "invalid-date": "无效日期",
"invalid-username": "无效用户名", "invalid-username": "无效用户名",
@@ -257,6 +261,7 @@
"settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.", "settings-privacy-lists-public": "Your lists are public by default. Everyone will be able to see them. You can change this setting at any point for individual lists.",
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.", "settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "",
"share": "分享", "share": "分享",
"share-profile": "Share profile", "share-profile": "Share profile",
"share-this-list": "分享此列表", "share-this-list": "分享此列表",

View File

@@ -12,14 +12,22 @@ const StravaSchema = z.object({
active: z.boolean() active: z.boolean()
}) })
const KomootSchema = z.object({
email: z.string().email(),
password: z.string(),
active: z.boolean()
})
const IntegrationCreateSchema = z.object({ const IntegrationCreateSchema = z.object({
user: z.string().length(15), user: z.string().length(15),
strava: StravaSchema, strava: StravaSchema.optional(),
komoot: KomootSchema.optional()
}) satisfies ZodType<Integration> }) satisfies ZodType<Integration>
const IntegrationUpdateSchema = z.object({ const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional(), strava: StravaSchema.optional().nullable(),
komoot: KomootSchema.optional().nullable()
}) satisfies ZodType<Partial<Integration>> }) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema } export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema };

View File

@@ -1,9 +1,9 @@
interface BaseIntegration { export interface BaseIntegration {
active: boolean active: boolean
} }
interface StravaIntegration extends BaseIntegration { export interface StravaIntegration extends BaseIntegration {
clientId: string | number; clientId: string | number;
clientSecret: string; clientSecret: string;
routes: boolean; routes: boolean;
@@ -13,13 +13,21 @@ interface StravaIntegration extends BaseIntegration {
expiresAt?: number; expiresAt?: number;
} }
export interface KomootIntegration extends BaseIntegration {
email: string,
password: string,
}
export class Integration { export class Integration {
id?: string; id?: string;
user: string; user: string;
strava?: StravaIntegration; strava?: StravaIntegration | null;
komoot?: KomootIntegration | null
constructor(user: string, strava?: StravaIntegration) { constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration) {
this.user = user; this.user = user;
this.strava = strava; this.strava = strava;
this.komoot = komoot;
} }
} }

View File

@@ -361,7 +361,7 @@
onclick={(data) => selectTrail(data.trail)} onclick={(data) => selectTrail(data.trail)}
></ListPanel> ></ListPanel>
{:else if selectedList && selectedTrail} {:else if selectedList && selectedTrail}
<TrailInfoPanel trail={selectedTrail} mode="list" {markers} <TrailInfoPanel initTrail={selectedTrail} mode="list" {markers}
></TrailInfoPanel> ></TrailInfoPanel>
{/if} {/if}
</div> </div>

View File

@@ -14,7 +14,7 @@
</svelte:head> </svelte:head>
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4"> <main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
<div id="panel" class="hidden md:block"> <div id="panel" class="hidden md:block">
<TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel> <TrailInfoPanel initTrail={$trail} {markers}></TrailInfoPanel>
</div> </div>
<div id="trail-details"> <div id="trail-details">
<MapWithElevationMaplibre <MapWithElevationMaplibre

View File

@@ -1,18 +1,19 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte"; import IntegrationCard from "$lib/components/settings/integrations/integration_card.svelte";
import Modal from "$lib/components/base/modal.svelte"; import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
import Toggle from "$lib/components/base/toggle.svelte"; import {
import { StravaSchema } from "$lib/models/api/integration_schema.js"; Integration,
import { Integration } from "$lib/models/integration.js"; type BaseIntegration,
type KomootIntegration,
type StravaIntegration,
} from "$lib/models/integration.js";
import { import {
integrations_create, integrations_create,
integrations_update, integrations_update,
} from "$lib/stores/integration_store.js"; } from "$lib/stores/integration_store.js";
import { show_toast } from "$lib/stores/toast_store.js"; import { show_toast } from "$lib/stores/toast_store.js";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let { data } = $props(); let { data } = $props();
@@ -22,54 +23,44 @@
const scope = "read_all,activity:read_all"; const scope = "read_all,activity:read_all";
const redirectUri = page.url.href + "/callback/strava"; const redirectUri = page.url.href + "/callback/strava";
let stravaSettingsModal: Modal; let stravaSettingsModal: StravaSettingsModal;
let stravaToggleValue: boolean = $derived( let stravaToggleValue: boolean = $derived(
integration?.strava?.active || false, integration?.strava?.active || false,
); );
const { form, errors, data: d } = createForm({ let komootSettingsModal: KomootSettingsModal;
initialValues: { let komootToggleValue: boolean = $state(data.integration?.komoot?.active ?? false);
clientId: data.integration?.strava?.clientId ?? "",
clientSecret: data.integration?.strava?.clientSecret ?? "",
routes: data.integration?.strava?.routes ?? true,
activities: data.integration?.strava?.activities ?? true,
active: data.integration?.strava?.active ?? false,
},
extend: validator({
schema: StravaSchema,
}),
onSubmit: async (form) => {
stravaSettingsModal.closeModal();
try { async function onSettingsSave(
if (integration) { form: StravaIntegration | KomootIntegration,
integration.strava = { key: "strava" | "komoot",
clientId: form.clientId, ) {
clientSecret: form.clientSecret, try {
routes: form.routes, if (integration) {
activities: form.activities, integration[key] = form as any;
active: integration.strava?.active ?? false, integration = await integrations_update(integration);
}; } else {
integration = await integrations_update(integration); const newIntegration: Integration = {
} else { user: "",
const newIntegration = new Integration("", { [key]: form,
routes: form.routes, };
activities: form.activities, integration = await integrations_create(newIntegration);
clientId: form.clientId,
clientSecret: form.clientSecret,
active: false,
});
integration = await integrations_create(newIntegration);
}
} catch (e) {
show_toast({
text: $_("error-setting-up-strava-integration"),
icon: "close",
type: "error",
});
} }
},
}); show_toast({
text: $_("settings-saved"),
icon: "check",
type: "success",
});
} catch (e) {
show_toast({
text: $_("error-setting-up-strava-integration"),
icon: "close",
type: "error",
});
}
}
async function onStravaToggle(value: boolean) { async function onStravaToggle(value: boolean) {
if (!integration?.strava) { if (!integration?.strava) {
return; return;
@@ -80,21 +71,13 @@
} else { } else {
const deauthUrl = `https://www.strava.com/oauth/deauthorize`; const deauthUrl = `https://www.strava.com/oauth/deauthorize`;
const r = await fetch(deauthUrl, { await fetch(deauthUrl, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${integration.strava.accessToken}`, Authorization: `Bearer ${integration.strava.accessToken}`,
}, },
}); });
if (!r.ok) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
return;
}
integration.strava = { integration.strava = {
clientId: integration.strava.clientId, clientId: integration.strava.clientId,
clientSecret: integration.strava.clientSecret, clientSecret: integration.strava.clientSecret,
@@ -116,6 +99,41 @@
} }
} }
} }
async function onKomootToggle(value: boolean) {
if (!integration?.komoot) {
return;
}
if (value) {
const authUrl = `https://api.komoot.de/v006/account/email/${integration.komoot.email}/`;
const r = await fetch(authUrl, {
method: "GET",
headers: {
Authorization: `Basic ${btoa(integration.komoot.email + ":" + integration.komoot.password)}`,
},
});
if (!r.ok) {
komootToggleValue = false;
show_toast({
text: $_("error-logging-in-to-komoot"),
icon: "close",
type: "error",
});
}
integration.komoot.active = true;
} else {
integration.komoot.active = false;
}
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-strava-integration"),
icon: "close",
type: "error",
});
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -125,77 +143,35 @@
<h3 class="text-2xl font-semibold">{$_("integrations")}</h3> <h3 class="text-2xl font-semibold">{$_("integrations")}</h3>
<hr class="mt-4 mb-6 border-input-border" /> <hr class="mt-4 mb-6 border-input-border" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="border border-input-border rounded-lg p-4 space-y-4"> <IntegrationCard
<img img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
src="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg" title="strava"
alt="strava logo" description={$_("integration-description-strava")}
/> disabled={!integration?.strava}
<div> active={stravaToggleValue}
<h5 class="text-xl font-semibold">strava</h5> onclick={() => stravaSettingsModal.openModal()}
<p class="text-sm text-gray-500"> ontoggle={onStravaToggle}
Syncs your strava routes with wanderer in regular intervals. ></IntegrationCard>
</p> <IntegrationCard
</div> img="https://upload.wikimedia.org/wikipedia/commons/8/82/Komoot-logo-type.svg"
<div class="flex items-center justify-between"> title="komoot"
<button description={$_("integration-description-komoot")}
class="btn-secondary" disabled={!integration?.komoot}
onclick={() => stravaSettingsModal.openModal()} bind:active={komootToggleValue}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button onclick={() => komootSettingsModal.openModal()}
> ontoggle={onKomootToggle}
<Toggle ></IntegrationCard>
value={stravaToggleValue}
onchange={onStravaToggle}
disabled={!integration?.strava}
></Toggle>
</div>
</div>
</div> </div>
<Modal <StravaSettingsModal
id="strava-settings-modal"
size="max-w-lg"
title={"strava " + $_("settings")}
bind:this={stravaSettingsModal} bind:this={stravaSettingsModal}
> {integration}
{#snippet content()} onsave={(form) => onSettingsSave(form, "strava")}
<form id="strava-settings-form" class="space-y-2" use:form> ></StravaSettingsModal>
<TextField
label="Client-ID" <KomootSettingsModal
placeholder="000000" bind:this={komootSettingsModal}
name="clientId" {integration}
error={$errors.clientId} onsave={(form) => onSettingsSave(form, "komoot")}
></TextField> ></KomootSettingsModal>
<TextField
label="Client Secret"
placeholder="de8b3789bd7116d..."
name="clientSecret"
type="password"
error={$errors.clientSecret}
></TextField>
<div class="flex gap-x-4">
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
></Toggle>
<Toggle
name="activities"
label={$_("activity", { values: { n: 2 } })}
></Toggle>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button
class="btn-secondary"
onclick={() => stravaSettingsModal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="strava-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -4,6 +4,10 @@ import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/k
export const load: ServerLoad = async ({ url, fetch }) => { export const load: ServerLoad = async ({ url, fetch }) => {
const oauthError = url.searchParams.get('error'); const oauthError = url.searchParams.get('error');
if (oauthError) { if (oauthError) {
// user cancelled
if(oauthError == "access_denied") {
return redirect(302, '/settings/integrations')
}
return error(400, { return error(400, {
message: oauthError message: oauthError
}); });

View File

@@ -10,7 +10,7 @@
{#if data.trail} {#if data.trail}
<TrailInfoPanel <TrailInfoPanel
activeTab={parseInt(page.url.searchParams.get("t") ?? "0")} activeTab={parseInt(page.url.searchParams.get("t") ?? "0")}
trail={data.trail} initTrail={data.trail}
mode="overview" mode="overview"
></TrailInfoPanel> ></TrailInfoPanel>
{/if} {/if}