Who's Nearby Feature

This commit is contained in:
Noah Petherbridge 2023-08-19 19:11:33 -07:00
parent 73f89c7837
commit cc628afd44
14 changed files with 1041 additions and 9 deletions

View File

@ -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
@ -136,4 +150,4 @@ Suggested crontab:
## License ## License
GPLv3. GPLv3.

View File

@ -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,

View File

@ -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 {

View File

@ -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

View File

@ -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 (

View File

@ -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)

View File

@ -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{})
} }

View File

@ -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
View 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
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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;
}

View File

@ -6,8 +6,13 @@
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<h1 class="title"> <h1 class="title">
<i class="fa fa-people-group mr-2"></i> {{if eq .Pager.Sort "distance"}}
People <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> </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 -->

View File

@ -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}}