summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-12-09 01:07:15 +0300
committerAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-12-09 01:07:15 +0300
commit34ccc98a942098faefb5f4211b215ff9ccc7ad0e (patch)
tree7696ab4d7c8d9fb09c7e2575d482517f68824ae3
Начальный
-rw-r--r--.dockerignore3
-rw-r--r--.env4
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile33
-rw-r--r--Makefile9
-rw-r--r--cmd/frontend/frontend.go101
-rw-r--r--cmd/importer/import.go182
-rw-r--r--cmd/migrator/migrate/init.go18
-rw-r--r--cmd/migrator/migrate/migrate.go31
-rw-r--r--cmd/migrator/migrate/rollback.go31
-rw-r--r--cmd/migrator/migrator.go54
-rw-r--r--cmd/migrator/migrator/migrator.go21
-rw-r--r--cmd/serve/serve.go67
-rw-r--r--contrib/dev/docker-compose.yml31
-rw-r--r--frontend/app.css4
-rw-r--r--frontend/app.jsx207
-rw-r--r--frontend/index.jsx8
-rw-r--r--go.mod50
-rw-r--r--go.sum98
-rw-r--r--main.go30
-rw-r--r--migrations/20241005143542_init.go29
-rw-r--r--migrations/main.go7
-rw-r--r--package-lock.json1674
-rw-r--r--package.json21
-rw-r--r--pkg/api/guess.go61
-rw-r--r--pkg/api/handler.go11
-rw-r--r--pkg/api/next.go28
-rw-r--r--pkg/api/state.go45
-rw-r--r--pkg/config/config.go22
-rw-r--r--pkg/db/config.go6
-rw-r--r--pkg/db/db.go24
-rw-r--r--pkg/middleware/context.go21
-rw-r--r--pkg/middleware/session/store.go232
-rw-r--r--pkg/middleware/state.go58
-rw-r--r--pkg/model/place.go84
-rw-r--r--pkg/service/places.go70
-rw-r--r--static/.gitignore2
-rw-r--r--static/fs.go6
-rw-r--r--static/index.html13
39 files changed, 3399 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d86fd64
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+places.json
+node_modules
+static/places \ No newline at end of file
diff --git a/.env b/.env
new file mode 100644
index 0000000..c7c41bb
--- /dev/null
+++ b/.env
@@ -0,0 +1,4 @@
+DATABASE=postgres://postgres:SuperSecret@localhost:5432/app?sslmode=disable
+LISTEN=:8000
+KEYS=0eb378f61ab54dfb9a27e40d441a1b0e,3bd246204cb44062a4ae914b47b703d4
+DEBUG=true \ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..76eb3d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+places.json
+node_modules
+*.http \ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1f43c6d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# Build backend
+FROM golang:1.23.3-alpine3.20 AS backend
+WORKDIR /srv
+RUN apk update --no-cache && apk add --no-cache tzdata
+COPY go.mod go.sum ./
+RUN go mod download && go mod verify
+COPY . .
+RUN go build -o app .
+
+# Build frontend
+FROM node:23.3-alpine3.20 AS frontend
+WORKDIR /srv
+COPY package.json package-lock.json ./
+RUN npm install
+COPY frontend ./frontend
+COPY --from=backend /srv/app /usr/bin/app
+RUN app frontend build
+
+# Runtime container
+FROM alpine:3.20
+WORKDIR /srv
+RUN apk update --no-cache && apk add --no-cache ca-certificates
+
+COPY --from=backend /usr/share/zoneinfo/Europe/Moscow /usr/share/zoneinfo/Europe/Moscow
+COPY --from=backend /srv/app /srv/app
+COPY ./static/index.html /srv/static/index.html
+COPY --from=frontend /srv/static/assets /srv/static/assets
+
+ENV TZ=Europe/Moscow
+
+EXPOSE 8000
+
+ENTRYPOINT ["/srv/app"] \ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..23e3d0a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+
+devdb-up:
+ docker compose -f contrib/dev/docker-compose.yml up -d
+devdb-logs:
+ docker compose -f contrib/dev/docker-compose.yml logs -f
+devdb-down:
+ docker compose -f contrib/dev/docker-compose.yml down
+build-image:
+ docker buildx build --platform linux/amd64,linux/arm64 --builder container-builder -t registry.neonxp.ru/guessr --push . \ No newline at end of file
diff --git a/cmd/frontend/frontend.go b/cmd/frontend/frontend.go
new file mode 100644
index 0000000..fc7dbc8
--- /dev/null
+++ b/cmd/frontend/frontend.go
@@ -0,0 +1,101 @@
+package frontend
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/evanw/esbuild/pkg/api"
+ "github.com/urfave/cli/v3"
+)
+
+var Command = &cli.Command{
+ Name: "frontend",
+ Commands: []*cli.Command{
+ {
+ Name: "build",
+ Action: func(ctx context.Context, c *cli.Command) error {
+ result := api.Build(api.BuildOptions{
+ EntryPoints: []string{"./frontend/index.jsx"},
+ Outdir: "static/assets",
+ Bundle: true,
+ Write: true,
+ LogLevel: api.LogLevelInfo,
+ ChunkNames: "chunks/[name]-[hash]",
+ MinifyWhitespace: true,
+ MinifyIdentifiers: true,
+ MinifySyntax: true,
+ Splitting: false,
+ Sourcemap: api.SourceMapInline,
+ Format: api.FormatDefault,
+ Color: api.ColorAlways,
+ Define: map[string]string{
+ "process.env.NODE_ENV": `"dev"`,
+ },
+ AssetNames: "assets/[name]-[hash]",
+ Loader: map[string]api.Loader{
+ ".png": api.LoaderFile,
+ ".css": api.LoaderCSS,
+ },
+ })
+ if len(result.Errors) > 0 {
+ errs := make([]string, 0, len(result.Errors))
+ for _, e := range result.Errors {
+ errs = append(errs, fmt.Sprintf("%s: %s", e.PluginName, e.Text))
+ }
+
+ return errors.New(strings.Join(errs, ", "))
+ }
+
+ return nil
+ },
+ },
+ {
+ Name: "watch",
+ Action: func(ctx context.Context, c *cli.Command) error {
+ bctx, result := api.Context(api.BuildOptions{
+ EntryPoints: []string{"./frontend/index.jsx"},
+ Outdir: "static/assets",
+ Bundle: true,
+ Write: true,
+ LogLevel: api.LogLevelInfo,
+ ChunkNames: "chunks/[name]-[hash]",
+ MinifyWhitespace: false,
+ MinifyIdentifiers: false,
+ MinifySyntax: false,
+ Splitting: false,
+ Sourcemap: api.SourceMapInline,
+ Format: api.FormatESModule,
+ Color: api.ColorAlways,
+ Define: map[string]string{
+ "process.env.NODE_ENV": `"dev"`,
+ },
+ AssetNames: "assets/[name]-[hash]",
+ Loader: map[string]api.Loader{
+ ".png": api.LoaderFile,
+ ".css": api.LoaderCSS,
+ },
+ })
+ if result != nil && len(result.Errors) > 0 {
+ errs := make([]string, 0, len(result.Errors))
+ for _, e := range result.Errors {
+ errs = append(errs, fmt.Sprintf("%s: %s", e.PluginName, e.Text))
+ }
+
+ return errors.New(strings.Join(errs, ", "))
+ }
+
+ if err := bctx.Watch(api.WatchOptions{}); err != nil {
+ return err
+ }
+
+ fmt.Printf("watching...\n")
+
+ <-make(chan struct{})
+
+ return nil
+ },
+ },
+ },
+}
diff --git a/cmd/importer/import.go b/cmd/importer/import.go
new file mode 100644
index 0000000..f08117d
--- /dev/null
+++ b/cmd/importer/import.go
@@ -0,0 +1,182 @@
+package importer
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode/utf16"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/config"
+ "git.neonxp.ru/neonxp/guessr/pkg/db"
+ "git.neonxp.ru/neonxp/guessr/pkg/model"
+ "github.com/urfave/cli/v3"
+)
+
+var Command = &cli.Command{
+ Name: "import",
+ Usage: "import json with places",
+ Flags: []cli.Flag{
+ &cli.BoolFlag{
+ Name: "download-img",
+ Usage: "download images locally",
+ },
+ },
+ ArgsUsage: "path to json file",
+ Action: func(ctx context.Context, c *cli.Command) error {
+ downloadImg := c.Bool("download-img")
+
+ cfg, err := config.New()
+ if err != nil {
+ return err
+ }
+
+ dbClient := db.New(cfg.DB)
+
+ fp, err := os.Open(c.Args().First())
+ if err != nil {
+ return err
+ }
+ defer fp.Close()
+
+ places := Places{}
+ dec := json.NewDecoder(fp)
+
+ if err := dec.Decode(&places); err != nil {
+ return err
+ }
+
+ for _, p := range places {
+ decodedValue := unescape(p.Name)
+ h := hash(p.Img)
+ prefix := h[0:1]
+ path := filepath.Join(".", "static", "places", prefix)
+
+ file := filepath.Join(path, h+".jpg")
+
+ if downloadImg {
+ //nolint:gomnd
+ if err := os.MkdirAll(path, 0o777); err != nil {
+ return err
+ }
+
+ if err := downloadFile(p.Img, file); err != nil {
+ return err
+ }
+ }
+
+ np := model.Place{
+ GUID: p.GUID,
+ Img: strings.Replace(file, "static", "", 1),
+ Name: strings.ReplaceAll(decodedValue, "�", `"`),
+ Position: &model.Point{
+ Lat: p.Position.Lat,
+ Lon: p.Position.Lng,
+ },
+ }
+
+ if _, err := dbClient.NewInsert().Model(&np).Exec(ctx); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ },
+}
+
+type Place struct {
+ GUID string `json:"guid"`
+ Position PositionEntity `json:"position"`
+ Img string `json:"img"`
+ Name string `json:"name"`
+}
+
+type PositionEntity struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+}
+
+type Places []Place
+
+func hash(in string) string {
+ hasher := sha256.New()
+ if _, err := hasher.Write([]byte(in)); err != nil {
+ return ""
+ }
+
+ return base64.URLEncoding.EncodeToString(hasher.Sum(nil))
+}
+
+func unescape(input string) string {
+ output := make([]rune, 0, len(input))
+ length := len(input)
+
+ for index := 0; index < length; {
+ //nolint:nestif
+ if input[index] == '%' {
+ if index <= length-6 && input[index+1] == 'u' {
+ byte16, err := hex.DecodeString(input[index+2 : index+6])
+ if err == nil {
+ //nolint:gomnd
+ value := uint16(byte16[0])<<8 + uint16(byte16[1])
+ chr := utf16.Decode([]uint16{value})[0]
+ output = append(output, chr)
+ index += 6
+
+ continue
+ }
+ }
+
+ if index <= length-3 {
+ byte8, err := hex.DecodeString(input[index+1 : index+3])
+ if err == nil {
+ value := uint16(byte8[0])
+ chr := utf16.Decode([]uint16{value})[0]
+ output = append(output, chr)
+ index += 3
+
+ continue
+ }
+ }
+ }
+
+ output = append(output, rune(input[index]))
+ index++
+ }
+
+ return string(output)
+}
+
+func downloadFile(fileURL, fileName string) error {
+ //nolint:gosec,noctx
+ response, err := http.Get(fileURL)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return errors.New("received non 200 response code")
+ }
+ // Create a empty file
+ file, err := os.Create(fileName)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ // Write the bytes to the fiel
+ _, err = io.Copy(file, response.Body)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/cmd/migrator/migrate/init.go b/cmd/migrator/migrate/init.go
new file mode 100644
index 0000000..6f06c2d
--- /dev/null
+++ b/cmd/migrator/migrate/init.go
@@ -0,0 +1,18 @@
+package migrate
+
+import (
+ "context"
+
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator/migrator"
+ "git.neonxp.ru/neonxp/guessr/migrations"
+ "github.com/urfave/cli/v3"
+)
+
+func Init(ctx context.Context, c *cli.Command) error {
+ m, err := migrator.New(c, migrations.Migrations)
+ if err != nil {
+ return err
+ }
+
+ return m.Init(ctx)
+}
diff --git a/cmd/migrator/migrate/migrate.go b/cmd/migrator/migrate/migrate.go
new file mode 100644
index 0000000..6245879
--- /dev/null
+++ b/cmd/migrator/migrate/migrate.go
@@ -0,0 +1,31 @@
+package migrate
+
+import (
+ "context"
+ "fmt"
+
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator/migrator"
+ "git.neonxp.ru/neonxp/guessr/migrations"
+ "github.com/urfave/cli/v3"
+)
+
+func Migrate(ctx context.Context, c *cli.Command) error {
+ m, err := migrator.New(c, migrations.Migrations)
+ if err != nil {
+ return err
+ }
+
+ group, err := m.Migrate(ctx)
+ if err != nil {
+ return err
+ }
+
+ if group.ID == 0 {
+ fmt.Printf("there are no new migrations to run\n")
+ return nil
+ }
+
+ fmt.Printf("migrated to %s\n", group)
+
+ return nil
+}
diff --git a/cmd/migrator/migrate/rollback.go b/cmd/migrator/migrate/rollback.go
new file mode 100644
index 0000000..af56654
--- /dev/null
+++ b/cmd/migrator/migrate/rollback.go
@@ -0,0 +1,31 @@
+package migrate
+
+import (
+ "context"
+ "fmt"
+
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator/migrator"
+ "git.neonxp.ru/neonxp/guessr/migrations"
+ "github.com/urfave/cli/v3"
+)
+
+func Rollback(ctx context.Context, c *cli.Command) error {
+ m, err := migrator.New(c, migrations.Migrations)
+ if err != nil {
+ return err
+ }
+
+ group, err := m.Rollback(ctx)
+ if err != nil {
+ return err
+ }
+
+ if group.ID == 0 {
+ fmt.Printf("there are no groups to roll back\n")
+ return nil
+ }
+
+ fmt.Printf("rolled back %s\n", group)
+
+ return nil
+}
diff --git a/cmd/migrator/migrator.go b/cmd/migrator/migrator.go
new file mode 100644
index 0000000..31f8dab
--- /dev/null
+++ b/cmd/migrator/migrator.go
@@ -0,0 +1,54 @@
+package migrator
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator/migrate"
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator/migrator"
+ "git.neonxp.ru/neonxp/guessr/migrations"
+ "github.com/urfave/cli/v3"
+)
+
+var Command = &cli.Command{
+ Name: "db",
+ Usage: "manage database migrations",
+ Commands: []*cli.Command{
+ {
+ Name: "init",
+ Usage: "create migration tables",
+ Action: migrate.Init,
+ },
+ {
+ Name: "migrate",
+ Usage: "migrate database",
+ Action: migrate.Migrate,
+ },
+ {
+ Name: "rollback",
+ Usage: "rollback the last migration group",
+ Action: migrate.Rollback,
+ },
+
+ {
+ Name: "create",
+ Usage: "create migration",
+ Action: func(ctx context.Context, c *cli.Command) error {
+ migrator, err := migrator.New(c, migrations.Migrations)
+ if err != nil {
+ return err
+ }
+
+ name := strings.Join(c.Args().Slice(), "_")
+ mf, err := migrator.CreateGoMigration(ctx, name)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path)
+
+ return nil
+ },
+ },
+ },
+}
diff --git a/cmd/migrator/migrator/migrator.go b/cmd/migrator/migrator/migrator.go
new file mode 100644
index 0000000..f487e64
--- /dev/null
+++ b/cmd/migrator/migrator/migrator.go
@@ -0,0 +1,21 @@
+package migrator
+
+import (
+ "git.neonxp.ru/neonxp/guessr/pkg/config"
+ "git.neonxp.ru/neonxp/guessr/pkg/db"
+ "github.com/uptrace/bun/migrate"
+ "github.com/urfave/cli/v3"
+)
+
+func New(c *cli.Command, mig *migrate.Migrations) (*migrate.Migrator, error) {
+ cfg, err := config.New()
+ if err != nil {
+ return nil, err
+ }
+
+ dbClient := db.New(cfg.DB)
+
+ migrator := migrate.NewMigrator(dbClient, mig)
+
+ return migrator, nil
+}
diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go
new file mode 100644
index 0000000..8591084
--- /dev/null
+++ b/cmd/serve/serve.go
@@ -0,0 +1,67 @@
+package serve
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/api"
+ "git.neonxp.ru/neonxp/guessr/pkg/config"
+ "git.neonxp.ru/neonxp/guessr/pkg/db"
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "git.neonxp.ru/neonxp/guessr/pkg/service"
+ "github.com/gorilla/sessions"
+ echosession "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+ echomiddleware "github.com/labstack/echo/v4/middleware"
+ "github.com/urfave/cli/v3"
+ "golang.org/x/time/rate"
+)
+
+var Command = &cli.Command{
+ Name: "serve",
+ Usage: "start api server",
+ Action: func(ctx context.Context, c *cli.Command) error {
+ cfg, err := config.New()
+ if err != nil {
+ return err
+ }
+ dbClient := db.New(cfg.DB)
+ placesService := service.New(dbClient)
+
+ apiHandler := api.New(placesService)
+
+ e := echo.New()
+ e.Debug = cfg.Debug
+
+ e.Use(
+ echomiddleware.Recover(),
+ echomiddleware.Logger(),
+ echomiddleware.RemoveTrailingSlash(),
+ )
+
+ func(g *echo.Group) {
+ g.Use(
+ echosession.Middleware(sessions.NewCookieStore([]byte(cfg.Keys[0]), []byte(cfg.Keys[1]))),
+ middleware.PopulateState(),
+ echomiddleware.RateLimiter(
+ echomiddleware.NewRateLimiterMemoryStore(
+ rate.Every(time.Second),
+ ),
+ ),
+ )
+ g.POST("/next", apiHandler.PostNext)
+ g.POST("/guess", apiHandler.PostGuess)
+ g.GET("/state", apiHandler.GetState)
+ g.POST("/state", apiHandler.PostState)
+ }(e.Group("/api"))
+
+ // if cfg.Debug {
+ e.StaticFS("/", os.DirFS("./static"))
+ // } else {
+ // e.StaticFS("/", static.FS)
+ // }
+
+ return e.Start(cfg.Listen)
+ },
+}
diff --git a/contrib/dev/docker-compose.yml b/contrib/dev/docker-compose.yml
new file mode 100644
index 0000000..35a92f3
--- /dev/null
+++ b/contrib/dev/docker-compose.yml
@@ -0,0 +1,31 @@
+version: '3.6'
+
+services:
+ postgres:
+ container_name: postgres_container
+ image: postgis/postgis
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-app}
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-SuperSecret}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ ports:
+ - '5432:5432'
+ networks:
+ - postgres
+ restart: unless-stopped
+
+ adminer:
+ image: adminer
+ restart: always
+ ports:
+ - 8090:8080
+ networks:
+ - postgres
+
+networks:
+ postgres:
+ driver: bridge
+volumes:
+ pgdata:
diff --git a/frontend/app.css b/frontend/app.css
new file mode 100644
index 0000000..2ffd64f
--- /dev/null
+++ b/frontend/app.css
@@ -0,0 +1,4 @@
+html, body {height: 100%;padding:0;margin:0}
+#app, .full {
+ height: 100%;
+} \ No newline at end of file
diff --git a/frontend/app.jsx b/frontend/app.jsx
new file mode 100644
index 0000000..60bdcf4
--- /dev/null
+++ b/frontend/app.jsx
@@ -0,0 +1,207 @@
+import React, { useState, useEffect } from "react";
+import GlMap, { Source, Marker, Layer } from "react-map-gl/maplibre";
+import {
+ Flex,
+ Layout,
+ Col,
+ Row,
+ Modal,
+ Form,
+ Input,
+ Button,
+ Image,
+ Card,
+} from "antd";
+
+const { Header, Content } = Layout;
+
+const layout = {
+ labelCol: { span: 8 },
+ wrapperCol: { span: 16 },
+};
+
+const geostyle = {
+ id: "data",
+ type: "line",
+ paint: {
+ "line-color": "red",
+ "line-width": 3,
+ },
+};
+
+const App = () => {
+ const [state, setState] = useState({});
+ const [selected, setSelected] = useState(null);
+ const [guess, setGuess] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [form] = Form.useForm();
+
+ useEffect(() => {
+ fetch("/api/state")
+ .then((x) => x.json())
+ .then(setState)
+ .then(() => setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ if (guess != null) {
+ setState(guess.state);
+ }
+ }, [guess]);
+
+ const onFinish = (values) => {
+ fetch("/api/state", {
+ method: "POST",
+ body: JSON.stringify(values),
+ headers: { "content-type": "application/json" },
+ })
+ .then((x) => x.json())
+ .then(setState);
+ };
+
+ const onReset = () => {
+ form.resetFields();
+ };
+
+ const onNext = () => {
+ setSelected(null);
+ setGuess(null);
+ setLoading(true);
+ fetch("/api/next", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ })
+ .then((x) => x.json())
+ .then(setState)
+ .then(() => setLoading(false));
+ };
+
+ const onMapClick = (e) => {
+ if (state.current_guid) {
+ setSelected(e.lngLat);
+ }
+ };
+
+ const onGuess = () => {
+ setLoading(true);
+ fetch("/api/guess", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(selected),
+ })
+ .then((x) => x.json())
+ .then(setGuess)
+ .then(() => setLoading(false));
+ };
+
+ return (
+ <>
+ <Layout>
+ <Content>
+ <Row style={{ height: "100vh" }}>
+ <Col sm={16}>
+ <GlMap
+ initialViewState={{
+ longitude: 49.106414,
+ latitude: 55.796127,
+ zoom: 11,
+ }}
+ onClick={onMapClick}
+ style={{ width: "100%", height: "100%" }}
+ mapStyle="https://tiles.openfreemap.org/styles/liberty"
+ >
+ {selected ? (
+ <Marker
+ latitude={selected.lat}
+ longitude={selected.lng}
+ />
+ ) : null}
+ {guess ? (
+ <Source
+ type="geojson"
+ data={JSON.parse(guess.geojson)}
+ >
+ <Layer {...geostyle} />
+ </Source>
+ ) : null}
+ </GlMap>
+ </Col>
+ <Col sm={8}>
+ {state.username ? (
+ <Card>
+ <h1>{state.username}</h1>
+ <h2>{state.points} очков</h2>
+ </Card>
+ ) : null}
+ <Card>
+ {!loading && !state.current_guid ? (
+ <Button
+ type="primary"
+ onClick={onNext}
+ block
+ size="large"
+ >
+ Новое задание
+ </Button>
+ ) : null}
+ {!loading && state.current_guid ? (
+ <Image src={state.image} />
+ ) : null}
+ {!loading && guess ? (
+ <>
+ <h2>{guess.name}</h2>
+ <Image src={guess.image} />
+ <h3>Расстояние: {guess.distance / 1000}км.</h3>
+ </>
+ ) : null}
+ {state.current_guid && !selected ? (
+ <p>
+ Нажмите на карте на точку, где по вашему
+ мнение находится то, что на фотографии
+ </p>
+ ) : null}
+ {state.current_guid && selected ? (
+ <Button
+ type="primary"
+ onClick={onGuess}
+ block
+ size="large"
+ >
+ Проверить
+ </Button>
+ ) : null}
+ </Card>
+ <p>Сделал <a href="https://neonxp.ru">Александр Кирюхин</a> в 2024 году</p>
+ </Col>
+ </Row>
+ </Content>
+ </Layout>
+ <Modal
+ title="Представьтесь"
+ open={!loading && !state.username}
+ onOk={form.submit}
+ onCancel={onReset}
+ >
+ <p>Для начала игры необходимо представиться</p>
+ <Form
+ {...layout}
+ form={form}
+ name="control-hooks"
+ onFinish={onFinish}
+ style={{ maxWidth: 600 }}
+ >
+ <Form.Item
+ name="username"
+ label="Имя"
+
+ rules={[{ required: true }]}
+ >
+ <Input />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </>
+ );
+};
+
+export default App;
diff --git a/frontend/index.jsx b/frontend/index.jsx
new file mode 100644
index 0000000..1153d89
--- /dev/null
+++ b/frontend/index.jsx
@@ -0,0 +1,8 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./app";
+
+import "./app.css";
+import "maplibre-gl/dist/maplibre-gl.css";
+
+createRoot(document.getElementById("app")).render(<App />);
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..553311a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,50 @@
+module git.neonxp.ru/neonxp/guessr
+
+go 1.23.3
+
+require (
+ github.com/aglyzov/charmap v0.0.0-20240916203842-8463cca61eca
+ github.com/gorilla/securecookie v1.1.2
+ github.com/gorilla/sessions v1.4.0
+ github.com/labstack/echo-contrib v0.17.1
+ github.com/labstack/echo/v4 v4.12.0
+ github.com/uptrace/bun v1.2.6
+ github.com/uptrace/bun/dialect/pgdialect v1.2.6
+ github.com/uptrace/bun/driver/pgdriver v1.2.6
+ github.com/urfave/cli/v3 v3.0.0-beta1
+)
+
+require (
+ github.com/agnivade/levenshtein v1.1.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+ github.com/gorilla/context v1.1.2 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ golang.org/x/net v0.24.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ golang.org/x/time v0.5.0 // indirect
+)
+
+require (
+ github.com/avito-tech/normalize v0.1.0
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/caarlos0/env/v11 v11.2.2
+ github.com/evanw/esbuild v0.24.0
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
+ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
+ github.com/uptrace/bun/extra/bundebug v1.2.6
+ github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
+ github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41 // indirect
+ golang.org/x/crypto v0.29.0 // indirect
+ golang.org/x/sys v0.27.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ mellium.im/sasl v0.3.2 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5188a8a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,98 @@
+github.com/aglyzov/charmap v0.0.0-20240916203842-8463cca61eca h1:p5TM0mgXB0wK5utkyeT2Uvq7fx8JPywqHeEGi1LD9U0=
+github.com/aglyzov/charmap v0.0.0-20240916203842-8463cca61eca/go.mod h1:VKpNbGKtQSdv+z6hQ5aIsPmcivKzgw9yNVomYg992b4=
+github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
+github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
+github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
+github.com/avito-tech/normalize v0.1.0 h1:c7iwnRCEgtdtG8PHyctyfQL11sTg2APoSo5vIq1usqI=
+github.com/avito-tech/normalize v0.1.0/go.mod h1:epEZEaqr3vwIALE78bc01q4qIjnvoKp9wxmLWFLkjYw=
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
+github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/evanw/esbuild v0.24.0 h1:GZ78naTLp7FKr+K7eNuM/SLs5maeiHYRPsTg6kmdsSE=
+github.com/evanw/esbuild v0.24.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/labstack/echo-contrib v0.17.1 h1:7I/he7ylVKsDUieaGRZ9XxxTYOjfQwVzHzUYrNykfCU=
+github.com/labstack/echo-contrib v0.17.1/go.mod h1:SnsCZtwHBAZm5uBSAtQtXQHI3wqEA73hvTn0bYMKnZA=
+github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
+github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
+github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
+github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
+github.com/uptrace/bun v1.2.6 h1:lyGBQAhNiClchb97HA2cBnDeRxwTRLhSIgiFPXVisV8=
+github.com/uptrace/bun v1.2.6/go.mod h1:xMgnVFf+/5xsrFBU34HjDJmzZnXbVuNEt/Ih56I8qBU=
+github.com/uptrace/bun/dialect/pgdialect v1.2.6 h1:iNd1YLx619K+sZK+dRcWPzluurXYK1QwIkp9FEfNB/8=
+github.com/uptrace/bun/dialect/pgdialect v1.2.6/go.mod h1:OL7d3qZLxKYP8kxNhMg3IheN1pDR3UScGjoUP+ivxJQ=
+github.com/uptrace/bun/driver/pgdriver v1.2.6 h1:dD7ckqIhVDayfYTwMKZ0dJM9AZfNJNBu5Cg/8g0EMOk=
+github.com/uptrace/bun/driver/pgdriver v1.2.6/go.mod h1:ChrVrMZlRzPQHTP4QCm/p1FfQqgnYXWlES0GS9qjWEY=
+github.com/uptrace/bun/extra/bundebug v1.2.6 h1:5l61LXIR2YWk/gqGSq5esha+x/qPPyhtQKORAuTfV34=
+github.com/uptrace/bun/extra/bundebug v1.2.6/go.mod h1:11C5ajtPrFcmIRo31TfQrmK5D2LgNIxxTQEZMz6lD2k=
+github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
+github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
+github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
+github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
+github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41 h1:rnB8ZLMeAr3VcqjfRkAm27qb8y6zFKNfuHvy1Gfe7KI=
+github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo=
+golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
+golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
+golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
+mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..dc734ca
--- /dev/null
+++ b/main.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+
+ "git.neonxp.ru/neonxp/guessr/cmd/frontend"
+ "git.neonxp.ru/neonxp/guessr/cmd/importer"
+ "git.neonxp.ru/neonxp/guessr/cmd/migrator"
+ "git.neonxp.ru/neonxp/guessr/cmd/serve"
+ "github.com/urfave/cli/v3"
+)
+
+func main() {
+ root := &cli.Command{
+ Name: "guessr",
+ Commands: []*cli.Command{
+ serve.Command,
+ migrator.Command,
+ importer.Command,
+ frontend.Command,
+ },
+ ErrWriter: os.Stdout,
+ ExitErrHandler: func(ctx context.Context, c *cli.Command, err error) {
+ log.Fatal(err)
+ },
+ }
+ root.Run(context.Background(), os.Args)
+}
diff --git a/migrations/20241005143542_init.go b/migrations/20241005143542_init.go
new file mode 100644
index 0000000..f15b7c1
--- /dev/null
+++ b/migrations/20241005143542_init.go
@@ -0,0 +1,29 @@
+package migrations
+
+import (
+ "context"
+ "fmt"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/model"
+ "github.com/uptrace/bun"
+)
+
+//nolint:gochecknoinits
+func init() {
+ Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
+ fmt.Print(" [up migration] ")
+ if _, err := db.NewCreateTable().Model((*model.Place)(nil)).Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ }, func(ctx context.Context, db *bun.DB) error {
+ fmt.Print(" [down migration] ")
+
+ if _, err := db.NewDropTable().Model((*model.Place)(nil)).Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+}
diff --git a/migrations/main.go b/migrations/main.go
new file mode 100644
index 0000000..f7346fb
--- /dev/null
+++ b/migrations/main.go
@@ -0,0 +1,7 @@
+package migrations
+
+import (
+ "github.com/uptrace/bun/migrate"
+)
+
+var Migrations = migrate.NewMigrations()
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..3bd8576
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1674 @@
+{
+ "name": "guessr",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "guessr",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "antd": "^5.22.3",
+ "maplibre-gl": "^4.7.1",
+ "pigeon-maps": "^0.21.6",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-map-gl": "^7.1.7"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.1"
+ }
+ },
+ "node_modules/@ant-design/colors": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.1.0.tgz",
+ "integrity": "sha512-MMoDGWn1y9LdQJQSHiCC20x3uZ3CwQnv9QMz6pCmJOrqdgM9YxsoVVY0wtrdXbmfSgnV0KNk6zi09NAhMR2jvg==",
+ "dependencies": {
+ "@ctrl/tinycolor": "^3.6.1"
+ }
+ },
+ "node_modules/@ant-design/cssinjs": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.22.1.tgz",
+ "integrity": "sha512-SLuXM4wiEE1blOx94iXrkOgseMZHzdr4ngdFu3VVDq6AOWh7rlwqTkMAtJho3EsBF6x/eUGOtK53VZXGQG7+sQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "@emotion/hash": "^0.8.0",
+ "@emotion/unitless": "^0.7.5",
+ "classnames": "^2.3.1",
+ "csstype": "^3.1.3",
+ "rc-util": "^5.35.0",
+ "stylis": "^4.3.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/@ant-design/cssinjs-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
+ "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
+ "dependencies": {
+ "@ant-design/cssinjs": "^1.21.0",
+ "@babel/runtime": "^7.23.2",
+ "rc-util": "^5.38.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@ant-design/fast-color": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
+ "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=8.x"
+ }
+ },
+ "node_modules/@ant-design/icons": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.5.2.tgz",
+ "integrity": "sha512-xc53rjVBl9v2BqFxUjZGti/RfdDeA8/6KYglmInM2PNqSXc/WfuGDTifJI/ZsokJK0aeKvOIbXc9y2g8ILAhEA==",
+ "dependencies": {
+ "@ant-design/colors": "^7.0.0",
+ "@ant-design/icons-svg": "^4.4.0",
+ "@babel/runtime": "^7.24.8",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.31.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/@ant-design/icons-svg": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+ "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
+ },
+ "node_modules/@ant-design/react-slick": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
+ "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.4",
+ "classnames": "^2.2.5",
+ "json2mq": "^0.2.0",
+ "resize-observer-polyfill": "^1.5.1",
+ "throttle-debounce": "^5.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
+ "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@ctrl/tinycolor": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+ "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
+ },
+ "node_modules/@mapbox/geojson-rewind": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
+ "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
+ "dependencies": {
+ "get-stream": "^6.0.1",
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "geojson-rewind": "geojson-rewind"
+ }
+ },
+ "node_modules/@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
+ "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
+ "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA=="
+ },
+ "node_modules/@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
+ "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
+ "dependencies": {
+ "@mapbox/point-geometry": "~0.1.0"
+ }
+ },
+ "node_modules/@mapbox/vector-tile/node_modules/@mapbox/point-geometry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
+ },
+ "node_modules/@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "20.4.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
+ "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^4.0.0",
+ "minimist": "^1.2.8",
+ "quickselect": "^2.0.0",
+ "rw": "^1.3.3",
+ "tinyqueue": "^3.0.0"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
+ },
+ "node_modules/@rc-component/async-validator": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz",
+ "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==",
+ "dependencies": {
+ "@babel/runtime": "^7.24.4"
+ },
+ "engines": {
+ "node": ">=14.x"
+ }
+ },
+ "node_modules/@rc-component/color-picker": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
+ "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
+ "dependencies": {
+ "@ant-design/fast-color": "^2.0.6",
+ "@babel/runtime": "^7.23.6",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.38.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/context": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz",
+ "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/mini-decimal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz",
+ "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ }
+ },
+ "node_modules/@rc-component/mutate-observer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz",
+ "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/portal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz",
+ "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/qrcode": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.0.0.tgz",
+ "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.38.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/tour": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
+ "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "@rc-component/portal": "^1.0.0-9",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/trigger": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.5.tgz",
+ "integrity": "sha512-F1EJ4KjFpGAHAjuKvOyZB/6IZDkVx0bHl0M4fQM5wXcmm7lgTgVSSnR3bXwdmS6jOJGHOqfDxIJW3WUvwMIXhQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2",
+ "@rc-component/portal": "^1.1.0",
+ "classnames": "^2.3.2",
+ "rc-motion": "^2.0.0",
+ "rc-resize-observer": "^1.3.1",
+ "rc-util": "^5.38.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.15",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz",
+ "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA=="
+ },
+ "node_modules/@types/geojson-vt": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
+ "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/mapbox__point-geometry": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
+ "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
+ },
+ "node_modules/@types/mapbox__vector-tile": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
+ "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
+ "dependencies": {
+ "@types/geojson": "*",
+ "@types/mapbox__point-geometry": "*",
+ "@types/pbf": "*"
+ }
+ },
+ "node_modules/@types/mapbox-gl": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz",
+ "integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/pbf": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
+ "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.14",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
+ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
+ "dev": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.14.tgz",
+ "integrity": "sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg==",
+ "dev": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.2",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.2.tgz",
+ "integrity": "sha512-Fqp+rcvem9wEnGr3RY8dYNvSQ8PoLqjZ9HLgaPUOjJJD120uDyOxOjc/39M4Kddp9JQCxpGQbnhVQF0C0ncYVg==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "^18"
+ }
+ },
+ "node_modules/@types/supercluster": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
+ "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/antd": {
+ "version": "5.22.3",
+ "resolved": "https://registry.npmjs.org/antd/-/antd-5.22.3.tgz",
+ "integrity": "sha512-YyJ9PhsWkTqJzEo1cZ6wBFk8Ofrfs5O3uGsW8vWcpqBLq/w/yPM/nqZkEDoPFeZ1H+nAhuPF/oWmE/sfj3uYeg==",
+ "dependencies": {
+ "@ant-design/colors": "^7.1.0",
+ "@ant-design/cssinjs": "^1.21.1",
+ "@ant-design/cssinjs-utils": "^1.1.1",
+ "@ant-design/icons": "^5.5.2",
+ "@ant-design/react-slick": "~1.1.2",
+ "@babel/runtime": "^7.25.7",
+ "@ctrl/tinycolor": "^3.6.1",
+ "@rc-component/color-picker": "~2.0.1",
+ "@rc-component/mutate-observer": "^1.1.0",
+ "@rc-component/qrcode": "~1.0.0",
+ "@rc-component/tour": "~1.15.1",
+ "@rc-component/trigger": "^2.2.5",
+ "classnames": "^2.5.1",
+ "copy-to-clipboard": "^3.3.3",
+ "dayjs": "^1.11.11",
+ "rc-cascader": "~3.30.0",
+ "rc-checkbox": "~3.3.0",
+ "rc-collapse": "~3.9.0",
+ "rc-dialog": "~9.6.0",
+ "rc-drawer": "~7.2.0",
+ "rc-dropdown": "~4.2.0",
+ "rc-field-form": "~2.6.0",
+ "rc-image": "~7.11.0",
+ "rc-input": "~1.6.4",
+ "rc-input-number": "~9.3.0",
+ "rc-mentions": "~2.17.0",
+ "rc-menu": "~9.16.0",
+ "rc-motion": "^2.9.3",
+ "rc-notification": "~5.6.2",
+ "rc-pagination": "~4.3.0",
+ "rc-picker": "~4.8.2",
+ "rc-progress": "~4.0.0",
+ "rc-rate": "~2.13.0",
+ "rc-resize-observer": "^1.4.0",
+ "rc-segmented": "~2.5.0",
+ "rc-select": "~14.16.3",
+ "rc-slider": "~11.1.7",
+ "rc-steps": "~6.0.1",
+ "rc-switch": "~4.1.0",
+ "rc-table": "~7.49.0",
+ "rc-tabs": "~15.4.0",
+ "rc-textarea": "~1.8.2",
+ "rc-tooltip": "~6.2.1",
+ "rc-tree": "~5.10.1",
+ "rc-tree-select": "~5.24.5",
+ "rc-upload": "~4.8.1",
+ "rc-util": "^5.43.0",
+ "scroll-into-view-if-needed": "^3.1.0",
+ "throttle-debounce": "^5.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ant-design"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/bytewise": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
+ "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
+ "dependencies": {
+ "bytewise-core": "^1.2.2",
+ "typewise": "^1.0.3"
+ }
+ },
+ "node_modules/bytewise-core": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
+ "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
+ "dependencies": {
+ "typewise-core": "^1.2"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
+ },
+ "node_modules/compute-scroll-into-view": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
+ "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg=="
+ },
+ "node_modules/copy-to-clipboard": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
+ "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
+ "dependencies": {
+ "toggle-selection": "^1.0.6"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.13",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/geojson-vt": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
+ "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/gl-matrix": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
+ "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
+ },
+ "node_modules/global-prefix": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
+ "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
+ "dependencies": {
+ "ini": "^4.1.3",
+ "kind-of": "^6.0.3",
+ "which": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/ini": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
+ "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
+ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
+ },
+ "node_modules/json2mq": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
+ "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
+ "dependencies": {
+ "string-convert": "^0.2.0"
+ }
+ },
+ "node_modules/kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/maplibre-gl": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
+ "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
+ "dependencies": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/point-geometry": "^0.1.0",
+ "@mapbox/tiny-sdf": "^2.0.6",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^1.3.1",
+ "@mapbox/whoots-js": "^3.1.0",
+ "@maplibre/maplibre-gl-style-spec": "^20.3.1",
+ "@types/geojson": "^7946.0.14",
+ "@types/geojson-vt": "3.2.5",
+ "@types/mapbox__point-geometry": "^0.1.4",
+ "@types/mapbox__vector-tile": "^1.3.4",
+ "@types/pbf": "^3.0.5",
+ "@types/supercluster": "^7.1.3",
+ "earcut": "^3.0.0",
+ "geojson-vt": "^4.0.2",
+ "gl-matrix": "^3.4.3",
+ "global-prefix": "^4.0.0",
+ "kdbush": "^4.0.2",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^3.3.0",
+ "potpack": "^2.0.0",
+ "quickselect": "^3.0.0",
+ "supercluster": "^8.0.1",
+ "tinyqueue": "^3.0.0",
+ "vt-pbf": "^3.1.3"
+ },
+ "engines": {
+ "node": ">=16.14.0",
+ "npm": ">=8.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
+ }
+ },
+ "node_modules/maplibre-gl/node_modules/@mapbox/point-geometry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
+ },
+ "node_modules/maplibre-gl/node_modules/earcut": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.0.tgz",
+ "integrity": "sha512-41Fs7Q/PLq1SDbqjsgcY7GA42T0jvaCNGXgGtsNdvg+Yv8eIu06bxv4/PoREkZ9nMDNwnUSG9OFB9+yv8eKhDg=="
+ },
+ "node_modules/maplibre-gl/node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
+ },
+ "node_modules/pbf": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
+ "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
+ "dependencies": {
+ "ieee754": "^1.1.12",
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
+ "node_modules/pigeon-maps": {
+ "version": "0.21.6",
+ "resolved": "https://registry.npmjs.org/pigeon-maps/-/pigeon-maps-0.21.6.tgz",
+ "integrity": "sha512-6io58lhpbtVT/DUKDoOqsjeAFW46Nfe+gr4PX/kmD1dyJxfb8WaT4hcgwcmM8dJdkyfibgLHo59Uo2o2/xscaQ==",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/potpack": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
+ "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
+ },
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
+ },
+ "node_modules/quickselect": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
+ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
+ },
+ "node_modules/rc-cascader": {
+ "version": "3.30.0",
+ "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.30.0.tgz",
+ "integrity": "sha512-rrzSbk1Bdqbu+pDwiLCLHu72+lwX9BZ28+JKzoi0DWZ4N29QYFeip8Gctl33QVd2Xg3Rf14D3yAOG76ElJw16w==",
+ "dependencies": {
+ "@babel/runtime": "^7.25.7",
+ "classnames": "^2.3.1",
+ "rc-select": "~14.16.2",
+ "rc-tree": "~5.10.1",
+ "rc-util": "^5.43.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-checkbox": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.3.0.tgz",
+ "integrity": "sha512-Ih3ZaAcoAiFKJjifzwsGiT/f/quIkxJoklW4yKGho14Olulwn8gN7hOBve0/WGDg5o/l/5mL0w7ff7/YGvefVw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.25.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-collapse": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
+ "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.3.4",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-dialog": {
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
+ "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/portal": "^1.0.0-8",
+ "classnames": "^2.2.6",
+ "rc-motion": "^2.3.0",
+ "rc-util": "^5.21.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-drawer": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz",
+ "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==",
+ "dependencies": {
+ "@babel/runtime": "^7.23.9",
+ "@rc-component/portal": "^1.1.1",
+ "classnames": "^2.2.6",
+ "rc-motion": "^2.6.1",
+ "rc-util": "^5.38.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-dropdown": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.0.tgz",
+ "integrity": "sha512-odM8Ove+gSh0zU27DUj5cG1gNKg7mLWBYzB5E4nNLrLwBmYEgYP43vHKDGOVZcJSVElQBI0+jTQgjnq0NfLjng==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.17.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.11.0",
+ "react-dom": ">=16.11.0"
+ }
+ },
+ "node_modules/rc-field-form": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.6.0.tgz",
+ "integrity": "sha512-qU7ei+G/nZ5nkx7TFLRoPtcMR0s0R0yG/2O+iWqA/CX65tJmgODpJvTYYzGMPW/Psj+gy5QHbcZUrNVcPXKjLQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "@rc-component/async-validator": "^5.0.3",
+ "rc-util": "^5.32.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-image": {
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.11.0.tgz",
+ "integrity": "sha512-aZkTEZXqeqfPZtnSdNUnKQA0N/3MbgR7nUnZ+/4MfSFWPFHZau4p5r5ShaI0KPEMnNjv4kijSCFq/9wtJpwykw==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "@rc-component/portal": "^1.0.2",
+ "classnames": "^2.2.6",
+ "rc-dialog": "~9.6.0",
+ "rc-motion": "^2.6.2",
+ "rc-util": "^5.34.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-input": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.6.4.tgz",
+ "integrity": "sha512-lBZhfRD4NSAUW0zOKLUeI6GJuXkxeZYi0hr8VcJgJpyTNOvHw1ysrKWAHcEOAAHj7guxgmWYSi6xWrEdfrSAsA==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.18.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/rc-input-number": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.3.0.tgz",
+ "integrity": "sha512-JQ363ywqRyxwgVxpg2z2kja3CehTpYdqR7emJ/6yJjRdbvo+RvfE83fcpBCIJRq3zLp8SakmEXq60qzWyZ7Usw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/mini-decimal": "^1.0.1",
+ "classnames": "^2.2.5",
+ "rc-input": "~1.6.0",
+ "rc-util": "^5.40.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-mentions": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.17.0.tgz",
+ "integrity": "sha512-sfHy+qLvc+p8jx8GUsujZWXDOIlIimp6YQz7N5ONQ6bHsa2kyG+BLa5k2wuxgebBbH97is33wxiyq5UkiXRpHA==",
+ "dependencies": {
+ "@babel/runtime": "^7.22.5",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.6",
+ "rc-input": "~1.6.0",
+ "rc-menu": "~9.16.0",
+ "rc-textarea": "~1.8.0",
+ "rc-util": "^5.34.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-menu": {
+ "version": "9.16.0",
+ "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.0.tgz",
+ "integrity": "sha512-vAL0yqPkmXWk3+YKRkmIR8TYj3RVdEt3ptG2jCJXWNAvQbT0VJJdRyHZ7kG/l1JsZlB+VJq/VcYOo69VR4oD+w==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "2.x",
+ "rc-motion": "^2.4.3",
+ "rc-overflow": "^1.3.1",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-motion": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.3.tgz",
+ "integrity": "sha512-rkW47ABVkic7WEB0EKJqzySpvDqwl60/tdkY7hWP7dYnh5pm0SzJpo54oW3TDUGXV5wfxXFmMkxrzRRbotQ0+w==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.43.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-notification": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.2.tgz",
+ "integrity": "sha512-Id4IYMoii3zzrG0lB0gD6dPgJx4Iu95Xu0BQrhHIbp7ZnAZbLqdqQ73aIWH0d0UFcElxwaKjnzNovTjo7kXz7g==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.9.0",
+ "rc-util": "^5.20.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-overflow": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.3.2.tgz",
+ "integrity": "sha512-nsUm78jkYAoPygDAcGZeC2VwIg/IBGSodtOY3pMof4W3M9qRJgqaDYm03ZayHlde3I6ipliAxbN0RUcGf5KOzw==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.37.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-pagination": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-4.3.0.tgz",
+ "integrity": "sha512-UubEWA0ShnroQ1tDa291Fzw6kj0iOeF26IsUObxYTpimgj4/qPCWVFl18RLZE+0Up1IZg0IK4pMn6nB3mjvB7g==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.38.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-picker": {
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.8.3.tgz",
+ "integrity": "sha512-hJ45qoEs4mfxXPAJdp1n3sKwADul874Cd0/HwnsEOE60H+tgiJUGgbOD62As3EG/rFVNS5AWRfBCDJJfmRqOVQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.1",
+ "rc-overflow": "^1.3.2",
+ "rc-resize-observer": "^1.4.0",
+ "rc-util": "^5.43.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "date-fns": ">= 2.x",
+ "dayjs": ">= 1.x",
+ "luxon": ">= 3.x",
+ "moment": ">= 2.x",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ },
+ "peerDependenciesMeta": {
+ "date-fns": {
+ "optional": true
+ },
+ "dayjs": {
+ "optional": true
+ },
+ "luxon": {
+ "optional": true
+ },
+ "moment": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/rc-progress": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz",
+ "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.16.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-rate": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.0.tgz",
+ "integrity": "sha512-oxvx1Q5k5wD30sjN5tqAyWTvJfLNNJn7Oq3IeS4HxWfAiC4BOXMITNAsw7u/fzdtO4MS8Ki8uRLOzcnEuoQiAw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-resize-observer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.0.tgz",
+ "integrity": "sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.7",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.38.0",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-segmented": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.5.0.tgz",
+ "integrity": "sha512-B28Fe3J9iUFOhFJET3RoXAPFJ2u47QvLSYcZWC4tFYNGPEjug5LAxEasZlA/PpAxhdOPqGWsGbSj7ftneukJnw==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-motion": "^2.4.4",
+ "rc-util": "^5.17.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/rc-select": {
+ "version": "14.16.4",
+ "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.4.tgz",
+ "integrity": "sha512-jP6qf7+vjnxGvPpfPWbGYfFlSl3h8L2XcD4O7g2GYXmEeBC0mw+nPD7i++OOE8v3YGqP8xtYjRKAWCMLfjgxlw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/trigger": "^2.1.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.0.1",
+ "rc-overflow": "^1.3.1",
+ "rc-util": "^5.16.1",
+ "rc-virtual-list": "^3.5.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-slider": {
+ "version": "11.1.7",
+ "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.7.tgz",
+ "integrity": "sha512-ytYbZei81TX7otdC0QvoYD72XSlxvTihNth5OeZ6PMXyEDq/vHdWFulQmfDGyXK1NwKwSlKgpvINOa88uT5g2A==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.36.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-steps": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz",
+ "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==",
+ "dependencies": {
+ "@babel/runtime": "^7.16.7",
+ "classnames": "^2.2.3",
+ "rc-util": "^5.16.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-switch": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz",
+ "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.30.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-table": {
+ "version": "7.49.0",
+ "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.49.0.tgz",
+ "integrity": "sha512-/FoPLX94muAQOxVpi1jhnpKjOIqUbT81eELQPAzSXOke4ky4oCWYUXOcVpL31ZCO90xScwVSXRd7coqtgtB1Ng==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/context": "^1.4.0",
+ "classnames": "^2.2.5",
+ "rc-resize-observer": "^1.1.0",
+ "rc-util": "^5.41.0",
+ "rc-virtual-list": "^3.14.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tabs": {
+ "version": "15.4.0",
+ "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.4.0.tgz",
+ "integrity": "sha512-llKuyiAVqmXm2z7OrmhX5cNb2ueZaL8ZyA2P4R+6/72NYYcbEgOXibwHiQCFY2RiN3swXl53SIABi2CumUS02g==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "classnames": "2.x",
+ "rc-dropdown": "~4.2.0",
+ "rc-menu": "~9.16.0",
+ "rc-motion": "^2.6.2",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.34.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-textarea": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.8.2.tgz",
+ "integrity": "sha512-UFAezAqltyR00a8Lf0IPAyTd29Jj9ee8wt8DqXyDMal7r/Cg/nDt3e1OOv3Th4W6mKaZijjgwuPXhAfVNTN8sw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.1",
+ "rc-input": "~1.6.0",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tooltip": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.2.1.tgz",
+ "integrity": "sha512-rws0duD/3sHHsD905Nex7FvoUGy2UBQRhTkKxeEvr2FB+r21HsOxcDJI0TzyO8NHhnAA8ILr8pfbSBg5Jj5KBg==",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.3.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tree": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.10.1.tgz",
+ "integrity": "sha512-FPXb3tT/u39mgjr6JNlHaUTYfHkVGW56XaGDahDpEFLGsnPxGcVLNTjcqoQb/GNbSCycl7tD7EvIymwOTP0+Yw==",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.0.1",
+ "rc-util": "^5.16.1",
+ "rc-virtual-list": "^3.5.1"
+ },
+ "engines": {
+ "node": ">=10.x"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-tree-select": {
+ "version": "5.24.5",
+ "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.24.5.tgz",
+ "integrity": "sha512-PnyR8LZJWaiEFw0SHRqo4MNQWyyZsyMs8eNmo68uXZWjxc7QqeWcjPPoONN0rc90c3HZqGF9z+Roz+GLzY5GXA==",
+ "dependencies": {
+ "@babel/runtime": "^7.25.7",
+ "classnames": "2.x",
+ "rc-select": "~14.16.2",
+ "rc-tree": "~5.10.1",
+ "rc-util": "^5.43.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-upload": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.8.1.tgz",
+ "integrity": "sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-util": {
+ "version": "5.44.0",
+ "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.0.tgz",
+ "integrity": "sha512-qSNMihvZdD0Y5ht9k0rK3QsYcOQ94hdsZSvc8rHy22N+ySC6taVN35SkY1dUyAARxp+w8+ZCQ7MvgRGzXhcQKA==",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "react-is": "^18.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-util/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
+ },
+ "node_modules/rc-virtual-list": {
+ "version": "3.16.0",
+ "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.16.0.tgz",
+ "integrity": "sha512-tRpWBC0msU+MxFxnD6+y4v0P17Yzplf+mbiHrqRvfVanx0S4o0XV+2zu4vv7hM9nNfcucO+MPHivqpRT2lfnFQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.0",
+ "classnames": "^2.2.6",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.36.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-map-gl": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.1.7.tgz",
+ "integrity": "sha512-mwjc0obkBJOXCcoXQr3VoLqmqwo9vS4bXfbGsdxXzEgVCv/PM0v+1QggL7W0d/ccIy+VCjbXNlGij+PENz6VNg==",
+ "dependencies": {
+ "@maplibre/maplibre-gl-style-spec": "^19.2.1",
+ "@types/mapbox-gl": ">=1.0.0"
+ },
+ "peerDependencies": {
+ "mapbox-gl": ">=1.13.0",
+ "maplibre-gl": ">=1.13.0",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ },
+ "peerDependenciesMeta": {
+ "mapbox-gl": {
+ "optional": true
+ },
+ "maplibre-gl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-map-gl/node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "19.3.3",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
+ "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^3.0.0",
+ "minimist": "^1.2.8",
+ "rw": "^1.3.3",
+ "sort-object": "^3.0.3"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/react-map-gl/node_modules/json-stringify-pretty-compact": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
+ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA=="
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+ },
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+ },
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/scroll-into-view-if-needed": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
+ "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
+ "dependencies": {
+ "compute-scroll-into-view": "^3.0.2"
+ }
+ },
+ "node_modules/set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-asc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
+ "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-desc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
+ "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-object": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
+ "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
+ "dependencies": {
+ "bytewise": "^1.1.0",
+ "get-value": "^2.0.2",
+ "is-extendable": "^0.1.1",
+ "sort-asc": "^0.2.0",
+ "sort-desc": "^0.2.0",
+ "union-value": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "dependencies": {
+ "extend-shallow": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string/node_modules/extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "dependencies": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string/node_modules/is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "dependencies": {
+ "is-plain-object": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-convert": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
+ "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="
+ },
+ "node_modules/stylis": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.4.tgz",
+ "integrity": "sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now=="
+ },
+ "node_modules/supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "dependencies": {
+ "kdbush": "^4.0.2"
+ }
+ },
+ "node_modules/throttle-debounce": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+ "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+ "engines": {
+ "node": ">=12.22"
+ }
+ },
+ "node_modules/toggle-selection": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="
+ },
+ "node_modules/typewise": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
+ "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
+ "dependencies": {
+ "typewise-core": "^1.2.0"
+ }
+ },
+ "node_modules/typewise-core": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
+ "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg=="
+ },
+ "node_modules/union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/vt-pbf": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
+ "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
+ "dependencies": {
+ "@mapbox/point-geometry": "0.1.0",
+ "@mapbox/vector-tile": "^1.3.1",
+ "pbf": "^3.2.1"
+ }
+ },
+ "node_modules/vt-pbf/node_modules/@mapbox/point-geometry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
+ },
+ "node_modules/which": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+ "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^16.13.0 || >=18.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0801e5e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "guessr",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "keywords": [],
+ "author": "neonxp",
+ "license": "ISC",
+ "dependencies": {
+ "antd": "^5.22.3",
+ "maplibre-gl": "^4.7.1",
+ "pigeon-maps": "^0.21.6",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-map-gl": "^7.1.7"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.1"
+ }
+}
diff --git a/pkg/api/guess.go b/pkg/api/guess.go
new file mode 100644
index 0000000..25d08d6
--- /dev/null
+++ b/pkg/api/guess.go
@@ -0,0 +1,61 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) PostGuess(c echo.Context) error {
+ req := &postGuessRequest{}
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ if state := c.Get("state"); state == nil {
+ return echo.ErrNotFound
+ }
+
+ state := c.Get("state").(*middleware.State)
+
+ resp, err := h.places.Guess(
+ c.Request().Context(),
+ state.CurrentGUID, req.Lat, req.Lng)
+ if err != nil {
+ return err
+ }
+
+ addPoints := 1000 - resp.Distance
+ if addPoints > 0 {
+ state.Points += addPoints
+ }
+ state.CurrentGUID = ""
+ state.Image = ""
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, postGuessResponse{
+ GUID: resp.GUID,
+ Image: resp.Img,
+ Name: resp.Name,
+ Geojson: resp.Geojson,
+ Distance: resp.Distance,
+ State: state,
+ })
+}
+
+type postGuessRequest struct {
+ Lat float32 `json:"lat"`
+ Lng float32 `json:"lng"`
+}
+
+type postGuessResponse struct {
+ GUID string `json:"guid"`
+ Image string `json:"image"`
+ Name string `json:"name"`
+ Geojson any `json:"geojson"`
+ Distance int `json:"distance"`
+ State *middleware.State `json:"state"`
+}
diff --git a/pkg/api/handler.go b/pkg/api/handler.go
new file mode 100644
index 0000000..de1e8c3
--- /dev/null
+++ b/pkg/api/handler.go
@@ -0,0 +1,11 @@
+package api
+
+import "git.neonxp.ru/neonxp/guessr/pkg/service"
+
+type Handler struct {
+ places *service.Places
+}
+
+func New(places *service.Places) *Handler {
+ return &Handler{places: places}
+}
diff --git a/pkg/api/next.go b/pkg/api/next.go
new file mode 100644
index 0000000..c73af62
--- /dev/null
+++ b/pkg/api/next.go
@@ -0,0 +1,28 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) PostNext(c echo.Context) error {
+ p, err := h.places.GetNext(c.Request().Context())
+ if err != nil {
+ return err
+ }
+
+ if state := c.Get("state"); state == nil {
+ return echo.ErrBadRequest
+ }
+
+ state := c.Get("state").(*middleware.State)
+ state.CurrentGUID = p.GUID
+ state.Image = p.Img
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, state)
+}
diff --git a/pkg/api/state.go b/pkg/api/state.go
new file mode 100644
index 0000000..5ddd0b3
--- /dev/null
+++ b/pkg/api/state.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/avito-tech/normalize"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) GetState(c echo.Context) error {
+ if state := c.Get("state"); state == nil {
+ return c.JSON(200, &middleware.State{})
+ }
+
+ state := c.Get("state").(*middleware.State)
+
+ return c.JSON(200, state)
+}
+
+func (h *Handler) PostState(c echo.Context) error {
+ req := &postStateRequest{}
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ username := normalize.Normalize(req.Username)
+ if len(username) < 3 {
+ return echo.ErrBadRequest
+ }
+
+ state := &middleware.State{
+ Username: username,
+ }
+
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, state)
+}
+
+type postStateRequest struct {
+ Username string `json:"username"`
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..31c2079
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+ "git.neonxp.ru/neonxp/guessr/pkg/db"
+ "github.com/caarlos0/env/v11"
+)
+
+type Config struct {
+ Listen string `env:"LISTEN"`
+ Debug bool `env:"DEBUG"`
+ Keys []string `env:"KEYS"`
+ DB *db.Config
+}
+
+func New() (*Config, error) {
+ cfg := &Config{
+ Listen: ":8000",
+ DB: &db.Config{},
+ }
+
+ return cfg, env.Parse(cfg)
+}
diff --git a/pkg/db/config.go b/pkg/db/config.go
new file mode 100644
index 0000000..0acab0c
--- /dev/null
+++ b/pkg/db/config.go
@@ -0,0 +1,6 @@
+package db
+
+type Config struct {
+ Database string `env:"DATABASE"`
+ Debug bool `env:"DB_DEBUG"`
+}
diff --git a/pkg/db/db.go b/pkg/db/db.go
new file mode 100644
index 0000000..7c361f6
--- /dev/null
+++ b/pkg/db/db.go
@@ -0,0 +1,24 @@
+package db
+
+import (
+ "database/sql"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/pgdialect"
+ "github.com/uptrace/bun/driver/pgdriver"
+ "github.com/uptrace/bun/extra/bundebug"
+)
+
+// dsn := "postgres://postgres:@localhost:5432/test?sslmode=disable"
+// dsn := "unix://user:pass@dbname/var/run/postgresql/.s.PGSQL.5432"
+
+func New(config *Config) *bun.DB {
+ sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(config.Database)))
+
+ db := bun.NewDB(sqldb, pgdialect.New())
+ if config.Debug {
+ db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
+ }
+
+ return db
+}
diff --git a/pkg/middleware/context.go b/pkg/middleware/context.go
new file mode 100644
index 0000000..f9c4425
--- /dev/null
+++ b/pkg/middleware/context.go
@@ -0,0 +1,21 @@
+package middleware
+
+import (
+ "context"
+
+ "github.com/labstack/echo/v4"
+)
+
+type ContextKey string
+
+func Context(key ContextKey, value any) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ ctx := context.WithValue(c.Request().Context(), key, value)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
diff --git a/pkg/middleware/session/store.go b/pkg/middleware/session/store.go
new file mode 100644
index 0000000..78172d7
--- /dev/null
+++ b/pkg/middleware/session/store.go
@@ -0,0 +1,232 @@
+package session
+
+import (
+ "context"
+ "encoding/base32"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gorilla/securecookie"
+ "github.com/gorilla/sessions"
+ "github.com/uptrace/bun"
+)
+
+const (
+ sessionIDLen = 32
+ defaultTableName = "sessions"
+ defaultMaxAge = 60 * 60 * 24 * 30 // 30 days
+ defaultPath = "/"
+)
+
+// Options for bunstore.
+type Options struct {
+ TableName string
+ SkipCreateTable bool
+}
+
+// Store represent a bunstore.
+type Store struct {
+ db *bun.DB
+ opts Options
+ Codecs []securecookie.Codec
+ SessionOpts *sessions.Options
+}
+
+type Model struct {
+ bun.BaseModel `bun:"table:sessions,alias:s"`
+
+ ID string `bun:",pk,unique"`
+ Data string
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ ExpiresAt time.Time
+}
+
+type KeyPairs []string
+
+func (k KeyPairs) ToKeys() [][]byte {
+ b := make([][]byte, 0, len(k))
+ for _, kk := range k {
+ b = append(b, []byte(kk))
+ }
+
+ return b
+}
+
+// New creates a new bunstore session.
+func New(db *bun.DB, keyPairs KeyPairs) (*Store, error) {
+ return NewOptions(db, Options{}, keyPairs)
+}
+
+// NewOptions creates a new bunstore session with options.
+func NewOptions(db *bun.DB, opts Options, keyPairs KeyPairs) (*Store, error) {
+ st := &Store{
+ db: db,
+ opts: opts,
+ Codecs: securecookie.CodecsFromPairs(keyPairs.ToKeys()...),
+ SessionOpts: &sessions.Options{
+ Path: defaultPath,
+ MaxAge: defaultMaxAge,
+ },
+ }
+
+ return st, nil
+}
+
+// Get returns a session for the given name after adding it to the registry.
+func (st *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return sessions.GetRegistry(r).Get(st, name)
+}
+
+// New creates a session with name without adding it to the registry.
+func (st *Store) New(r *http.Request, name string) (*sessions.Session, error) {
+ session := sessions.NewSession(st, name)
+ opts := *st.SessionOpts
+ session.Options = &opts
+ session.IsNew = true
+
+ st.MaxAge(st.SessionOpts.MaxAge)
+
+ // try fetch from db if there is a cookie
+ s := st.getSessionFromCookie(r, session.Name())
+ if s != nil {
+ if err := securecookie.DecodeMulti(session.Name(), s.Data, &session.Values, st.Codecs...); err != nil {
+ //nolint:nilerr
+ return session, nil
+ }
+
+ session.ID = s.ID
+ session.IsNew = false
+ }
+
+ return session, nil
+}
+
+// Save session and set cookie header.
+func (st *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ s := st.getSessionFromCookie(r, session.Name())
+
+ // delete if max age is < 0
+ if session.Options.MaxAge < 0 {
+ if s != nil {
+ if _, err := st.db.NewDelete().Model(&Model{ID: session.ID}).WherePK("id").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+
+ return nil
+ }
+
+ data, err := securecookie.EncodeMulti(session.Name(), session.Values, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ expire := now.Add(time.Second * time.Duration(session.Options.MaxAge))
+
+ if s == nil {
+ // generate random session ID key suitable for storage in the db
+ session.ID = strings.TrimRight(
+ base32.StdEncoding.EncodeToString(
+ securecookie.GenerateRandomKey(sessionIDLen)), "=")
+ s = &Model{
+ ID: session.ID,
+ Data: data,
+ ExpiresAt: expire,
+ }
+
+ if _, err := st.db.NewInsert().Model(s).Exec(r.Context()); err != nil {
+ return err
+ }
+ } else {
+ s.Data = data
+ s.ExpiresAt = expire
+
+ if _, err := st.db.NewUpdate().Model(s).WherePK("id").Column("data", "expires_at").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ // set session id cookie
+ id, err := securecookie.EncodeMulti(session.Name(), s.ID, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), id, session.Options))
+
+ return nil
+}
+
+// getSessionFromCookie looks for an existing bunSession from a session ID stored inside a cookie.
+func (st *Store) getSessionFromCookie(r *http.Request, name string) *Model {
+ if cookie, err := r.Cookie(name); err == nil {
+ sessionID := ""
+ if err := securecookie.DecodeMulti(name, cookie.Value, &sessionID, st.Codecs...); err != nil {
+ return nil
+ }
+
+ s := &Model{}
+ if err := st.db.NewSelect().
+ Model(s).
+ Where("id = ? AND expires_at > ?", sessionID, time.Now()).
+ Scan(r.Context()); err != nil {
+ return nil
+ }
+
+ return s
+ }
+
+ return nil
+}
+
+// MaxAge sets the maximum age for the store and the underlying cookie
+// implementation. Individual sessions can be deleted by setting
+// Options.MaxAge = -1 for that session.
+func (st *Store) MaxAge(age int) {
+ st.SessionOpts.MaxAge = age
+ for _, codec := range st.Codecs {
+ if sc, ok := codec.(*securecookie.SecureCookie); ok {
+ sc.MaxAge(age)
+ }
+ }
+}
+
+// MaxLength restricts the maximum length of new sessions to l.
+// If l is 0 there is no limit to the size of a session, use with caution.
+// The default is 4096 (default for securecookie).
+func (st *Store) MaxLength(l int) {
+ for _, c := range st.Codecs {
+ if codec, ok := c.(*securecookie.SecureCookie); ok {
+ codec.MaxLength(l)
+ }
+ }
+}
+
+// Cleanup deletes expired sessions.
+func (st *Store) Cleanup() {
+ _, err := st.db.NewDelete().Model(&Model{}).Where("expires_at <= ?", time.Now()).Exec(context.Background())
+ if err != nil {
+ slog.Default().With("error", err).Error("cleanup")
+ }
+}
+
+// PeriodicCleanup runs Cleanup every interval. Close quit channel to stop.
+func (st *Store) PeriodicCleanup(interval time.Duration, quit <-chan struct{}) {
+ t := time.NewTicker(interval)
+ defer t.Stop()
+
+ for {
+ select {
+ case <-t.C:
+ st.Cleanup()
+ case <-quit:
+ return
+ }
+ }
+}
diff --git a/pkg/middleware/state.go b/pkg/middleware/state.go
new file mode 100644
index 0000000..c918411
--- /dev/null
+++ b/pkg/middleware/state.go
@@ -0,0 +1,58 @@
+package middleware
+
+import (
+ "context"
+ "encoding/gob"
+
+ "github.com/gorilla/sessions"
+ "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+)
+
+func init() {
+ gob.Register(&State{})
+}
+
+func PopulateState() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ sess, err := session.Get("state", c)
+ if err != nil {
+ return err
+ }
+
+ u := sess.Values["state"]
+ c.Set("state", u)
+
+ ctx := context.WithValue(c.Request().Context(), ContextKey("user"), u)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
+
+func SetState(c echo.Context, u State, maxage int) error {
+ sess, err := session.Get("state", c)
+ if err != nil {
+ return err
+ }
+
+ sess.Values["state"] = u
+ sess.Options = &sessions.Options{
+ Path: "/",
+ MaxAge: maxage,
+ HttpOnly: true,
+ Secure: true,
+ }
+
+ return sess.Save(c.Request(), c.Response())
+}
+
+type State struct {
+ Username string `json:"username"`
+ CurrentGUID string `json:"current_guid"`
+ Points int `json:"points"`
+ Image string `json:"image"`
+}
diff --git a/pkg/model/place.go b/pkg/model/place.go
new file mode 100644
index 0000000..54ce038
--- /dev/null
+++ b/pkg/model/place.go
@@ -0,0 +1,84 @@
+package model
+
+import (
+ "database/sql/driver"
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+type Place struct {
+ bun.BaseModel `bun:"table:places,alias:p"`
+
+ GUID string `bun:",pk,unique"`
+ Position *Point `bun:"type:geometry(Point, 4326)"`
+ Img string
+ Name string
+ Count int
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ DeletedAt time.Time `bun:",soft_delete,nullzero"`
+}
+
+type Point struct {
+ Lat float64
+ Lon float64
+}
+
+func (p *Point) Value() (driver.Value, error) {
+ return fmt.Sprintf(`SRID=4326;POINT(%f %f)`, p.Lon, p.Lat), nil
+}
+
+func (p *Point) Scan(src any) (err error) {
+ re := regexp.MustCompile(`/\((.+?) (.+?)\)/`)
+ s := ""
+ //nolint:revive
+ switch src := src.(type) {
+ case string:
+ s = src
+ case []uint8:
+ s = string(src)
+ default:
+ return fmt.Errorf("unsupported data type: %T", src)
+ }
+ //nolint:gomnd
+ subs := re.FindAllString(s, 2)
+
+ lon, err := strconv.ParseFloat(subs[0], 64)
+ if err != nil {
+ return err
+ }
+
+ lat, err := strconv.ParseFloat(subs[1], 64)
+ if err != nil {
+ return err
+ }
+
+ p.Lat = lat
+ p.Lon = lon
+
+ return nil
+}
+
+const radius = 6371 // Earth's mean radius in kilometers
+
+func degrees2radians(degrees float64) float64 {
+ return degrees * math.Pi / 180
+}
+
+func (p *Point) Distance(destination *Point) float64 {
+ degreesLat := degrees2radians(destination.Lat - p.Lat)
+ degreesLong := degrees2radians(destination.Lon - p.Lon)
+ a := (math.Sin(degreesLat/2)*math.Sin(degreesLat/2) +
+ math.Cos(degrees2radians(p.Lat))*
+ math.Cos(degrees2radians(destination.Lat))*math.Sin(degreesLong/2)*
+ math.Sin(degreesLong/2))
+ c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
+ d := radius * c
+
+ return d
+}
diff --git a/pkg/service/places.go b/pkg/service/places.go
new file mode 100644
index 0000000..d088836
--- /dev/null
+++ b/pkg/service/places.go
@@ -0,0 +1,70 @@
+package service
+
+import (
+ "context"
+ "database/sql"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/model"
+ "github.com/uptrace/bun"
+)
+
+type Places struct {
+ db *bun.DB
+}
+
+func New(db *bun.DB) *Places {
+ return &Places{db: db}
+}
+
+func (p *Places) GetNext(ctx context.Context) (*model.Place, error) {
+ r := new(model.Place)
+ btx, err := p.db.BeginTx(ctx, &sql.TxOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ err = btx.NewSelect().
+ ColumnExpr(`p.guid, p.img`).
+ Model(r).
+ Where(`p.count = (SELECT MIN(pl.count) FROM places pl WHERE pl.deleted_at IS NULL)`).
+ OrderExpr(`RANDOM()`).
+ Limit(1).
+ Scan(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ _, err = btx.NewUpdate().
+ Model(r).
+ Set(`count = count + 1`).
+ WherePK("guid").
+ Exec(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return r, btx.Commit()
+}
+
+func (p *Places) Guess(ctx context.Context, guid string, lat, lon float32) (*GuessResult, error) {
+ r := &GuessResult{}
+ err := p.db.NewSelect().
+ Model(&model.Place{GUID: guid}).
+ WherePK("guid").
+ ColumnExpr(`p.name, p.guid, p.img,
+ ST_Distance(ST_MakePoint(?, ?)::geography, p.position::geography)::int AS distance,
+ ST_AsGeoJSON(ST_MakeLine(
+ ST_SetSRID(ST_MakePoint(?, ?), 4326),
+ ST_SetSRID(p.position, 4326)
+ )) AS geojson`, lon, lat, lon, lat).
+ Scan(ctx, r)
+
+ return r, err
+}
+
+type GuessResult struct {
+ GUID string `json:"guid"`
+ Img string `json:"img"`
+ Name string `json:"name"`
+ Geojson any `json:"geojson"`
+ Distance int `json:"distance"`
+}
diff --git a/static/.gitignore b/static/.gitignore
new file mode 100644
index 0000000..682c87e
--- /dev/null
+++ b/static/.gitignore
@@ -0,0 +1,2 @@
+places
+assets \ No newline at end of file
diff --git a/static/fs.go b/static/fs.go
new file mode 100644
index 0000000..b770631
--- /dev/null
+++ b/static/fs.go
@@ -0,0 +1,6 @@
+package static
+
+import "embed"
+
+//go:embed assets/* index.html
+var FS embed.FS
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..5234688
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GeoGuessr</title>
+ <link rel="stylesheet" href="/assets/index.css">
+</head>
+<body>
+ <div id="app"></div>
+</body>
+<script src="/assets/index.js"></script>
+</html> \ No newline at end of file