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
|
||||
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
|
||||
|
||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"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/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -75,6 +76,12 @@ func Dashboard() http.HandlerFunc {
|
|||
_, 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{}{
|
||||
"Notifications": notifs,
|
||||
"NotifMap": notifMap,
|
||||
|
|
|
@ -20,6 +20,7 @@ func Search() http.HandlerFunc {
|
|||
"created_at desc",
|
||||
"username",
|
||||
"lower(name)",
|
||||
"distance",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -103,6 +104,10 @@ func Search() http.HandlerFunc {
|
|||
|
||||
// Photo counts mapped to 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 {
|
||||
|
|
|
@ -4,10 +4,12 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
nm "net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/mail"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -122,6 +124,36 @@ func Settings() http.HandlerFunc {
|
|||
}
|
||||
|
||||
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":
|
||||
hashtag = "#account"
|
||||
var (
|
||||
|
@ -210,6 +242,14 @@ func Settings() http.HandlerFunc {
|
|||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
@ -3,6 +3,7 @@ package geoip
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -12,6 +13,70 @@ import (
|
|||
"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.
|
||||
func GetRequestCity(r *http.Request) (*geoip2.City, error) {
|
||||
var (
|
||||
|
|
|
@ -29,6 +29,7 @@ func DeleteUser(user *models.User) error {
|
|||
{"Subscriptions", DeleteSubscriptions},
|
||||
{"Photos", DeleteUserPhotos},
|
||||
{"Certification Photo", DeleteCertification},
|
||||
{"Who's Nearby Locations", DeleteUserLocation},
|
||||
{"Comment Photos", DeleteUserCommentPhotos},
|
||||
{"Messages", DeleteUserMessages},
|
||||
{"Friends", DeleteFriends},
|
||||
|
@ -139,6 +140,16 @@ func DeleteCertification(userID uint64) error {
|
|||
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.
|
||||
func DeleteUserMessages(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteUserMessages(%d)", userID)
|
||||
|
|
|
@ -28,4 +28,5 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&PollVote{})
|
||||
DB.AutoMigrate(&AdminGroup{})
|
||||
DB.AutoMigrate(&AdminScope{})
|
||||
DB.AutoMigrate(&UserLocation{})
|
||||
}
|
||||
|
|
|
@ -203,11 +203,40 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
|||
var (
|
||||
users = []*User{}
|
||||
query *gorm.DB
|
||||
joins string // GPS location join.
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
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 {
|
||||
wheres = append(wheres, "id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
|
@ -271,7 +300,11 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
|||
placeholders = append(placeholders, date)
|
||||
}
|
||||
|
||||
query = (&User{}).Preload().Where(
|
||||
query = (&User{}).Preload()
|
||||
if joins != "" {
|
||||
query = query.Joins(joins)
|
||||
}
|
||||
query = query.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).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="container">
|
||||
<h1 class="title">
|
||||
<i class="fa fa-people-group mr-2"></i>
|
||||
People
|
||||
{{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>
|
||||
People
|
||||
{{end}}
|
||||
</h1>
|
||||
<h2 class="subtitle">Member Directory</h2>
|
||||
</div>
|
||||
|
@ -24,6 +29,22 @@
|
|||
</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="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="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="distance"{{if eq .Sort "distance"}} selected{{end}}>Distance to me</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -260,6 +282,13 @@
|
|||
</small>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Ordered by distance? -->
|
||||
{{if eq $Root.Sort "distance"}}
|
||||
<div>
|
||||
{{$Root.DistanceMap.Get .ID}} away
|
||||
</div>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div><!-- media-block -->
|
||||
|
|
|
@ -24,7 +24,11 @@
|
|||
Preferences
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/settings#location" class="nonshy-tab-button">
|
||||
Location
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/settings#account" class="nonshy-tab-button">
|
||||
Account
|
||||
|
@ -33,6 +37,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="card" id="profile">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
|
@ -400,6 +405,151 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<div id="account">
|
||||
<form method="POST" action="/settings">
|
||||
|
@ -485,17 +635,21 @@
|
|||
</div>
|
||||
{{end}}
|
||||
{{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>
|
||||
window.addEventListener("DOMContentLoaded", (event) => {
|
||||
// The tabs
|
||||
const $profile = document.querySelector("#profile"),
|
||||
$prefs = document.querySelector("#prefs"),
|
||||
$account = document.querySelector("#account")
|
||||
$location = document.querySelector("#location"),
|
||||
$account = document.querySelector("#account"),
|
||||
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
||||
|
||||
// Hide all by default.
|
||||
$profile.style.display = 'none';
|
||||
$prefs.style.display = 'none';
|
||||
$location.style.display = 'none';
|
||||
$account.style.display = 'none';
|
||||
|
||||
// Current tab to select by default.
|
||||
|
@ -509,10 +663,13 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
switch (name) {
|
||||
case "prefs":
|
||||
$activeTab = $prefs;
|
||||
break
|
||||
break;
|
||||
case "location":
|
||||
$activeTab = $location;
|
||||
break;
|
||||
case "account":
|
||||
$activeTab = $account;
|
||||
break
|
||||
break;
|
||||
default:
|
||||
$activeTab = $profile;
|
||||
}
|
||||
|
@ -545,8 +702,189 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
});
|
||||
})
|
||||
|
||||
|
||||
// Show the requested tab on first page load.
|
||||
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>
|
||||
{{end}}
|
||||
|
|
Loading…
Reference in New Issue
Block a user