Who's Nearby Feature
This commit is contained in:
parent
73f89c7837
commit
cc628afd44
14
README.md
14
README.md
|
@ -20,6 +20,20 @@ The website can also run out of a local SQLite database which is convenient
|
||||||
for local development. The production server runs on PostgreSQL and the
|
for local development. The production server runs on PostgreSQL and the
|
||||||
web app is primarily designed for that.
|
web app is primarily designed for that.
|
||||||
|
|
||||||
|
### PostGIS Extension for PostgreSQL
|
||||||
|
|
||||||
|
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||||
|
database with the PostGIS geospatial extension installed. Usually
|
||||||
|
it might be a matter of `dnf install postgis` and activating the
|
||||||
|
extension on your nonshy database as your superuser (postgres):
|
||||||
|
|
||||||
|
```psql
|
||||||
|
create extension postgis;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you get errors like "Type geography not found" from Postgres when
|
||||||
|
running distance based searches, this is the likely culprit.
|
||||||
|
|
||||||
## Building the App
|
## Building the App
|
||||||
|
|
||||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -75,6 +76,12 @@ func Dashboard() http.HandlerFunc {
|
||||||
_, hasPublic = photoTypes[models.PhotoPublic]
|
_, hasPublic = photoTypes[models.PhotoPublic]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Geolocation/Who's Nearby: if the current user uses GeoIP, update
|
||||||
|
// their coordinates now.
|
||||||
|
if err := models.RefreshGeoIP(currentUser.ID, r); err != nil {
|
||||||
|
log.Error("RefreshGeoIP: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Notifications": notifs,
|
"Notifications": notifs,
|
||||||
"NotifMap": notifMap,
|
"NotifMap": notifMap,
|
||||||
|
|
|
@ -20,6 +20,7 @@ func Search() http.HandlerFunc {
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
"username",
|
"username",
|
||||||
"lower(name)",
|
"lower(name)",
|
||||||
|
"distance",
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -103,6 +104,10 @@ func Search() http.HandlerFunc {
|
||||||
|
|
||||||
// Photo counts mapped to users
|
// Photo counts mapped to users
|
||||||
"PhotoCountMap": models.MapPhotoCounts(users),
|
"PhotoCountMap": models.MapPhotoCounts(users),
|
||||||
|
|
||||||
|
// Current user's location setting.
|
||||||
|
"MyLocation": models.GetUserLocation(currentUser.ID),
|
||||||
|
"DistanceMap": models.MapDistances(currentUser, users),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -4,10 +4,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
nm "net/mail"
|
nm "net/mail"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/mail"
|
"code.nonshy.com/nonshy/website/pkg/mail"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -122,6 +124,36 @@ func Settings() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Flash(w, r, "Website preferences updated!")
|
session.Flash(w, r, "Website preferences updated!")
|
||||||
|
case "location":
|
||||||
|
hashtag = "#location"
|
||||||
|
var (
|
||||||
|
source = r.PostFormValue("source")
|
||||||
|
latStr = r.PostFormValue("latitude")
|
||||||
|
lonStr = r.PostFormValue("longitude")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get and update the user's location.
|
||||||
|
location := models.GetUserLocation(user.ID)
|
||||||
|
location.Source = source
|
||||||
|
|
||||||
|
if lat, err := strconv.ParseFloat(latStr, 64); err == nil {
|
||||||
|
location.Latitude = lat
|
||||||
|
} else {
|
||||||
|
location.Latitude = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if lon, err := strconv.ParseFloat(lonStr, 64); err == nil {
|
||||||
|
location.Longitude = lon
|
||||||
|
} else {
|
||||||
|
location.Longitude = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save it.
|
||||||
|
if err := location.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't save your location preference: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Location settings updated!")
|
||||||
|
}
|
||||||
case "settings":
|
case "settings":
|
||||||
hashtag = "#account"
|
hashtag = "#account"
|
||||||
var (
|
var (
|
||||||
|
@ -210,6 +242,14 @@ func Settings() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the Location tab: get GeoIP insights.
|
||||||
|
insights, err := geoip.GetRequestInsights(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetRequestInsights: %s", err)
|
||||||
|
}
|
||||||
|
vars["GeoIPInsights"] = insights
|
||||||
|
vars["UserLocation"] = models.GetUserLocation(user.ID)
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
@ -3,6 +3,7 @@ package geoip
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -12,6 +13,70 @@ import (
|
||||||
"github.com/oschwald/geoip2-golang"
|
"github.com/oschwald/geoip2-golang"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Insights returns structured GeoIP insights useful for the Location tab of the settings page.
|
||||||
|
type Insights struct {
|
||||||
|
CountryCode string
|
||||||
|
CountryName string
|
||||||
|
Subdivisions []string
|
||||||
|
City string
|
||||||
|
PostalCode string
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero checks if the insights are unpopulated.
|
||||||
|
func (i Insights) IsZero() bool {
|
||||||
|
return i.CountryCode == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// String pretty prints the insights for front-end display.
|
||||||
|
func (i Insights) String() string {
|
||||||
|
var parts = []string{
|
||||||
|
i.CountryName,
|
||||||
|
strings.Join(i.Subdivisions, ", "),
|
||||||
|
i.City,
|
||||||
|
}
|
||||||
|
if i.PostalCode != "" {
|
||||||
|
parts = append(parts, "Postal Code "+i.PostalCode)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("Lat: %f; Long: %f", i.Latitude, i.Longitude))
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestInsights returns structured insights based on the current HTTP request.
|
||||||
|
func GetRequestInsights(r *http.Request) (Insights, error) {
|
||||||
|
var (
|
||||||
|
addr = utility.IPAddress(r)
|
||||||
|
ip = net.ParseIP(addr)
|
||||||
|
)
|
||||||
|
return GetInsights(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInsights returns structured insights based on an IP address.
|
||||||
|
func GetInsights(ip net.IP) (Insights, error) {
|
||||||
|
city, err := GetCity(ip)
|
||||||
|
if err != nil {
|
||||||
|
return Insights{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = Insights{
|
||||||
|
City: city.City.Names["en"],
|
||||||
|
CountryCode: city.Country.IsoCode,
|
||||||
|
CountryName: city.Country.Names["en"],
|
||||||
|
Subdivisions: []string{},
|
||||||
|
PostalCode: city.Postal.Code,
|
||||||
|
Latitude: city.Location.Latitude,
|
||||||
|
Longitude: city.Location.Longitude,
|
||||||
|
}
|
||||||
|
for _, sub := range city.Subdivisions {
|
||||||
|
if name, ok := sub.Names["en"]; ok {
|
||||||
|
result.Subdivisions = append(result.Subdivisions, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRequestCity returns the GeoIP City result for the current HTTP request.
|
// GetRequestCity returns the GeoIP City result for the current HTTP request.
|
||||||
func GetRequestCity(r *http.Request) (*geoip2.City, error) {
|
func GetRequestCity(r *http.Request) (*geoip2.City, error) {
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -29,6 +29,7 @@ func DeleteUser(user *models.User) error {
|
||||||
{"Subscriptions", DeleteSubscriptions},
|
{"Subscriptions", DeleteSubscriptions},
|
||||||
{"Photos", DeleteUserPhotos},
|
{"Photos", DeleteUserPhotos},
|
||||||
{"Certification Photo", DeleteCertification},
|
{"Certification Photo", DeleteCertification},
|
||||||
|
{"Who's Nearby Locations", DeleteUserLocation},
|
||||||
{"Comment Photos", DeleteUserCommentPhotos},
|
{"Comment Photos", DeleteUserCommentPhotos},
|
||||||
{"Messages", DeleteUserMessages},
|
{"Messages", DeleteUserMessages},
|
||||||
{"Friends", DeleteFriends},
|
{"Friends", DeleteFriends},
|
||||||
|
@ -139,6 +140,16 @@ func DeleteCertification(userID uint64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUserLocation scrubs data for deleting a user.
|
||||||
|
func DeleteUserLocation(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteUserLocation(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.UserLocation{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteUserMessages scrubs data for deleting a user.
|
// DeleteUserMessages scrubs data for deleting a user.
|
||||||
func DeleteUserMessages(userID uint64) error {
|
func DeleteUserMessages(userID uint64) error {
|
||||||
log.Error("DeleteUser: DeleteUserMessages(%d)", userID)
|
log.Error("DeleteUser: DeleteUserMessages(%d)", userID)
|
||||||
|
|
|
@ -28,4 +28,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&PollVote{})
|
DB.AutoMigrate(&PollVote{})
|
||||||
DB.AutoMigrate(&AdminGroup{})
|
DB.AutoMigrate(&AdminGroup{})
|
||||||
DB.AutoMigrate(&AdminScope{})
|
DB.AutoMigrate(&AdminScope{})
|
||||||
|
DB.AutoMigrate(&UserLocation{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -203,11 +203,40 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
var (
|
var (
|
||||||
users = []*User{}
|
users = []*User{}
|
||||||
query *gorm.DB
|
query *gorm.DB
|
||||||
|
joins string // GPS location join.
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
placeholders = []interface{}{}
|
placeholders = []interface{}{}
|
||||||
blockedUserIDs = BlockedUserIDs(user.ID)
|
blockedUserIDs = BlockedUserIDs(user.ID)
|
||||||
|
myLocation = GetUserLocation(user.ID)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sort by distance? Requires PostgreSQL.
|
||||||
|
if pager.Sort == "distance" {
|
||||||
|
if !config.Current.Database.IsPostgres {
|
||||||
|
return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the current user doesn't have their location on file, they can't do this.
|
||||||
|
if myLocation.Source == LocationSourceNone || (myLocation.Latitude == 0 && myLocation.Longitude == 0) {
|
||||||
|
return users, errors.New("can not order by distance because your location is not known")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only query for users who have locations.
|
||||||
|
joins = "JOIN user_locations ON (user_locations.user_id = users.id)"
|
||||||
|
wheres = append(wheres,
|
||||||
|
"user_locations.latitude IS NOT NULL",
|
||||||
|
"user_locations.longitude IS NOT NULL",
|
||||||
|
"user_locations.latitude <> 0",
|
||||||
|
"user_locations.longitude <> 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
pager.Sort = fmt.Sprintf(`ST_Distance(
|
||||||
|
ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography,
|
||||||
|
ST_MakePoint(%f, %f)::geography)`,
|
||||||
|
myLocation.Longitude, myLocation.Latitude,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if len(blockedUserIDs) > 0 {
|
if len(blockedUserIDs) > 0 {
|
||||||
wheres = append(wheres, "id NOT IN ?")
|
wheres = append(wheres, "id NOT IN ?")
|
||||||
placeholders = append(placeholders, blockedUserIDs)
|
placeholders = append(placeholders, blockedUserIDs)
|
||||||
|
@ -271,7 +300,11 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
placeholders = append(placeholders, date)
|
placeholders = append(placeholders, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
query = (&User{}).Preload().Where(
|
query = (&User{}).Preload()
|
||||||
|
if joins != "" {
|
||||||
|
query = query.Joins(joins)
|
||||||
|
}
|
||||||
|
query = query.Where(
|
||||||
strings.Join(wheres, " AND "),
|
strings.Join(wheres, " AND "),
|
||||||
placeholders...,
|
placeholders...,
|
||||||
).Order(pager.Sort)
|
).Order(pager.Sort)
|
||||||
|
|
135
pkg/models/user_location.go
Normal file
135
pkg/models/user_location.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserLocation table holds a user's location preference and coordinates.
|
||||||
|
type UserLocation struct {
|
||||||
|
UserID uint64 `gorm:"primaryKey"`
|
||||||
|
Source string
|
||||||
|
Latitude float64 `gorm:"index"`
|
||||||
|
Longitude float64 `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source options for UserLocation.
|
||||||
|
const (
|
||||||
|
LocationSourceNone = ""
|
||||||
|
LocationSourceGeoIP = "geoip"
|
||||||
|
LocationSourceGPS = "gps"
|
||||||
|
LocationSourcePin = "pin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUserLocation gets the UserLocation object for a user ID, or a new object.
|
||||||
|
func GetUserLocation(userId uint64) *UserLocation {
|
||||||
|
var ul = &UserLocation{}
|
||||||
|
result := DB.First(&ul, userId)
|
||||||
|
if result.Error != nil {
|
||||||
|
return &UserLocation{
|
||||||
|
UserID: userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ul
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the UserLocation.
|
||||||
|
func (ul *UserLocation) Save() error {
|
||||||
|
if ul.Source == LocationSourceNone {
|
||||||
|
ul.Latitude = 0
|
||||||
|
ul.Longitude = 0
|
||||||
|
}
|
||||||
|
return DB.Save(ul).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshGeoIP will auto-update a user's location by GeoIP if that's their setting.
|
||||||
|
func RefreshGeoIP(userID uint64, r *http.Request) error {
|
||||||
|
loc := GetUserLocation(userID)
|
||||||
|
if loc.Source == LocationSourceGeoIP {
|
||||||
|
if insights, err := geoip.GetRequestInsights(r); err == nil {
|
||||||
|
loc.Latitude = insights.Latitude
|
||||||
|
loc.Longitude = insights.Longitude
|
||||||
|
return loc.Save()
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("didn't get insights: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapDistances computes human readable distances between you and the set of users.
|
||||||
|
func MapDistances(currentUser *User, others []*User) DistanceMap {
|
||||||
|
// Get all the distances we can.
|
||||||
|
var (
|
||||||
|
result = DistanceMap{}
|
||||||
|
myDist = GetUserLocation(currentUser.ID)
|
||||||
|
// dist = map[uint64]*UserLocation{}
|
||||||
|
userIDs = []uint64{}
|
||||||
|
)
|
||||||
|
for _, user := range others {
|
||||||
|
userIDs = append(userIDs, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for their UserLocation objects, if exists.
|
||||||
|
var ul = []*UserLocation{}
|
||||||
|
res := DB.Where("user_id IN ?", userIDs).Find(&ul)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error("MapDistances: %s", res.Error)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map them out.
|
||||||
|
for _, row := range ul {
|
||||||
|
km, mi := HaversineDistance(
|
||||||
|
myDist.Latitude, myDist.Longitude,
|
||||||
|
row.Latitude, row.Longitude,
|
||||||
|
)
|
||||||
|
result[row.UserID] = fmt.Sprintf("%.1fkm / %.1fmi", km, mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistanceMap maps user IDs to distance strings.
|
||||||
|
type DistanceMap map[uint64]string
|
||||||
|
|
||||||
|
// Get a value from the DistanceMap for easy front-end access.
|
||||||
|
func (dm DistanceMap) Get(key uint64) string {
|
||||||
|
if value, ok := dm[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "unknown distance"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaversineDistance returns the distance (in kilometers, miles) between
|
||||||
|
// two points of latitude and longitude pairs.
|
||||||
|
func HaversineDistance(lat1, lon1, lat2, lon2 float64) (float64, float64) {
|
||||||
|
lat1 *= piRad
|
||||||
|
lon1 *= piRad
|
||||||
|
lat2 *= piRad
|
||||||
|
lon2 *= piRad
|
||||||
|
var r = earthRadius
|
||||||
|
|
||||||
|
// Calculate.
|
||||||
|
h := hsin(lat2-lat1) + math.Cos(lat1)*math.Cos(lat2)*hsin(lon2-lon1)
|
||||||
|
|
||||||
|
meters := 2 * r * math.Asin(math.Sqrt(h))
|
||||||
|
kilometers := meters / 1000
|
||||||
|
miles := kilometers * 0.621371
|
||||||
|
return kilometers, miles
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapted from: https://gist.github.com/cdipaolo/d3f8db3848278b49db68
|
||||||
|
// haversin(θ) function
|
||||||
|
func hsin(theta float64) float64 {
|
||||||
|
return math.Pow(math.Sin(theta/2), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
piRad = math.Pi / 180
|
||||||
|
earthRadius = 6378100.0 // Earth radius in meters
|
||||||
|
)
|
4
web/static/js/openlayers-7.5.1/en/latest/ol/dist/ol.js
vendored
Normal file
4
web/static/js/openlayers-7.5.1/en/latest/ol/dist/ol.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/static/js/openlayers-7.5.1/en/latest/ol/dist/ol.js.map
vendored
Normal file
1
web/static/js/openlayers-7.5.1/en/latest/ol/dist/ol.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
349
web/static/js/openlayers-7.5.1/en/latest/ol/ol.css
Normal file
349
web/static/js/openlayers-7.5.1/en/latest/ol/ol.css
Normal file
|
@ -0,0 +1,349 @@
|
||||||
|
:root,
|
||||||
|
:host {
|
||||||
|
--ol-background-color: white;
|
||||||
|
--ol-accent-background-color: #F5F5F5;
|
||||||
|
--ol-subtle-background-color: rgba(128, 128, 128, 0.25);
|
||||||
|
--ol-partial-background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
--ol-foreground-color: #333333;
|
||||||
|
--ol-subtle-foreground-color: #666666;
|
||||||
|
--ol-brand-color: #00AAFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1.5px solid var(--ol-background-color);
|
||||||
|
background-color: var(--ol-partial-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-mouse-position {
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-line {
|
||||||
|
background: var(--ol-partial-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-line-inner {
|
||||||
|
border: 1px solid var(--ol-subtle-foreground-color);
|
||||||
|
border-top: none;
|
||||||
|
color: var(--ol-foreground-color);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 1px;
|
||||||
|
will-change: contents, width;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-bar-inner {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-step-marker {
|
||||||
|
width: 1px;
|
||||||
|
height: 15px;
|
||||||
|
background-color: var(--ol-foreground-color);
|
||||||
|
float: right;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-step-text {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
font-size: 10px;
|
||||||
|
z-index: 11;
|
||||||
|
color: var(--ol-foreground-color);
|
||||||
|
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-text {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
bottom: 25px;
|
||||||
|
color: var(--ol-foreground-color);
|
||||||
|
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-singlebar {
|
||||||
|
position: relative;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 9;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--ol-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-singlebar-even {
|
||||||
|
background-color: var(--ol-subtle-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-scale-singlebar-odd {
|
||||||
|
background-color: var(--ol-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-unsupported {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-viewport,
|
||||||
|
.ol-unselectable {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-viewport canvas {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-viewport {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-selectable {
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-grabbing {
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-grab {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--ol-subtle-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoom {
|
||||||
|
top: .5em;
|
||||||
|
left: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-rotate {
|
||||||
|
top: .5em;
|
||||||
|
right: .5em;
|
||||||
|
transition: opacity .25s linear, visibility 0s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-rotate.ol-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity .25s linear, visibility 0s linear .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoom-extent {
|
||||||
|
top: 4.643em;
|
||||||
|
left: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-full-screen {
|
||||||
|
right: .5em;
|
||||||
|
top: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control button {
|
||||||
|
display: block;
|
||||||
|
margin: 1px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ol-subtle-foreground-color);
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: inherit;
|
||||||
|
text-align: center;
|
||||||
|
height: 1.375em;
|
||||||
|
width: 1.375em;
|
||||||
|
line-height: .4em;
|
||||||
|
background-color: var(--ol-background-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control button::-moz-focus-inner {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoom-extent button {
|
||||||
|
line-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-compass {
|
||||||
|
display: block;
|
||||||
|
font-weight: normal;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-control button {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-zoom-extent {
|
||||||
|
top: 5.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control button:hover,
|
||||||
|
.ol-control button:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
outline: 1px solid var(--ol-subtle-foreground-color);
|
||||||
|
color: var(--ol-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoom .ol-zoom-in {
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoom .ol-zoom-out {
|
||||||
|
border-radius: 0 0 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution {
|
||||||
|
text-align: right;
|
||||||
|
bottom: .5em;
|
||||||
|
right: .5em;
|
||||||
|
max-width: calc(100% - 1.3em);
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row-reverse;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution a {
|
||||||
|
color: var(--ol-subtle-foreground-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1px .5em;
|
||||||
|
color: var(--ol-foreground-color);
|
||||||
|
text-shadow: 0 0 2px var(--ol-background-color);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution li {
|
||||||
|
display: inline;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution li:not(:last-child):after {
|
||||||
|
content: " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution img {
|
||||||
|
max-height: 2em;
|
||||||
|
max-width: inherit;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution.ol-collapsed ul {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution:not(.ol-collapsed) {
|
||||||
|
background: var(--ol-partial-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution.ol-uncollapsible {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution.ol-uncollapsible img {
|
||||||
|
margin-top: -.2em;
|
||||||
|
max-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-attribution.ol-uncollapsible button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoomslider {
|
||||||
|
top: 4.5em;
|
||||||
|
left: .5em;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-zoomslider button {
|
||||||
|
position: relative;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-zoomslider {
|
||||||
|
top: 5.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap {
|
||||||
|
left: 0.5em;
|
||||||
|
bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap.ol-uncollapsible {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap .ol-overviewmap-map,
|
||||||
|
.ol-overviewmap button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap .ol-overviewmap-map {
|
||||||
|
border: 1px solid var(--ol-subtle-foreground-color);
|
||||||
|
height: 150px;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap:not(.ol-collapsed) button {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap.ol-collapsed .ol-overviewmap-map,
|
||||||
|
.ol-overviewmap.ol-uncollapsible button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap:not(.ol-collapsed) {
|
||||||
|
background: var(--ol-subtle-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap-box {
|
||||||
|
border: 1.5px dotted var(--ol-subtle-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-overviewmap .ol-overviewmap-box:hover {
|
||||||
|
cursor: move;
|
||||||
|
}
|
|
@ -6,8 +6,13 @@
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
|
{{if eq .Pager.Sort "distance"}}
|
||||||
|
<i class="fa fa-location-dot mr-2"></i>
|
||||||
|
Who's Nearby
|
||||||
|
{{else}}
|
||||||
<i class="fa fa-people-group mr-2"></i>
|
<i class="fa fa-people-group mr-2"></i>
|
||||||
People
|
People
|
||||||
|
{{end}}
|
||||||
</h1>
|
</h1>
|
||||||
<h2 class="subtitle">Member Directory</h2>
|
<h2 class="subtitle">Member Directory</h2>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,6 +29,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not (eq .Sort "distance")}}
|
||||||
|
<div class="notification is-success is-light">
|
||||||
|
<strong>New feature:</strong> you can now see <strong>Who's Nearby!</strong>
|
||||||
|
{{if not .MyLocation.Source}}
|
||||||
|
You will need to <a href="/settings#location">set your location</a> first.
|
||||||
|
{{else}}
|
||||||
|
<a href="{{.Request.URL.Path}}?sort=distance">See who's nearby now!</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="notification is-success is-light">
|
||||||
|
Showing you <strong>Who's Nearby.</strong>
|
||||||
|
<a href="/settings#location">Update your location?</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
||||||
<div class="card nonshy-collapsible-mobile">
|
<div class="card nonshy-collapsible-mobile">
|
||||||
|
@ -145,6 +166,7 @@
|
||||||
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Signup date</option>
|
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Signup date</option>
|
||||||
<option value="username"{{if eq .Sort "username"}} selected{{end}}>Username (a-z)</option>
|
<option value="username"{{if eq .Sort "username"}} selected{{end}}>Username (a-z)</option>
|
||||||
<option value="lower(name)"{{if eq .Sort "lower(name)"}} selected{{end}}>Name (a-z)</option>
|
<option value="lower(name)"{{if eq .Sort "lower(name)"}} selected{{end}}>Name (a-z)</option>
|
||||||
|
<option value="distance"{{if eq .Sort "distance"}} selected{{end}}>Distance to me</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -260,6 +282,13 @@
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Ordered by distance? -->
|
||||||
|
{{if eq $Root.Sort "distance"}}
|
||||||
|
<div>
|
||||||
|
{{$Root.DistanceMap.Get .ID}} away
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- media-block -->
|
</div><!-- media-block -->
|
||||||
|
|
|
@ -24,7 +24,11 @@
|
||||||
Preferences
|
Preferences
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/settings#location" class="nonshy-tab-button">
|
||||||
|
Location
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings#account" class="nonshy-tab-button">
|
<a href="/settings#account" class="nonshy-tab-button">
|
||||||
Account
|
Account
|
||||||
|
@ -33,6 +37,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
<div class="card" id="profile">
|
<div class="card" id="profile">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
|
@ -400,6 +405,151 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Location Settings -->
|
||||||
|
<div id="location">
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
<input type="hidden" name="intent" value="location">
|
||||||
|
{{InputCSRF}}
|
||||||
|
|
||||||
|
<div class="card mb-5">
|
||||||
|
<header class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-globe mr-2"></i>
|
||||||
|
Location Settings
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p class="block">
|
||||||
|
The settings on this page control your location for the <a href="/members?sort=distance"><strong>Who's Nearby</strong></a>
|
||||||
|
feature of {{PrettyTitle}}. Being discoverable by your location is an <strong>opt-in</strong>
|
||||||
|
feature and you have your choice of options how you want your location to be found.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">How do you want your location to be determined?</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="source"
|
||||||
|
value=""
|
||||||
|
id="location-option-none"
|
||||||
|
{{if eq .UserLocation.Source ""}}checked{{end}}>
|
||||||
|
<i class="fa fa-ban ml-2 mr-1 has-text-danger"></i>
|
||||||
|
Do not share my location with {{PrettyTitle}}.
|
||||||
|
</label>
|
||||||
|
<p class="help mb-4">
|
||||||
|
This option will opt-out of the Who's Nearby feature and your profile will not be
|
||||||
|
discoverable by distance to other members. Any location data already stored by
|
||||||
|
the website will be erased if you choose this option.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="source"
|
||||||
|
value="geoip"
|
||||||
|
id="location-option-geoip"
|
||||||
|
{{if eq .UserLocation.Source "geoip"}}checked{{end}}>
|
||||||
|
<i class="fa fa-network-wired ml-2 mr-1 has-text-info"></i>
|
||||||
|
Automatically detect my location by my IP address
|
||||||
|
</label>
|
||||||
|
<p class="help mb-4">
|
||||||
|
Course location data based on your IP address. Might be accurate to your
|
||||||
|
city level.
|
||||||
|
|
||||||
|
<!-- Do we have GeoIP insights? -->
|
||||||
|
{{if not .GeoIPInsights.IsZero}}
|
||||||
|
<br>
|
||||||
|
<strong>Currently:</strong>
|
||||||
|
{{.GeoIPInsights}}
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="source"
|
||||||
|
value="gps"
|
||||||
|
id="location-option-gps"
|
||||||
|
{{if eq .UserLocation.Source "gps"}}checked{{end}}>
|
||||||
|
<i class="fa fa-location-dot ml-2 mr-1 has-text-success"></i>
|
||||||
|
Automatically detect my current location by GPS (with your permission)
|
||||||
|
</label>
|
||||||
|
<p class="help mb-4">
|
||||||
|
Your web browser will prompt you to share your current location with {{PrettyTitle}}.
|
||||||
|
<br>
|
||||||
|
<strong>Currently:</strong>
|
||||||
|
<span id="location-status-gps">You have not granted permission.</span>
|
||||||
|
<a href="#" class="fa fa-arrows-rotate" id="gps-refresh" title="Refresh my location"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="source"
|
||||||
|
value="pin"
|
||||||
|
id="location-option-pin"
|
||||||
|
{{if eq .UserLocation.Source "pin"}}checked{{end}}>
|
||||||
|
<i class="fa fa-map-pin ml-2 mr-1 has-text-info"></i>
|
||||||
|
Drop a pin on the map myself
|
||||||
|
</label>
|
||||||
|
<p class="help mb-4">
|
||||||
|
Use the map below and drop a pin anywhere you like to set your location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">
|
||||||
|
Your Current Location (click "Save" when you are satisfied)
|
||||||
|
</label>
|
||||||
|
<p class="block">
|
||||||
|
Your location will be saved as the following in the database:
|
||||||
|
</p>
|
||||||
|
<div class="columns is-mobile" style="max-width: 500px">
|
||||||
|
<div class="column is-half pr-1">
|
||||||
|
<label class="label mb-0">Latitude:</label>
|
||||||
|
<input type="text" class="input is-fullwidth"
|
||||||
|
name="latitude"
|
||||||
|
id="saveLatitude"
|
||||||
|
value="{{.UserLocation.Latitude}}"
|
||||||
|
placeholder="None"
|
||||||
|
readonly>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half pl-1">
|
||||||
|
<label class="label mb-0">Longitude:</label>
|
||||||
|
<input type="text" class="input is-fullwidth"
|
||||||
|
name="longitude"
|
||||||
|
id="saveLongitude"
|
||||||
|
value="{{.UserLocation.Longitude}}"
|
||||||
|
placeholder="None"
|
||||||
|
readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" class="button is-success"
|
||||||
|
name="intent" value="location">
|
||||||
|
Save My Location Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="is-size-3">Map</h3>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
This map shows your current location pin. To click and drop a pin manually,
|
||||||
|
select the "Drop a pin on the map myself" option above. Otherwise, the map will
|
||||||
|
center on your GPS location (if available) or your IP address location, depending
|
||||||
|
on your selection above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="map" class="block" style="width: 100%; height: 450px"></div>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
Map tiles provided by <a href="https://www.openstreetmap.org" target="_blank">OpenStreetMap.</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Settings -->
|
<!-- Account Settings -->
|
||||||
<div id="account">
|
<div id="account">
|
||||||
<form method="POST" action="/settings">
|
<form method="POST" action="/settings">
|
||||||
|
@ -485,17 +635,21 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
|
<link rel="stylesheet" href="/static/js/openlayers-7.5.1/en/latest/ol/ol.css">
|
||||||
|
<script src="/static/js/openlayers-7.5.1/en/latest/ol/dist/ol.js"></script>
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("DOMContentLoaded", (event) => {
|
window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
// The tabs
|
// The tabs
|
||||||
const $profile = document.querySelector("#profile"),
|
const $profile = document.querySelector("#profile"),
|
||||||
$prefs = document.querySelector("#prefs"),
|
$prefs = document.querySelector("#prefs"),
|
||||||
$account = document.querySelector("#account")
|
$location = document.querySelector("#location"),
|
||||||
|
$account = document.querySelector("#account"),
|
||||||
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
||||||
|
|
||||||
// Hide all by default.
|
// Hide all by default.
|
||||||
$profile.style.display = 'none';
|
$profile.style.display = 'none';
|
||||||
$prefs.style.display = 'none';
|
$prefs.style.display = 'none';
|
||||||
|
$location.style.display = 'none';
|
||||||
$account.style.display = 'none';
|
$account.style.display = 'none';
|
||||||
|
|
||||||
// Current tab to select by default.
|
// Current tab to select by default.
|
||||||
|
@ -509,10 +663,13 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "prefs":
|
case "prefs":
|
||||||
$activeTab = $prefs;
|
$activeTab = $prefs;
|
||||||
break
|
break;
|
||||||
|
case "location":
|
||||||
|
$activeTab = $location;
|
||||||
|
break;
|
||||||
case "account":
|
case "account":
|
||||||
$activeTab = $account;
|
$activeTab = $account;
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
$activeTab = $profile;
|
$activeTab = $profile;
|
||||||
}
|
}
|
||||||
|
@ -545,8 +702,189 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Show the requested tab on first page load.
|
||||||
showTab(window.location.hash.replace(/^#/, ''));
|
showTab(window.location.hash.replace(/^#/, ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Location tab scripts.
|
||||||
|
window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
// Get useful controls from the tab.
|
||||||
|
const $optionGPS = document.querySelector("#location-option-gps"),
|
||||||
|
$optionGeoIP = document.querySelector("#location-option-geoip"),
|
||||||
|
$optionPin = document.querySelector("#location-option-pin"),
|
||||||
|
$optionNone = document.querySelector("#location-option-none"),
|
||||||
|
$gpsCurrentStatus = document.querySelector("#location-status-gps"),
|
||||||
|
$gpsRefreshButton = document.querySelector("#gps-refresh"),
|
||||||
|
$saveLatitude = document.querySelector("#saveLatitude"),
|
||||||
|
$saveLongitude = document.querySelector("#saveLongitude");
|
||||||
|
|
||||||
|
// Function to massage coordinates for saving in the DB.
|
||||||
|
const saveCoords = (latitude, longitude) => {
|
||||||
|
$saveLatitude.value = latitude === null ? "" : latitude.toFixed(2);
|
||||||
|
$saveLongitude.value = longitude === null ? "" : longitude.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A lazy-initialized function to handle setting a new pin on the OpenStreetMap widget.
|
||||||
|
let setMapPin = null; // function(lonLat)
|
||||||
|
|
||||||
|
// The user's GeoIP coordinates, if available.
|
||||||
|
let geoIPInsights = {{ToJSON .GeoIPInsights}},
|
||||||
|
savedLocation = {{.UserLocation}};
|
||||||
|
|
||||||
|
// Default GPS coords to select on the map on page load:
|
||||||
|
// whatever we get from the backend DB.
|
||||||
|
let defaultCoords = [12.5, 41.9];
|
||||||
|
if (savedLocation.Latitude != 0 || savedLocation.Longitude != 0) {
|
||||||
|
// Prefer the DB saved coords.
|
||||||
|
defaultCoords = [
|
||||||
|
savedLocation.Longitude,
|
||||||
|
savedLocation.Latitude,
|
||||||
|
];
|
||||||
|
} else if (geoIPInsights.Latitude != 0 || geoIPInsights.Longitude != 0) {
|
||||||
|
// Then fall back on GeoIP coords.
|
||||||
|
defaultCoords = [
|
||||||
|
geoIPInsights.Longitude,
|
||||||
|
geoIPInsights.Latitude,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is geolocation not supported?
|
||||||
|
if (navigator.geolocation == undefined) {
|
||||||
|
$gpsCurrentStatus.className = "";
|
||||||
|
$gpsCurrentStatus.classList.add("has-text-danger");
|
||||||
|
$gpsCurrentStatus.innerHTML = "Geolocation is not supported by your browser.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geolocation callback funcs.
|
||||||
|
const onSuccess = (pos) => {
|
||||||
|
const crd = pos.coords;
|
||||||
|
$gpsCurrentStatus.className = "";
|
||||||
|
$gpsCurrentStatus.classList.add("has-text-success-dark");
|
||||||
|
$gpsCurrentStatus.innerHTML = `Lat: ${crd.latitude}; Long: ${crd.longitude}; Accuracy: ${crd.accuracy}`;
|
||||||
|
$gpsRefreshButton.style.display = "";
|
||||||
|
|
||||||
|
// Set the form fields.
|
||||||
|
saveCoords(crd.latitude, crd.longitude);
|
||||||
|
|
||||||
|
// Can we drop a pin?
|
||||||
|
if (setMapPin !== null) {
|
||||||
|
setMapPin([crd.longitude, crd.latitude]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onError = (err) => {
|
||||||
|
$gpsCurrentStatus.className = "";
|
||||||
|
$gpsCurrentStatus.classList.add("has-text-danger");
|
||||||
|
$gpsCurrentStatus.innerHTML = `${err.message} (code ${err.code})`;
|
||||||
|
$gpsRefreshButton.style.display = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get or refresh their location.
|
||||||
|
const getLocation = () => {
|
||||||
|
if (navigator.geolocation != undefined) {
|
||||||
|
$gpsRefreshButton.style.display = "none";
|
||||||
|
$gpsCurrentStatus.className = "";
|
||||||
|
$gpsCurrentStatus.classList.add("has-text-info");
|
||||||
|
$gpsCurrentStatus.innerHTML = "Requesting your current location...";
|
||||||
|
navigator.geolocation.getCurrentPosition(onSuccess, onError, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set an onClick handler for the GPS location tab, to request
|
||||||
|
// permission from the user's browser.
|
||||||
|
$optionGPS.addEventListener("click", (e) => {
|
||||||
|
getLocation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire the refresh button.
|
||||||
|
$gpsRefreshButton.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
getLocation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the user goes back to GeoIP coords, reinitialize the defaults.
|
||||||
|
$optionGeoIP.addEventListener("click", (e) => {
|
||||||
|
// Set the form fields.
|
||||||
|
// NOTE: defaultCoords are [lon, lat] we want [lat, lon]
|
||||||
|
saveCoords(defaultCoords[1], defaultCoords[0]);
|
||||||
|
|
||||||
|
if (setMapPin !== null) {
|
||||||
|
setMapPin(defaultCoords);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the user disables their geolocation.
|
||||||
|
$optionNone.addEventListener("click", (e) => {
|
||||||
|
saveCoords(null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/***
|
||||||
|
* OpenLayers Map Widget
|
||||||
|
***/
|
||||||
|
|
||||||
|
let vectorSource = new ol.source.Vector(),
|
||||||
|
vectorLayer = new ol.layer.Vector({
|
||||||
|
source: vectorSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
let map = new ol.Map({
|
||||||
|
target: 'map', // the <div id="map"> target
|
||||||
|
layers: [
|
||||||
|
new ol.layer.Tile({
|
||||||
|
source: new ol.source.OSM()
|
||||||
|
}),
|
||||||
|
vectorLayer
|
||||||
|
],
|
||||||
|
|
||||||
|
// The view allows to specify the center, resolution, and rotation of the map.
|
||||||
|
view: new ol.View({
|
||||||
|
center: ol.proj.fromLonLat(defaultCoords),
|
||||||
|
zoom: 10
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define the function to drop a pin.
|
||||||
|
let onMapClick = (coordinate, { center=true }) => {
|
||||||
|
vectorSource.clear();
|
||||||
|
let feature = new ol.Feature(new ol.geom.Point(coordinate));
|
||||||
|
vectorSource.addFeatures([feature]);
|
||||||
|
|
||||||
|
let prettyCoord = ol.coordinate.toStringHDMS(
|
||||||
|
ol.proj.transform(coordinate, 'EPSG:3857', 'EPSG:4326'),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
console.log("COORDINATE: " + prettyCoord);
|
||||||
|
|
||||||
|
// Center the map on the pin.
|
||||||
|
if (center) {
|
||||||
|
map.setView(new ol.View({
|
||||||
|
center: coordinate,
|
||||||
|
zoom: 10
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setMapPin = (lonLat) => {
|
||||||
|
let center = ol.proj.fromLonLat(lonLat);
|
||||||
|
onMapClick(ol.proj.fromLonLat(lonLat), {center: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', (e) => {
|
||||||
|
// Do not drop a pin if the option isn't set.
|
||||||
|
if (!$optionPin.checked) return;
|
||||||
|
|
||||||
|
// Save the selected coords to the form.
|
||||||
|
let center = ol.proj.toLonLat(e.coordinate);
|
||||||
|
saveCoords(center[1], center[0]);
|
||||||
|
|
||||||
|
onMapClick(e.coordinate, {center: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop the initial pin at the default coords.
|
||||||
|
setMapPin(defaultCoords);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user