added frontend files and folders to git
This commit is contained in:
parent
1490754a6e
commit
246e2cc1fb
9
README.md
Normal file
9
README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# ethermap
|
||||
|
||||
> This is very much a janky _earlydays_ project. All help is welcome!
|
||||
|
||||
An interactive map tool. A tool for collaborative planning on maps. Anyone can create new maps, and add, modify and delete locations on any map.
|
||||
|
||||
## Install
|
||||
|
||||
Ethermap is built in JavaScript (soz) using NodeJS. Install instructinos for the back and frontend are in their respective folders.
|
14
backend/.env.template
Normal file
14
backend/.env.template
Normal file
@ -0,0 +1,14 @@
|
||||
# ethermap
|
||||
SITE_NAME=
|
||||
|
||||
# Server Setup
|
||||
PORT=
|
||||
|
||||
# Database Setup
|
||||
DB_PROVIDER=
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
|
31
backend/README.md
Normal file
31
backend/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# ethermap api
|
||||
|
||||
Backend for ethermap
|
||||
|
||||
## Install
|
||||
|
||||
To install and run the backen you will need [NodeJS](https://nodejs.org/en) and `npm` installed, along with access to Postgresql server (possibility for this to be any database server). Then :
|
||||
|
||||
```sh
|
||||
$ npm i
|
||||
```
|
||||
|
||||
Once all the packages are installed you should setup your `.env` file (follow the `.env.template`). Once this has all the appropriate entries you can then connect and migrate the database.
|
||||
|
||||
```sh
|
||||
$ npm run migrate:latest
|
||||
```
|
||||
|
||||
then to run the development server you should run :
|
||||
|
||||
```sh
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
## Tech
|
||||
|
||||
The backend is made up of a REST api and websocket server. The REST api is built on [Express](https://expressjs.com/) and the websocket server is built on [socket.io](https://socket.io/).
|
||||
|
||||
Database interface is the ODM [objection.js](https://vincit.github.io/objection.js/). This setup might not be the best as it was adopted mid-project after starting with just [Knex](https://knexjs.org/) alone.
|
||||
|
||||
Tests are written in [Ava](https://github.com/avajs/ava).
|
25
backend/controllers/maps.js
Normal file
25
backend/controllers/maps.js
Normal file
@ -0,0 +1,25 @@
|
||||
import MapModel from '../db/models/map.js'
|
||||
|
||||
const getAllMaps = async (req, res) => {
|
||||
const maps = await MapModel.query()
|
||||
|
||||
res.json({ maps })
|
||||
}
|
||||
|
||||
const getMapByName = async (req, res) => {
|
||||
const name = req.params.mapName
|
||||
try {
|
||||
let map = await MapModel.query().where({ name }).withGraphFetched('map_points').first()
|
||||
|
||||
if (!map) {
|
||||
const created = await MapModel.query().insert({ name })
|
||||
map = await MapModel.query().findById(created.id).withGraphFetched('map_points')
|
||||
}
|
||||
|
||||
res.json(map)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export { getMapByName, getAllMaps }
|
18
backend/controllers/points.js
Normal file
18
backend/controllers/points.js
Normal file
@ -0,0 +1,18 @@
|
||||
import MapModel from '../db/models/map.js'
|
||||
|
||||
const setPoint = async (req, res, next) => {
|
||||
try {
|
||||
const { mapId, point } = req.body
|
||||
|
||||
console.log(req.body)
|
||||
|
||||
const map = await MapModel.query().findById(mapId)
|
||||
const p = await map.$relatedQuery('map_points').insert(point)
|
||||
|
||||
res.json(p)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
export { setPoint }
|
23
backend/db/DB.js
Normal file
23
backend/db/DB.js
Normal file
@ -0,0 +1,23 @@
|
||||
import knexConfig from '../knexfile.js'
|
||||
import Knex from 'knex'
|
||||
import { Model } from 'objection'
|
||||
import { newDb } from 'pg-mem'
|
||||
|
||||
const environment = process.env.NODE_ENV || 'development'
|
||||
|
||||
// variable for exporting the db
|
||||
let DB
|
||||
|
||||
if (environment == 'test') {
|
||||
const mem = newDb()
|
||||
DB = mem.adapters.createKnex(0, {
|
||||
migrations: {
|
||||
directory: './db/migrations'
|
||||
},
|
||||
})
|
||||
Model.knex(DB)
|
||||
} else {
|
||||
DB = Knex(knexConfig[environment])
|
||||
}
|
||||
|
||||
export default DB
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
const up = (knex) => {
|
||||
return knex.schema
|
||||
.createTable('maps', (table) => {
|
||||
table.increments().primary()
|
||||
table.string('name').notNullable().unique()
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
.createTable('map_points', (table) => {
|
||||
table.increments().primary()
|
||||
table.string('name')
|
||||
table.string('notes')
|
||||
table.point('location')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
table.integer('map_id').references('id').inTable('maps')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
const down = (knex) => {
|
||||
return knex.schema
|
||||
.raw('DROP TABLE maps CASCADE')
|
||||
.dropTable('map_points')
|
||||
}
|
||||
|
||||
export { up, down }
|
21
backend/db/models/map.js
Normal file
21
backend/db/models/map.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { Model } from 'objection'
|
||||
import Point from './point.js'
|
||||
|
||||
class MapModel extends Model {
|
||||
static tableName = 'maps'
|
||||
|
||||
static get relationMappings() {
|
||||
return {
|
||||
map_points: {
|
||||
relation: Model.HasManyRelation,
|
||||
modelClass: Point,
|
||||
join: {
|
||||
from: 'maps.id',
|
||||
to: 'map_points.map_id',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MapModel
|
7
backend/db/models/point.js
Normal file
7
backend/db/models/point.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { Model } from 'objection'
|
||||
|
||||
class PointModel extends Model {
|
||||
static tableName = 'map_points'
|
||||
}
|
||||
|
||||
export default PointModel
|
30
backend/express.js
Normal file
30
backend/express.js
Normal file
@ -0,0 +1,30 @@
|
||||
// web server
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
|
||||
// database
|
||||
import DB from './db/DB.js'
|
||||
import { Model } from 'objection'
|
||||
|
||||
// middleware
|
||||
import ErrorMiddleware from './middleware/errors.js'
|
||||
|
||||
// database setup
|
||||
Model.knex(DB)
|
||||
|
||||
// webserver setup
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use(cors())
|
||||
|
||||
// home route
|
||||
app.get('/', (_, res) => res.send('ethermap'))
|
||||
|
||||
// routes
|
||||
import apiRouter from './routes/api.js'
|
||||
app.use('/api', apiRouter)
|
||||
|
||||
// error middleware
|
||||
app.use(ErrorMiddleware)
|
||||
|
||||
export default app
|
17
backend/index.js
Normal file
17
backend/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
import App from './express.js'
|
||||
import 'dotenv/config'
|
||||
|
||||
import { Socket } from './sockets.js'
|
||||
import { Server } from 'socket.io'
|
||||
|
||||
const server = App.listen(process.env.PORT, () => {
|
||||
console.log(`Ethermap listening for connections on port ${process.env.PORT}`)
|
||||
})
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: 'http://localhost:5173'
|
||||
}
|
||||
})
|
||||
|
||||
Socket(io)
|
64
backend/knexfile.js
Normal file
64
backend/knexfile.js
Normal file
@ -0,0 +1,64 @@
|
||||
// Update with your config settings.
|
||||
import 'dotenv/config'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* @type { Object.<string, import("knex").Knex.Config> }
|
||||
*/
|
||||
export default {
|
||||
development: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT
|
||||
},
|
||||
migrations: {
|
||||
directory: __dirname + '/db/migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: __dirname + '/db/seeds'
|
||||
}
|
||||
},
|
||||
|
||||
staging: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10
|
||||
},
|
||||
migrations: {
|
||||
tableName: 'knex_migrations'
|
||||
}
|
||||
},
|
||||
|
||||
production: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10
|
||||
},
|
||||
migrations: {
|
||||
tableName: 'knex_migrations'
|
||||
}
|
||||
}
|
||||
}
|
6
backend/middleware/errors.js
Normal file
6
backend/middleware/errors.js
Normal file
@ -0,0 +1,6 @@
|
||||
// TODO@me update error handler
|
||||
export default (err, _, res) => {
|
||||
res.status(500).json({ message: err.message })
|
||||
}
|
||||
|
||||
|
6636
backend/package-lock.json
generated
Normal file
6636
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/package.json
Normal file
38
backend/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "ethermap",
|
||||
"version": "0.0.1",
|
||||
"description": "collaborative map tool inspired by etherpad",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nodemon index.js",
|
||||
"test": "ava",
|
||||
"test:routes": "ava ./tests/routes.js",
|
||||
"test:db": "ava ./tests/db.js",
|
||||
"migrate:latest": "knex migrate:latest",
|
||||
"migrate:drop": "knex migrate:down"
|
||||
},
|
||||
"keywords": [
|
||||
"ethermap",
|
||||
"map",
|
||||
"collaborative"
|
||||
],
|
||||
"author": "",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"knex": "^2.5.1",
|
||||
"objection": "^3.1.1",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ava": "^5.3.1",
|
||||
"eslint": "^8.48.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"pg-mem": "^2.6.13",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
15
backend/routes/api.js
Normal file
15
backend/routes/api.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { Router } from 'express'
|
||||
const router = Router()
|
||||
|
||||
// MAPS
|
||||
import { getAllMaps } from '../controllers/maps.js'
|
||||
import { getMapByName } from '../controllers/maps.js'
|
||||
|
||||
router.get('/maps', getAllMaps)
|
||||
router.get('/map/:mapName', getMapByName)
|
||||
|
||||
// POINTS
|
||||
import { setPoint } from '../controllers/points.js'
|
||||
router.post('/point/add', setPoint)
|
||||
|
||||
export default router
|
19
backend/sockets.js
Normal file
19
backend/sockets.js
Normal file
@ -0,0 +1,19 @@
|
||||
export const Socket = (io) => {
|
||||
io.on('connection', (socket) => {
|
||||
console.log('client connected with id', socket.id)
|
||||
let roomId = null
|
||||
|
||||
socket.on('connect-map', (mapId) => {
|
||||
console.log('connect to map room', mapId)
|
||||
|
||||
roomId = `map-${mapId}`
|
||||
|
||||
socket.join(roomId)
|
||||
})
|
||||
|
||||
socket.on('mousemove', (latlng) => {
|
||||
socket.to(roomId).emit('mousemove', { id: socket.id, pos: latlng })
|
||||
})
|
||||
|
||||
})
|
||||
}
|
37
backend/tests/db.js
Normal file
37
backend/tests/db.js
Normal file
@ -0,0 +1,37 @@
|
||||
// testing tools
|
||||
import test from 'ava'
|
||||
import db from '../db/DB.js'
|
||||
|
||||
// db model
|
||||
import MapModel from '../db/models/map.js'
|
||||
|
||||
test.before(async () => {
|
||||
await db.migrate.latest()
|
||||
})
|
||||
|
||||
test('Selecting maps should return array', async t => {
|
||||
const maps = await MapModel.query()
|
||||
|
||||
t.truthy(maps)
|
||||
})
|
||||
|
||||
test.serial('Inserting map returns map object', async t => {
|
||||
const map = await MapModel.query().insert({ name: 'milo' })
|
||||
|
||||
t.is(map.name, 'milo')
|
||||
})
|
||||
|
||||
test.serial('Insert point for existing map returns point', async t => {
|
||||
const map = await MapModel.query().where({ name: 'milo' }).first()
|
||||
const point = await map.$relatedQuery('map_points').insert({
|
||||
name: 'pointy',
|
||||
location: '(50.8552,4.3454)',
|
||||
})
|
||||
|
||||
t.is(point.name, 'pointy')
|
||||
t.is(point.location, '(50.8552,4.3454)')
|
||||
})
|
||||
|
||||
test.after(async () => {
|
||||
await db.migrate.down()
|
||||
})
|
93
backend/tests/routes.js
Normal file
93
backend/tests/routes.js
Normal file
@ -0,0 +1,93 @@
|
||||
// testing tools
|
||||
import test from 'ava'
|
||||
import request from 'supertest'
|
||||
|
||||
// express app
|
||||
import App from '../express.js'
|
||||
import db from '../db/DB.js'
|
||||
|
||||
test.before(async t => {
|
||||
await db.migrate.latest()
|
||||
})
|
||||
|
||||
test.serial('get "/" route should return body of "ethermap"', async t => {
|
||||
const res = await request(App).get('/')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.is(res.text, 'ethermap')
|
||||
})
|
||||
|
||||
test.serial('get "/api/maps" route should return an object containing an array called "maps"', async t => {
|
||||
const res = await request(App).get('/api/maps')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.truthy(res.body.maps?.constructor === Array)
|
||||
})
|
||||
|
||||
test.serial('get "/api/map/:mapName" route should return map with matching name', async t => {
|
||||
const res = await request(App).get('/api/map/bingo')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.is(res.body.name, 'bingo')
|
||||
})
|
||||
|
||||
test.serial('get "/api/map/:mapName" route with different mapName should create new map with different id', async t => {
|
||||
const res = await request(App).get('/api/map/cheese')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.truthy(res.body.id)
|
||||
t.not(res.body.id, 1)
|
||||
})
|
||||
|
||||
test.serial('get "/api/map/:mapName" route with existing mapName should return same id', async t => {
|
||||
const res = await request(App).get('/api/map/bingo')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.is(res.body.id, 1)
|
||||
})
|
||||
|
||||
test.serial('post "/api/point/add" body containing a name, location and map_id should return a point', async t => {
|
||||
const { body: { id: mapId } } = await request(App).get('/api/map/bingo')
|
||||
const res = await request(App)
|
||||
.post('/api/point/add')
|
||||
.send({
|
||||
mapId,
|
||||
point: {
|
||||
name: 'pointy',
|
||||
location: '(50.8552,4.3454)',
|
||||
}
|
||||
})
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.is(res.body.id, 1)
|
||||
t.is(res.body.map_id, mapId)
|
||||
t.is(res.body.name, 'pointy')
|
||||
})
|
||||
|
||||
test.serial('get "/api/map/:mapName" with associated points should return a map with an array of points', async t => {
|
||||
const res = await request(App).get('/api/map/bingo')
|
||||
|
||||
t.is(res.status, 200)
|
||||
t.truthy(res.body.map_points)
|
||||
t.is(res.body.map_points.length, 1)
|
||||
})
|
||||
|
||||
test.serial('post "/api/point/add" with incorrect data keys throws 500 error', async t => {
|
||||
const { body: { id: mapId } } = await request(App).get('/api/map/bingo')
|
||||
const error = await request(App)
|
||||
.post('/api/point/add')
|
||||
.send({
|
||||
mapId,
|
||||
point: {
|
||||
title: 'pointy',
|
||||
coords: '(50.8552,4.3454)',
|
||||
}
|
||||
})
|
||||
|
||||
t.is(error.status, 500)
|
||||
})
|
||||
|
||||
test.after(async () => {
|
||||
await db.migrate.down()
|
||||
})
|
||||
|
53
frontend/.eslintrc.cjs
Normal file
53
frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,53 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
'env': {
|
||||
'browser': true,
|
||||
'node': true,
|
||||
'es2019': true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:lit/recommended',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
'rules': {
|
||||
'no-var': 'error',
|
||||
'semi': [
|
||||
'error',
|
||||
'never',
|
||||
],
|
||||
'quotes': [
|
||||
'error',
|
||||
'single',
|
||||
],
|
||||
'object-curly-spacing': [
|
||||
'warn',
|
||||
'always',
|
||||
],
|
||||
'array-bracket-spacing': [
|
||||
'warn',
|
||||
'always',
|
||||
],
|
||||
'space-in-parens': [
|
||||
'warn',
|
||||
'never',
|
||||
],
|
||||
'array-bracket-newline': [
|
||||
'warn',
|
||||
'consistent',
|
||||
],
|
||||
'object-curly-newline': [
|
||||
'warn',
|
||||
{
|
||||
'consistent': true,
|
||||
},
|
||||
],
|
||||
'space-before-blocks': [
|
||||
'warn',
|
||||
'always',
|
||||
],
|
||||
},
|
||||
};
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
25
frontend/README.md
Normal file
25
frontend/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# ethermap frontend
|
||||
|
||||
Frontend for ethermap
|
||||
|
||||
## Install
|
||||
|
||||
To install the frontend you will need [NodeJS](https://nodejs.org/en) and `npm` installed. Then :
|
||||
|
||||
```sh
|
||||
$ npm i
|
||||
```
|
||||
|
||||
To run the development server run :
|
||||
|
||||
```sh
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
## Tech
|
||||
|
||||
The interface is built with [LitElement](https://lit.dev/) and setup with [Vite](https://vitejs.dev/) bundler and dev server.
|
||||
|
||||
Maps are rendered with [Leaflet](https://leafletjs.com).
|
||||
|
||||
For ethermap to work you will also need to be running the [ethermap.api]() server for REST API and Socket.io connectivity.
|
26
frontend/index.html
Normal file
26
frontend/index.html
Normal file
@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
|
||||
<!-- leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<!-- styles -->
|
||||
<link rel="stylesheet" href="/styles/main.css">
|
||||
<!-- javascript -->
|
||||
<script type="module" src="/src/EthermapApp.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<ethermap-app></ethermap-app>
|
||||
</body>
|
||||
</html>
|
1939
frontend/package-lock.json
generated
Normal file
1939
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ethermap.front",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-lit": "^1.9.1",
|
||||
"vite": "^4.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-contextmenu": "^1.4.0",
|
||||
"lit": "^2.8.0",
|
||||
"socket.io-client": "^4.7.2"
|
||||
}
|
||||
}
|
8
frontend/public/icons/cursor.svg
Normal file
8
frontend/public/icons/cursor.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve">
|
||||
|
||||
<defs>
|
||||
</defs>
|
||||
<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" >
|
||||
<path d="M 63.269 55.583 l 25.589 -10.294 c 1.682 -0.677 1.459 -3.168 -0.362 -4.041 L 2.884 0.205 c -1.71 -0.82 -3.499 0.969 -2.679 2.679 l 41.043 85.612 c 0.873 1.821 3.364 2.044 4.041 0.362 l 10.294 -25.589 C 56.991 59.767 59.767 56.991 63.269 55.583 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 951 B |
17
frontend/public/styles/main.css
Normal file
17
frontend/public/styles/main.css
Normal file
@ -0,0 +1,17 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding-inline: 0.75em;
|
||||
padding-block: 0.75em;
|
||||
}
|
||||
|
||||
main.map {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
53
frontend/src/EthermapApp.js
Normal file
53
frontend/src/EthermapApp.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import './components/PointModal.js'
|
||||
import Router from './controllers/Router.js'
|
||||
|
||||
class EthermapApp extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
route: {}
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.route = Router.render()
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
Router.addEventListener('route-changed', () => {
|
||||
console.log('route-changed')
|
||||
this.route = Router.render()
|
||||
})
|
||||
|
||||
this.addEventListener('open-modal', (evt) => {
|
||||
const modal = this.shadowRoot.querySelector('point-modal')
|
||||
modal.open(evt.detail.mapId, evt.detail.latlng)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<nav>toolbar</nav>
|
||||
${this.route}
|
||||
<point-modal></point-modal>
|
||||
`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding-inline: 0.75em;
|
||||
padding-block: 0.75em;
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('ethermap-app', EthermapApp)
|
122
frontend/src/components/MapView.js
Normal file
122
frontend/src/components/MapView.js
Normal file
@ -0,0 +1,122 @@
|
||||
import { unsafeCSS, LitElement, html, css } from 'lit'
|
||||
import leafletCss from 'leaflet/dist/leaflet.css?inline'
|
||||
import leafletContextCss from 'leaflet-contextmenu/dist/leaflet.contextmenu.css?inline'
|
||||
import * as L from 'leaflet'
|
||||
import 'leaflet-contextmenu'
|
||||
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
import MapController from '../controllers/MapController'
|
||||
|
||||
class MapView extends LitElement {
|
||||
mapController = new MapController(this)
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
name: { type: String },
|
||||
map: { state: true },
|
||||
points: { type: Array, state: true },
|
||||
leaflet: { state: true },
|
||||
socket: { state: true },
|
||||
users: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.name = ''
|
||||
this.leaflet = {}
|
||||
this.points = []
|
||||
this.users = []
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
// connect to socket
|
||||
this.socket = io('http://localhost:3000')
|
||||
|
||||
// create leaflet map
|
||||
const mapEl = this.shadowRoot.querySelector('main')
|
||||
this.leaflet = L.map(mapEl, {
|
||||
contextmenu: true,
|
||||
contextmenuWidth: 140,
|
||||
contextmenuItems: [ {
|
||||
text: 'create point',
|
||||
callback: (evt) => {
|
||||
const event = new CustomEvent('open-modal', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { mapId: this.mapController.map.id, latlng: evt.latlng }
|
||||
})
|
||||
|
||||
this.dispatchEvent(event)
|
||||
}
|
||||
} ]
|
||||
}).setView([ 51.89190, 4.46920 ], 19)
|
||||
|
||||
// set map tiles
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(this.leaflet)
|
||||
|
||||
|
||||
// track mouse movement
|
||||
this.leaflet.on('mousemove', (evt) => {
|
||||
this.socket.emit('mousemove', evt.latlng)
|
||||
})
|
||||
|
||||
// track other users
|
||||
this.socket.on('mousemove', (data) => {
|
||||
// TODO@me check whether user is in view before rendering
|
||||
// TODO@me restrict rendering within range of zoom-level
|
||||
const { id, pos } = data
|
||||
|
||||
// if user is not created, then do so
|
||||
if (!this.users[id]) {
|
||||
const cursorIcon = L.icon({ iconUrl: '/icons/cursor.svg', iconSize: [ 20, 30 ], iconAnchor: [ 0, 8 ], className: 'user-cursor' })
|
||||
this.users[id] = L.marker([ pos.lat, pos.lng ], { icon: cursorIcon, interactive: false, zIndexOffset: 1000 }).addTo(this.leaflet)
|
||||
}
|
||||
|
||||
// update cursor's position
|
||||
this.users[id].setLatLng([ pos.lat, pos.lng ])
|
||||
})
|
||||
|
||||
// get the map with name
|
||||
this.mapController.get(this.name).then(m => {
|
||||
this.socket.emit('connect-map', m.id)
|
||||
|
||||
// set points
|
||||
this.setPoints(m.map_points)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
setPoints(points) {
|
||||
points.forEach((p, i) => {
|
||||
this.points[i] = L.marker([ p.location.x, p.location.y ]).addTo(this.leaflet)
|
||||
this.points[i].bindPopup(`<h3>${p.name}</h3>${p.notes ? `<p>${p.notes}</p>` : ''}`)
|
||||
})
|
||||
|
||||
const end = points.length - 1
|
||||
this.leaflet.setView([ points[end].location.x, points[end].location.y ], 13)
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<main></main>`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [ unsafeCSS(leafletCss), unsafeCSS(leafletContextCss), css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
` ]
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('map-view', MapView)
|
72
frontend/src/components/NewMapModal.js
Normal file
72
frontend/src/components/NewMapModal.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import Router from "../controllers/Router";
|
||||
|
||||
class NewMapModal extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
mapName: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.mapName = ''
|
||||
}
|
||||
|
||||
navigate(evt) {
|
||||
evt.preventDefault()
|
||||
Router.navigate(`/m/${this.mapName}`)
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<main>
|
||||
<div class="window">
|
||||
<form>
|
||||
<label for="name">map name</label>
|
||||
<input type="text" name="name" @input=${e => this.mapName = e.target.value}></input>
|
||||
<div class="controls">
|
||||
<button @click=${this.navigate}>go</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
main {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
background: #33333350;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.window {
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border: thick solid black;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 5ch;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
grid-auto-flow: rows;
|
||||
gap: 0.25em;
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('newmap-modal', NewMapModal)
|
104
frontend/src/components/PointModal.js
Normal file
104
frontend/src/components/PointModal.js
Normal file
@ -0,0 +1,104 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { live } from 'lit/directives/live.js'
|
||||
import PointController from '../controllers/PointController.js'
|
||||
|
||||
class PointModal extends LitElement {
|
||||
|
||||
pointController = new PointController(this)
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
latlng: { type: Object },
|
||||
mapId: { type: Number, state: true },
|
||||
id: { type: Number },
|
||||
name: { type: String },
|
||||
notes: { type: String },
|
||||
modalEl: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.id = ''
|
||||
this.mapId = ''
|
||||
this.name = ''
|
||||
this.notes = ''
|
||||
this.latlng = {}
|
||||
this.modalEl = {}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.modalEl = this.shadowRoot.querySelector('dialog')
|
||||
}
|
||||
|
||||
open(mapId, latlng) {
|
||||
this.mapId = mapId
|
||||
console.log(mapId)
|
||||
this.latlng = { ...latlng }
|
||||
this.name = ''
|
||||
this.notes = ''
|
||||
this.modalEl.showModal()
|
||||
}
|
||||
|
||||
close(evt) {
|
||||
evt.preventDefault()
|
||||
this.modalEl.close()
|
||||
}
|
||||
|
||||
save(evt) {
|
||||
evt.preventDefault()
|
||||
console.log(this.mapId)
|
||||
PointController.savePoint(this.mapId, { name: this.name, notes: this.notes, location: this.latlng })
|
||||
// TODO@me validate form entry
|
||||
// TODO@me check if successful
|
||||
this.close(evt)
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<dialog>
|
||||
<h2>create new point</h2>
|
||||
<p>${this.latlng.lat}, ${this.latlng.lng}</p>
|
||||
<form>
|
||||
<label name="name" for="name">name</label>
|
||||
<input name="name" type="text" .value=${live(this.name)} @input=${e => this.name = e.target.value}>
|
||||
|
||||
<label name="notes" for="notes">notes</label>
|
||||
<textarea name="notes" .value=${live(this.notes)} @input=${e => this.notes = e.target.value}></textarea>
|
||||
|
||||
<div class="controls">
|
||||
<button @click=${this.close}>cancel</button> <button @click=${this.save}>save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
form {
|
||||
display: grid;
|
||||
grid-auto-flow: rows;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.controls > :first-child {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0.25em;
|
||||
}
|
||||
|
||||
label, .controls {
|
||||
margin-block-start: 0.25em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block: 0.125em;
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('point-modal', PointModal)
|
21
frontend/src/controllers/MapController.js
Normal file
21
frontend/src/controllers/MapController.js
Normal file
@ -0,0 +1,21 @@
|
||||
export default class MapController {
|
||||
constructor(host) {
|
||||
this.host = host
|
||||
this.host.addController(this)
|
||||
|
||||
this.map = {}
|
||||
}
|
||||
|
||||
async get(name) {
|
||||
try {
|
||||
const req = await fetch(`http://localhost:3000/api/map/${name}`)
|
||||
const json = await req.json()
|
||||
|
||||
this.map = json
|
||||
|
||||
return json
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
28
frontend/src/controllers/PointController.js
Normal file
28
frontend/src/controllers/PointController.js
Normal file
@ -0,0 +1,28 @@
|
||||
export default class PointController {
|
||||
constructor(host) {
|
||||
this.host = host
|
||||
this.host.addController(this)
|
||||
}
|
||||
|
||||
static async savePoint(mapId, point) {
|
||||
try {
|
||||
// set location to point for db
|
||||
point.location = `(${point.location.lat}, ${point.location.lng})`
|
||||
|
||||
const res = await fetch('http://localhost:3000/api/point/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mapId,
|
||||
point
|
||||
})
|
||||
})
|
||||
|
||||
console.log(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
36
frontend/src/controllers/Router.js
Normal file
36
frontend/src/controllers/Router.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { html } from 'lit'
|
||||
import { Router } from '@thepassle/app-tools/router.js'
|
||||
|
||||
// views
|
||||
import '../components/MapView.js'
|
||||
import '../components/NewMapModal.js'
|
||||
|
||||
if (!globalThis.URLPattern) {
|
||||
await import('urlpattern-polyfill')
|
||||
}
|
||||
|
||||
// router
|
||||
export default new Router({
|
||||
fallback: '/404',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
title: 'ethermap | index',
|
||||
render: () => html`
|
||||
<map-view></map-view>
|
||||
<newmap-modal></newmap-modal>
|
||||
`
|
||||
},
|
||||
{
|
||||
path: '/m/:mapId',
|
||||
title: ({ params }) => `ethermap | ${params.mapId}`,
|
||||
render: ({ params }) => html`<map-view name=${params.mapId}></map-view>`
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
title: 'etherpad | 404',
|
||||
render: () => html`<h2>404 : page not found</h2>`
|
||||
}
|
||||
]
|
||||
})
|
||||
|
9
frontend/vite.config.js
Normal file
9
frontend/vite.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
build: {
|
||||
sourcemap: true,
|
||||
target: [ 'esnext', 'edge100', 'firefox100', 'chrome100', 'safari18' ]
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue
Block a user