Search By Location
* Add a world cities database with type-ahead search on the Member Directory. * Users can search for a known city to order users by distance from that city rather than from their own configured location on their settings page. * Users must opt-in their own location before this feature may be used, in order to increase adoption of the location feature and to enforce fairness. * The `nonshy setup locations` command can import the world cities database.
This commit is contained in:
parent
6ca94cb926
commit
b8be14ea8d
|
@ -221,6 +221,30 @@ func main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "setup",
|
||||||
|
Usage: "setup and data import functions for the website",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "locations",
|
||||||
|
Usage: "import the database of world city locations from simplemaps.com",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "input",
|
||||||
|
Aliases: []string{"i"},
|
||||||
|
Required: true,
|
||||||
|
Usage: "the input worldcities.csv from simplemaps, with required headers: id, city, lat, lng, country, iso2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
initdb(c)
|
||||||
|
|
||||||
|
filename := c.String("input")
|
||||||
|
return models.InitializeWorldCities(filename)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "backfill",
|
Name: "backfill",
|
||||||
Usage: "One-off maintenance tasks and data backfills for database migrations",
|
Usage: "One-off maintenance tasks and data backfills for database migrations",
|
||||||
|
|
|
@ -36,6 +36,7 @@ func Search() http.HandlerFunc {
|
||||||
isCertified = r.FormValue("certified")
|
isCertified = r.FormValue("certified")
|
||||||
username = r.FormValue("name") // username search
|
username = r.FormValue("name") // username search
|
||||||
searchTerm = r.FormValue("search") // profile text search
|
searchTerm = r.FormValue("search") // profile text search
|
||||||
|
citySearch = r.FormValue("wcs")
|
||||||
gender = r.FormValue("gender")
|
gender = r.FormValue("gender")
|
||||||
orientation = r.FormValue("orientation")
|
orientation = r.FormValue("orientation")
|
||||||
maritalStatus = r.FormValue("marital_status")
|
maritalStatus = r.FormValue("marital_status")
|
||||||
|
@ -96,6 +97,24 @@ func Search() http.HandlerFunc {
|
||||||
log.Error("RefreshGeoIP: %s", err)
|
log.Error("RefreshGeoIP: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are they doing a Location search (from world city typeahead)?
|
||||||
|
var city *models.WorldCities
|
||||||
|
if citySearch != "" {
|
||||||
|
sort = "distance"
|
||||||
|
|
||||||
|
// Require the current user to have THEIR location set, for fairness.
|
||||||
|
if myLocation.Source == models.LocationSourceNone {
|
||||||
|
session.FlashError(w, r, "You must set your own location before you can search for others by their location.")
|
||||||
|
} else {
|
||||||
|
// Look up the coordinates of their search.
|
||||||
|
city, err = models.FindWorldCity(citySearch)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Location search: no match was found for '%s', please use one of the exact search results from the type-ahead on the Location field.", citySearch)
|
||||||
|
citySearch = "" // null out their search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort options.
|
// Sort options.
|
||||||
for _, v := range sortWhitelist {
|
for _, v := range sortWhitelist {
|
||||||
if sort == v {
|
if sort == v {
|
||||||
|
@ -138,6 +157,7 @@ func Search() http.HandlerFunc {
|
||||||
HereFor: hereFor,
|
HereFor: hereFor,
|
||||||
ProfileText: search,
|
ProfileText: search,
|
||||||
Certified: certifiedOnly,
|
Certified: certifiedOnly,
|
||||||
|
NearCity: city,
|
||||||
NotCertified: isCertified == "false",
|
NotCertified: isCertified == "false",
|
||||||
InnerCircle: isCertified == "circle",
|
InnerCircle: isCertified == "circle",
|
||||||
ShyAccounts: isCertified == "shy",
|
ShyAccounts: isCertified == "shy",
|
||||||
|
@ -183,6 +203,7 @@ func Search() http.HandlerFunc {
|
||||||
"HereFor": hereFor,
|
"HereFor": hereFor,
|
||||||
"EmailOrUsername": username,
|
"EmailOrUsername": username,
|
||||||
"Search": searchTerm,
|
"Search": searchTerm,
|
||||||
|
"City": citySearch,
|
||||||
"AgeMin": ageMin,
|
"AgeMin": ageMin,
|
||||||
"AgeMax": ageMax,
|
"AgeMax": ageMax,
|
||||||
"FriendSearch": friendSearch,
|
"FriendSearch": friendSearch,
|
||||||
|
@ -208,7 +229,7 @@ func Search() http.HandlerFunc {
|
||||||
// Current user's location setting.
|
// Current user's location setting.
|
||||||
"MyLocation": myLocation,
|
"MyLocation": myLocation,
|
||||||
"GeoIPInsights": insights,
|
"GeoIPInsights": insights,
|
||||||
"DistanceMap": models.MapDistances(currentUser, users),
|
"DistanceMap": models.MapDistances(currentUser, city, users),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -49,3 +49,17 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
w.Write(buf)
|
w.Write(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendRawJSON response without the standard API wrapper.
|
||||||
|
func SendRawJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
||||||
|
buf, err := json.Marshal(v)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
w.Write(buf)
|
||||||
|
}
|
||||||
|
|
34
pkg/controller/api/world_cities.go
Normal file
34
pkg/controller/api/world_cities.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorldCities API searches the location database for a world city location.
|
||||||
|
func WorldCities() http.HandlerFunc {
|
||||||
|
type Result struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var query = r.FormValue("query")
|
||||||
|
if query == "" {
|
||||||
|
SendRawJSON(w, http.StatusOK, []Result{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := models.SearchWorldCities(query)
|
||||||
|
if err != nil {
|
||||||
|
SendRawJSON(w, http.StatusInternalServerError, []Result{{
|
||||||
|
ID: 1,
|
||||||
|
Value: err.Error(),
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendRawJSON(w, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
|
@ -34,4 +34,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&ChangeLog{})
|
DB.AutoMigrate(&ChangeLog{})
|
||||||
DB.AutoMigrate(&IPAddress{})
|
DB.AutoMigrate(&IPAddress{})
|
||||||
DB.AutoMigrate(&PushNotification{})
|
DB.AutoMigrate(&PushNotification{})
|
||||||
|
DB.AutoMigrate(&WorldCities{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -259,6 +259,7 @@ type UserSearch struct {
|
||||||
MaritalStatus string
|
MaritalStatus string
|
||||||
HereFor string
|
HereFor string
|
||||||
ProfileText *Search
|
ProfileText *Search
|
||||||
|
NearCity *WorldCities
|
||||||
Certified bool
|
Certified bool
|
||||||
NotCertified bool
|
NotCertified bool
|
||||||
InnerCircle bool
|
InnerCircle bool
|
||||||
|
@ -289,7 +290,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sort by distance? Requires PostgreSQL.
|
// Sort by distance? Requires PostgreSQL.
|
||||||
if pager.Sort == "distance" {
|
if pager.Sort == "distance" || search.NearCity != nil {
|
||||||
if !config.Current.Database.IsPostgres {
|
if !config.Current.Database.IsPostgres {
|
||||||
return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension")
|
return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension")
|
||||||
}
|
}
|
||||||
|
@ -299,6 +300,15 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
return users, errors.New("can not sort members by distance because your location is not known")
|
return users, errors.New("can not sort members by distance because your location is not known")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which location to search from?
|
||||||
|
var (
|
||||||
|
latitude, longitude = myLocation.Latitude, myLocation.Longitude
|
||||||
|
)
|
||||||
|
if search.NearCity != nil {
|
||||||
|
latitude = search.NearCity.Latitude
|
||||||
|
longitude = search.NearCity.Longitude
|
||||||
|
}
|
||||||
|
|
||||||
// Only query for users who have locations.
|
// Only query for users who have locations.
|
||||||
joins = "JOIN user_locations ON (user_locations.user_id = users.id)"
|
joins = "JOIN user_locations ON (user_locations.user_id = users.id)"
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
|
@ -311,7 +321,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
pager.Sort = fmt.Sprintf(`ST_Distance(
|
pager.Sort = fmt.Sprintf(`ST_Distance(
|
||||||
ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography,
|
ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography,
|
||||||
ST_MakePoint(%f, %f)::geography)`,
|
ST_MakePoint(%f, %f)::geography)`,
|
||||||
myLocation.Longitude, myLocation.Latitude,
|
longitude, latitude,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -69,11 +69,15 @@ func truncate(f float64) float64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapDistances computes human readable distances between you and the set of users.
|
// MapDistances computes human readable distances between you and the set of users.
|
||||||
func MapDistances(currentUser *User, others []*User) DistanceMap {
|
//
|
||||||
|
// The fromCity attribute is optional. If non-nil, the distance will be computed in
|
||||||
|
// relation to that city's location instead of the current user.
|
||||||
|
func MapDistances(currentUser *User, fromCity *WorldCities, others []*User) DistanceMap {
|
||||||
// Get all the distances we can.
|
// Get all the distances we can.
|
||||||
var (
|
var (
|
||||||
result = DistanceMap{}
|
result = DistanceMap{}
|
||||||
myDist = GetUserLocation(currentUser.ID)
|
myDist = GetUserLocation(currentUser.ID)
|
||||||
|
latitude, longitude = myDist.Latitude, myDist.Longitude
|
||||||
// dist = map[uint64]*UserLocation{}
|
// dist = map[uint64]*UserLocation{}
|
||||||
userIDs = []uint64{}
|
userIDs = []uint64{}
|
||||||
)
|
)
|
||||||
|
@ -81,6 +85,12 @@ func MapDistances(currentUser *User, others []*User) DistanceMap {
|
||||||
userIDs = append(userIDs, user.ID)
|
userIDs = append(userIDs, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are we ordering from a different city?
|
||||||
|
if fromCity != nil {
|
||||||
|
latitude = fromCity.Latitude
|
||||||
|
longitude = fromCity.Longitude
|
||||||
|
}
|
||||||
|
|
||||||
// Query for their UserLocation objects, if exists.
|
// Query for their UserLocation objects, if exists.
|
||||||
var ul = []*UserLocation{}
|
var ul = []*UserLocation{}
|
||||||
res := DB.Where("user_id IN ?", userIDs).Find(&ul)
|
res := DB.Where("user_id IN ?", userIDs).Find(&ul)
|
||||||
|
@ -92,7 +102,7 @@ func MapDistances(currentUser *User, others []*User) DistanceMap {
|
||||||
// Map them out.
|
// Map them out.
|
||||||
for _, row := range ul {
|
for _, row := range ul {
|
||||||
km, mi := HaversineDistance(
|
km, mi := HaversineDistance(
|
||||||
myDist.Latitude, myDist.Longitude,
|
latitude, longitude,
|
||||||
row.Latitude, row.Longitude,
|
row.Latitude, row.Longitude,
|
||||||
)
|
)
|
||||||
result[row.UserID] = fmt.Sprintf("%.1fkm / %.1fmi", km, mi)
|
result[row.UserID] = fmt.Sprintf("%.1fkm / %.1fmi", km, mi)
|
||||||
|
|
152
pkg/models/world_cities.go
Normal file
152
pkg/models/world_cities.go
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorldCities is a lookup database of cities/countries to their geo coordinates.
|
||||||
|
//
|
||||||
|
// It aids the search by location feature of the member directory. Data obtained from simplemaps.com
|
||||||
|
type WorldCities struct {
|
||||||
|
ID uint64 `gorm:"primary_key"`
|
||||||
|
City string
|
||||||
|
State string
|
||||||
|
Country string
|
||||||
|
ISO string
|
||||||
|
Canonical string `gorm:"index"` // the full City, State, ISO string
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWorldCities handles a type-ahead search query.
|
||||||
|
func SearchWorldCities(query string) ([]*WorldCities, error) {
|
||||||
|
var (
|
||||||
|
like = fmt.Sprintf("%%%s%%", strings.ToLower(query))
|
||||||
|
result = []*WorldCities{}
|
||||||
|
tx = DB.Model(&WorldCities{}).Where(
|
||||||
|
"canonical ILIKE ?",
|
||||||
|
like,
|
||||||
|
).Limit(50).Scan(&result)
|
||||||
|
)
|
||||||
|
return result, tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindWorldCity looks up a world city by its Canonical name from typeahead search.
|
||||||
|
func FindWorldCity(canonical string) (*WorldCities, error) {
|
||||||
|
var (
|
||||||
|
city *WorldCities
|
||||||
|
result = DB.Model(&WorldCities{}).Where("canonical = ?", canonical).First(&city)
|
||||||
|
)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
return city, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeWorldCities from an input CSV spreadsheet from simplemaps.com.
|
||||||
|
//
|
||||||
|
// This will wipe and reset the WorldCities table from the imported spreadsheet.
|
||||||
|
//
|
||||||
|
// The CSV file needs at least the columns: id, city, admin_name, country, iso2, lat, lng.
|
||||||
|
//
|
||||||
|
// The CSV can be downloaded from: https://simplemaps.com/data/world-cities
|
||||||
|
func InitializeWorldCities(csvFilename string) error {
|
||||||
|
fh, err := os.Open(csvFilename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := csv.NewReader(fh)
|
||||||
|
|
||||||
|
// Read the header row and find the required fields.
|
||||||
|
fields := map[string]*int{
|
||||||
|
"id": nil,
|
||||||
|
"city": nil,
|
||||||
|
"admin_name": nil,
|
||||||
|
"country": nil,
|
||||||
|
"iso2": nil,
|
||||||
|
"lat": nil,
|
||||||
|
"lng": nil,
|
||||||
|
}
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CSV header row: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the header fields to their index.
|
||||||
|
for i, field := range header {
|
||||||
|
if _, ok := fields[field]; ok {
|
||||||
|
fields[field] = &i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check that all header fields are found.
|
||||||
|
var errHeader error
|
||||||
|
for k, v := range fields {
|
||||||
|
if v == nil {
|
||||||
|
log.Error("WorldCities CSV: required header %s not found", k)
|
||||||
|
errHeader = errors.New("missing one or more required csv headers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errHeader != nil {
|
||||||
|
return errHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action!
|
||||||
|
|
||||||
|
// Flush the existing WorldCities table.
|
||||||
|
if tx := DB.Exec("DELETE FROM world_cities"); tx.Error != nil {
|
||||||
|
return fmt.Errorf("deleting world_cities: %s", tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the database.
|
||||||
|
for {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast data types.
|
||||||
|
id, err1 := strconv.ParseUint(row[*fields["id"]], 10, 64)
|
||||||
|
lat, err2 := strconv.ParseFloat(row[*fields["lat"]], 64)
|
||||||
|
lon, err3 := strconv.ParseFloat(row[*fields["lng"]], 64)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return fmt.Errorf("row %+v failed to cast one or more data types: id (%s), lat (%s), lng (%s)", row, err1, err2, err3)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &WorldCities{
|
||||||
|
ID: id,
|
||||||
|
City: row[*fields["city"]],
|
||||||
|
State: row[*fields["admin_name"]],
|
||||||
|
Country: row[*fields["country"]],
|
||||||
|
ISO: row[*fields["iso2"]],
|
||||||
|
Latitude: lat,
|
||||||
|
Longitude: lon,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical string for search.
|
||||||
|
var canonical string
|
||||||
|
if record.State != "" {
|
||||||
|
canonical = fmt.Sprintf("%s, %s, %s", record.City, record.State, record.ISO)
|
||||||
|
} else {
|
||||||
|
canonical = fmt.Sprintf("%s, %s", record.City, record.ISO)
|
||||||
|
}
|
||||||
|
record.Canonical = canonical
|
||||||
|
|
||||||
|
result := DB.Create(&record)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("create row %+v: %s", record, result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("WorldCities: loaded %s, %s", record.City, record.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -121,6 +121,7 @@ func New() http.Handler {
|
||||||
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
||||||
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
||||||
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
|
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
|
||||||
|
mux.Handle("GET /v1/world-cities", middleware.LoginRequired(api.WorldCities()))
|
||||||
mux.Handle("POST /v1/barertc/report", barertc.Report())
|
mux.Handle("POST /v1/barertc/report", barertc.Report())
|
||||||
mux.Handle("POST /v1/barertc/profile", barertc.Profile())
|
mux.Handle("POST /v1/barertc/profile", barertc.Profile())
|
||||||
|
|
||||||
|
|
2
web/static/js/jquery-3.7.1.min.js
vendored
Normal file
2
web/static/js/jquery-3.7.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2451
web/static/js/typeahead.bundle.js
Normal file
2451
web/static/js/typeahead.bundle.js
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -39,6 +39,20 @@
|
||||||
Showing you <i class="fa fa-location-dot mr-1"></i> <strong>Who's Nearby.</strong>
|
Showing you <i class="fa fa-location-dot mr-1"></i> <strong>Who's Nearby.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Was it a manual city search? -->
|
||||||
|
{{if .City}}
|
||||||
|
<!-- If the current user has no location set, lead the way. -->
|
||||||
|
{{if not .MyLocation.Source}}
|
||||||
|
<p>
|
||||||
|
You will need to <a href="/settings#location">set your own location</a> first before you can search for other members by
|
||||||
|
their location. It's only fair!
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p>
|
||||||
|
You are searching for profiles near {{.City}}.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
<!-- Show options to refresh their location -->
|
<!-- Show options to refresh their location -->
|
||||||
<p>
|
<p>
|
||||||
{{if eq .MyLocation.Source "geoip"}}
|
{{if eq .MyLocation.Source "geoip"}}
|
||||||
|
@ -54,6 +68,7 @@
|
||||||
You will need to <a href="/settings#location">set your location</a> first before we can sort people by distance from you.
|
You will need to <a href="/settings#location">set your location</a> first before we can sort people by distance from you.
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else if eq .Certified "shy"}}
|
{{else if eq .Certified "shy"}}
|
||||||
<div class="notification is-success is-light content">
|
<div class="notification is-success is-light content">
|
||||||
|
@ -153,7 +168,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-half pl-1">
|
<div class="column px-1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="search">Profile text:</label>
|
<label class="label" for="search">Profile text:</label>
|
||||||
<input type="text" class="input"
|
<input type="text" class="input"
|
||||||
|
@ -168,6 +183,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="column pl-1">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="wcs">Location: <span class="tag is-success">New!</span></label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="wcs" id="wcs"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{$Root.City}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
|
|
||||||
|
@ -470,3 +495,78 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
|
||||||
|
<!-- Typeahead search for City -->
|
||||||
|
<script type="text/javascript" src="/static/js/jquery-3.7.1.min.js"></script>
|
||||||
|
<script type="text/javascript" src="/static/js/typeahead.bundle.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', (e) => {
|
||||||
|
let backend = new Bloodhound({
|
||||||
|
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
|
||||||
|
queryTokenizer: Bloodhound.tokenizers.whitespace,
|
||||||
|
remote: {
|
||||||
|
url: '/v1/world-cities?query=%QUERY',
|
||||||
|
wildcard: '%QUERY'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let $searchField = $("#wcs");
|
||||||
|
$searchField.typeahead({
|
||||||
|
autoselect: true
|
||||||
|
}, {
|
||||||
|
items: 4,
|
||||||
|
name: 'location',
|
||||||
|
displayKey: 'Canonical',
|
||||||
|
source: backend,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
/*****************************
|
||||||
|
* Twitter Typeahead Styling *
|
||||||
|
*****************************/
|
||||||
|
.twitter-typeahead {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-hint {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-menu {
|
||||||
|
width: 300px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||||
|
-moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||||
|
box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion {
|
||||||
|
padding: 3px 20px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion.tt-cursor {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0097cf;
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
Loading…
Reference in New Issue
Block a user