feat: new live tracker map example

This commit is contained in:
phamhieu committed 2021-03-23 09:57:11 +08:00
1 parent e5bb9bfcff
commit 02092cdcfc
46 files changed
+5124 -12224

No files matched your search

+52 -1
View File
@@ -1 +1,52 @@
# Live Tracker Map [COMING SOON]
# Live Tracker Map [WIP]
Build with supabase-js and leafletjs. [[Demo App]](https://realtime-map.vercel.app)
<p align="center">
<kbd>
<img src="https://i.imgur.com/WoMRKwo.gif" alt="Demo"/>
</kbd>
</p>
## Test with your own Supabase Project
Note: you have to enable `DISABLE EMAIL CONFIRMATIONS` under your Supabase project settings.
#### Database schema
Go to [app.supabase.io](https://app.supabase.io/), create a new organisation and project if you haven't had one.
Run this sql query to create required tables.
```sql
-- Locations
CREATE TABLE public.locations (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
latitude numeric NOT NULL,
longitude numeric NOT NULL,
user_id uuid REFERENCES auth.users NOT NULL
);
ALTER TABLE public.locations REPLICA IDENTITY FULL; -- Send "previous data" on change
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
COMMENT ON table public.locations IS 'Individual locations sent by each user.';
CREATE POLICY "Allow logged-in read access" on public.locations USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow individual insert access" on public.locations FOR INSERT WITH CHECK ( auth.uid() = user_id );
```
#### Setup env vars
Next, copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git):
```bash
cp .env.local.example .env.local
```
Then set each variable on `.env.local`:
- `NEXT_PUBLIC_SUPABASE_URL` should be the **API URL**
- `NEXT_PUBLIC_SUPABASE_KEY` should be the **anon** key
You can get these values from your project dashboard at [app.supabase.io](https://app.supabase.io/).
The **anon** key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data.
> **_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
@@ -1,91 +0,0 @@
# Live Tracker Map [WIP]
Build with supabase auth, realtime and leafletjs. [[Demo App]](https://realtime-map-v2.vercel.app/)
**This `archive` project doesn't use official supabase-js and gotrue-js libs.**
<p align="center">
<kbd>
<img src="https://media.giphy.com/media/iDU80ngpsSddc0ObGI/giphy.gif" alt="Demo"/>
</kbd>
</p>
## How to use
#### Clone this folder
```bash
git clone https://github.com/supabase/supabase
cd supabase/examples/live-tracker-map
```
#### Install dependencies
```bash
npm install
```
#### Start the app
```bash
# Open a terminal and run:
npm run dev
```
Visit http://localhost:3000 and start testing!
## Test with your own Supabase Project
#### Database schema
Go to [app.supabase.io](https://app.supabase.io/), create a new organisation and project if you haven't had one.
Run this sql query to create required tables.
```sql
-- USERS
CREATE TYPE public.user_role AS ENUM ('DRIVER', 'MANAGER');
CREATE TABLE public.users (
id uuid NOT NULL PRIMARY KEY, -- UUID from auth.users
username text,
role user_role DEFAULT 'DRIVER'::public.user_role
);
COMMENT ON table public.users IS 'Profile data for each user.';
COMMENT ON column public.users.id IS 'References the internal Supabase Auth user.';
ALTER TABLE public.users REPLICA IDENTITY FULL; -- Send "previous data" on change
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow logged-in read access" on public.users FOR SELECT USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow individual insert access" on public.users FOR INSERT WITH CHECK ( auth.uid() = id );
CREATE POLICY "Allow individual update access" on public.users FOR UPDATE USING ( auth.uid() = id );
-- LOCATIONS
CREATE TABLE public.locations (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
latitude numeric NOT NULL,
longitude numeric NOT NULL,
user_id uuid REFERENCES public.users NOT NULL
);
ALTER TABLE public.locations REPLICA IDENTITY FULL; -- Send "previous data" on change
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
COMMENT ON table public.locations IS 'Individual locations sent by each user.';
CREATE POLICY "Allow logged-in read access" on public.locations USING ( auth.role() = 'authenticated' );
CREATE POLICY "Allow individual insert access" on public.locations FOR INSERT WITH CHECK ( auth.uid() = user_id );
```
#### Setup env vars
Next, copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git):
```bash
cp .env.local.example .env.local
```
Then set each variable on `.env.local`:
- `NEXT_PUBLIC_SUPABASE_ENDPOINT` should be the **API URL**
- `NEXT_PUBLIC_SUPABASE_APIKEY` should be the **anon** key
You can get these values from your project dashboard at [app.supabase.io](https://app.supabase.io/).
The **anon** key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data.
> **_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
@@ -1,81 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { MapContainer, TileLayer, Polyline } from 'react-leaflet'
import { RoundToFixDecimals } from "lib/Utils"
import TextLog from "components/TextLog"
function randomColor() {
var array = ["black", "magenta", "blue", "indigo", "green", "blueviolet"];
return array[Math.floor(Math.random() * array.length)];
}
function MapView({ supabase, center, zoom }) {
const [log, setLog] = useState(undefined)
const [positions, setPositions] = useState([])
const [userPositionsDict, setUserPositionsDict] = useState([])
const mySubscription = useRef(false)
useEffect(() => {
let newLog = `Start listerning...`
newLog += positions.map(item => { return `\nuser_id=${item.user_id} lat=${RoundToFixDecimals(item.lat)} long=${RoundToFixDecimals(item.lng)}` })
setLog(newLog)
}, [positions])
useEffect(() => {
// Listen to INSERT event on locations table
mySubscription.current = supabase
.from('locations')
.on('INSERT', payload => {
console.log('Change received!', payload)
const { new: newItem } = payload
const { id, user_id, latitude, longitude } = newItem
let userPositions = userPositionsDict[user_id]
if (userPositions) userPositions.data = [...userPositions.data, { id, user_id, lat: latitude, lng: longitude }]
else userPositions = { color: randomColor(), data: [{ id, user_id, lat: latitude, lng: longitude }] }
setPositions([...positions, { id, user_id, lat: latitude, lng: longitude }])
setUserPositionsDict({ ...userPositionsDict, [user_id]: userPositions })
})
.subscribe()
return () => {
if (mySubscription.current) supabase.removeSubscription(mySubscription.current)
}
}, [supabase, positions, setPositions, userPositionsDict, setUserPositionsDict])
function drawPolylines() {
// console.log(userPositionsDict)
function drawPolyline(color, data) {
const polyline = data.map(item => {
if (!item) return
const { lat, lng } = item
return [lat, lng]
})
return <Polyline pathOptions={{ color: color }} positions={polyline} />
}
let result = Object.keys(userPositionsDict).map(key => {
const item = userPositionsDict[key]
if (!item) return
const { color, data } = item
return drawPolyline(color, data)
})
return result
}
return (
<div className="map-view">
<MapContainer center={center} zoom={zoom || 15} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{drawPolylines()}
</MapContainer>
<TextLog log={log} />
</div>
)
}
export default MapView
@@ -1,95 +0,0 @@
import React, { useState, useEffect } from 'react'
import { auth } from 'lib/Store'
/**
* Sign in with username and password
*
* @param {String} role DRIVER/MANAGER. default as DRIVER
*/
export default function SignIn({ role = "DRIVER" }) {
const [formData, updateFormData] = useState({ role });
const [action, setAction] = useState(null);
function onChange(e) {
updateFormData({
...formData,
// Trimming any whitespace
[e.target.name]: e.target.value.trim()
});
};
async function postAndWait(url, data, options = {}) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(data),
...options,
})
const body = await response.json()
return body
}
async function onSubmit(event) {
event.preventDefault();
try {
if (action === "SIGNUP") {
const signupResponse = await postAndWait(`/api/auth/signup`, formData)
await auth.loginWithRefreshToken(signupResponse.refresh_token, true)
window.location.reload();
} else if (action === "LOGIN") {
const loginResponse = await postAndWait(`/api/auth/login`, formData)
await auth.loginWithRefreshToken(loginResponse.refresh_token, true)
window.location.reload();
}
} catch (error) {
console.log('error', error)
alert("Authentication failed. Please check your input.")
}
}
return (
<form className="container" onSubmit={onSubmit}>
<label>
Email
<input name="email" type='email' onChange={onChange} />
</label>
<label>
Password
<input name="password" type='password' onChange={onChange} />
</label>
<button type='submit' onClick={() => setAction("SIGNUP")}>Sign up</button>
<button type='submit' onClick={() => setAction("LOGIN")}>Login</button>
<style jsx>{`
.container {
min-width: 20rem;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
}
label {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
input {
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 1rem;
}
button {
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 1rem;
}
`}</style>
</form>
)
}
@@ -1,14 +0,0 @@
import { createClient } from "lib/supabase/index"
import GoTrue from "lib/gotrue/index";
// Create a single supabase client for interacting with your database
export const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_ENDPOINT, process.env.NEXT_PUBLIC_SUPABASE_APIKEY);
export const auth = new GoTrue({
APIUrl: `${process.env.NEXT_PUBLIC_SUPABASE_ENDPOINT}/auth/v1`,
audience: '',
setCookie: true,
});
const user = auth.currentUser();
if (user) supabase.setAccessToken(user.token.access_token)
@@ -1,5 +0,0 @@
import { createContext } from 'react'
const UserContext = createContext()
export default UserContext
@@ -1,39 +0,0 @@
export default class Admin {
constructor(user) {
this.user = user;
}
// Return a list of all users in an audience
listUsers(aud) {
return this.user._request('/admin/users', {
method: 'GET',
audience: aud,
});
}
getUser(user) {
return this.user._request(`/admin/users/${user.id}`);
}
updateUser(user, attributes = {}) {
return this.user._request(`/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(attributes),
});
}
createUser(email, password, attributes = {}) {
attributes.email = email;
attributes.password = password;
return this.user._request('/admin/users', {
method: 'POST',
body: JSON.stringify(attributes),
});
}
deleteUser(user) {
return this.user._request(`/admin/users/${user.id}`, {
method: 'DELETE',
});
}
}
@@ -1,144 +0,0 @@
import API, { JSONHTTPError } from 'micro-api-client';
import User from './user';
const HTTPRegexp = /^http:\/\//;
const defaultApiURL = `/.netlify/identity`;
export default class GoTrue {
constructor({ APIUrl = defaultApiURL, audience = '', setCookie = false } = {}) {
if (APIUrl.match(HTTPRegexp)) {
console.warn(
'Warning:\n\nDO NOT USE HTTP IN PRODUCTION FOR GOTRUE EVER!\nGoTrue REQUIRES HTTPS to work securely.',
);
}
if (audience) {
this.audience = audience;
}
this.setCookie = setCookie;
this.api = new API(APIUrl);
}
_request(path, options = {}) {
options.headers = options.headers || {};
options.headers['apikey'] = process.env.NEXT_PUBLIC_SUPABASE_APIKEY;
const aud = options.audience || this.audience;
if (aud) {
options.headers['X-JWT-AUD'] = aud;
}
return this.api.request(path, options).catch((err) => {
if (err instanceof JSONHTTPError && err.json) {
if (err.json.msg) {
err.message = err.json.msg;
} else if (err.json.error) {
err.message = `${err.json.error}: ${err.json.error_description}`;
}
}
return Promise.reject(err);
});
}
settings() {
return this._request('/settings');
}
signup(email, password, data) {
return this._request('/signup', {
method: 'POST',
body: JSON.stringify({ email, password, data }),
});
}
login(email, password, remember) {
this._setRememberHeaders(remember);
return this._request('/token?grant_type=password', {
method: 'POST',
body: JSON.stringify({ email, password }),
}).then((response) => {
User.removeSavedSession();
return this.createUser(response, remember);
});
}
loginWithRefreshToken(refreshToken, remember) {
this._setRememberHeaders(remember);
return this._request('/token?grant_type=refresh_token', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
}).then((response) => {
User.removeSavedSession();
return this.createUser(response, remember);
});
}
loginExternalUrl(provider) {
return `${this.api.apiURL}/authorize?provider=${provider}`;
}
confirm(token, remember) {
this._setRememberHeaders(remember);
return this.verify('signup', token, remember);
}
requestPasswordRecovery(email) {
return this._request('/recover', {
method: 'POST',
body: JSON.stringify({ email }),
});
}
recover(token, remember) {
this._setRememberHeaders(remember);
return this.verify('recovery', token, remember);
}
acceptInvite(token, password, remember) {
this._setRememberHeaders(remember);
return this._request('/verify', {
method: 'POST',
body: JSON.stringify({ token, password, type: 'signup' }),
}).then((response) => this.createUser(response, remember));
}
acceptInviteExternalUrl(provider, token) {
return `${this.api.apiURL}/authorize?provider=${provider}&invite_token=${token}`;
}
createUser(tokenResponse, remember = false) {
this._setRememberHeaders(remember);
const user = new User(this.api, tokenResponse, this.audience);
return user.getUserData().then((user) => {
if (remember) {
user._saveSession();
}
return user;
});
}
currentUser() {
const user = User.recoverSession(this.api);
user && this._setRememberHeaders(user._fromStorage);
return user;
}
verify(type, token, remember) {
this._setRememberHeaders(remember);
return this._request('/verify', {
method: 'POST',
body: JSON.stringify({ token, type }),
}).then((response) => this.createUser(response, remember));
}
_setRememberHeaders(remember) {
if (this.setCookie) {
this.api.defaultHeaders = this.api.defaultHeaders || {};
this.api.defaultHeaders['X-Use-Cookie'] = remember ? '1' : 'session';
}
}
}
if (typeof window !== 'undefined') {
window.GoTrue = GoTrue;
}
@@ -1,223 +0,0 @@
import API, { JSONHTTPError } from 'micro-api-client';
import Admin from './admin';
const ExpiryMargin = 60 * 1000;
const storageKey = 'gotrue.user';
const refreshPromises = {};
let currentUser = null;
const forbiddenUpdateAttributes = { api: 1, token: 1, audience: 1, url: 1 };
const forbiddenSaveAttributes = { api: 1 };
const isBrowser = () => typeof window !== 'undefined';
export default class User {
constructor(api, tokenResponse, audience) {
this.api = api;
this.url = api.apiURL;
this.audience = audience;
this._processTokenResponse(tokenResponse);
currentUser = this;
}
static removeSavedSession() {
isBrowser() && localStorage.removeItem(storageKey);
}
static recoverSession(apiInstance) {
if (currentUser) {
return currentUser;
}
const json = isBrowser() && localStorage.getItem(storageKey);
if (json) {
try {
const data = JSON.parse(json);
const { url, token, audience } = data;
if (!url || !token) {
return null;
}
const api = apiInstance || new API(url, {});
return new User(api, token, audience)._saveUserData(data, true);
} catch (ex) {
console.error(new Error(`Gotrue-js: Error recovering session: ${ex}`));
return null;
}
}
return null;
}
get admin() {
return new Admin(this);
}
update(attributes) {
return this._request('/user', {
method: 'PUT',
body: JSON.stringify(attributes),
}).then((response) => {
return this._saveUserData(response)._refreshSavedSession();
});
}
jwt(forceRefresh) {
const token = this.tokenDetails();
if (token === null || token === undefined) {
return Promise.reject(new Error(`Gotrue-js: failed getting jwt access token`));
}
const { expires_at, refresh_token, access_token } = token;
if (forceRefresh || expires_at - ExpiryMargin < Date.now()) {
return this._refreshToken(refresh_token);
}
return Promise.resolve(access_token);
}
logout() {
return this._request('/logout', { method: 'POST' })
.then(this.clearSession.bind(this))
.catch(this.clearSession.bind(this));
}
_refreshToken(refresh_token) {
if (refreshPromises[refresh_token]) {
return refreshPromises[refresh_token];
}
return (refreshPromises[refresh_token] = this.api
.request('/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=refresh_token&refresh_token=${refresh_token}`,
})
.then((response) => {
delete refreshPromises[refresh_token];
this._processTokenResponse(response);
this._refreshSavedSession();
return this.token.access_token;
})
.catch((error) => {
delete refreshPromises[refresh_token];
this.clearSession();
return Promise.reject(error);
}));
}
_request(path, options = {}) {
options.headers = options.headers || {};
options.headers['apikey'] = process.env.NEXT_PUBLIC_SUPABASE_APIKEY;
const aud = options.audience || this.audience;
if (aud) {
options.headers['X-JWT-AUD'] = aud;
}
return this.jwt().then((token) => {
return this.api
.request(path, {
headers: Object.assign(options.headers, {
Authorization: `Bearer ${token}`,
}),
...options,
})
.catch((err) => {
if (err instanceof JSONHTTPError && err.json) {
if (err.json.msg) {
err.message = err.json.msg;
} else if (err.json.error) {
err.message = `${err.json.error}: ${err.json.error_description}`;
}
}
return Promise.reject(err);
});
});
}
getUserData() {
return this._request('/user')
.then(this._saveUserData.bind(this))
.then(this._refreshSavedSession.bind(this));
}
_saveUserData(attributes, fromStorage) {
for (const key in attributes) {
if (key in User.prototype || key in forbiddenUpdateAttributes) {
continue;
}
this[key] = attributes[key];
}
if (fromStorage) {
this._fromStorage = true;
}
return this;
}
_processTokenResponse(tokenResponse) {
this.token = tokenResponse;
let claims;
try {
claims = JSON.parse(urlBase64Decode(tokenResponse.access_token.split('.')[1]));
this.token.expires_at = claims.exp * 1000;
} catch (e) {
console.error(
new Error(
`Gotrue-js: Failed to parse tokenResponse claims: ${JSON.stringify(tokenResponse)}`,
),
);
}
}
_refreshSavedSession() {
// only update saved session if we previously saved something
if (isBrowser() && localStorage.getItem(storageKey)) {
this._saveSession();
}
return this;
}
get _details() {
const userCopy = {};
for (const key in this) {
if (key in User.prototype || key in forbiddenSaveAttributes) {
continue;
}
userCopy[key] = this[key];
}
return userCopy;
}
_saveSession() {
isBrowser() && localStorage.setItem(storageKey, JSON.stringify(this._details));
return this;
}
tokenDetails() {
return this.token;
}
clearSession() {
User.removeSavedSession();
this.token = null;
currentUser = null;
}
}
function urlBase64Decode(str) {
// From https://jwt.io/js/jwt.js
var output = str.replace(/-/g, '+').replace(/_/g, '/');
switch (output.length % 4) {
case 0:
break;
case 2:
output += '==';
break;
case 3:
output += '=';
break;
default:
throw 'Illegal base64url string!';
}
var result = window.atob(output); //polifyll https://github.com/davidchambers/Base64.js
try {
return decodeURIComponent(escape(result));
} catch (err) {
return result;
}
}
@@ -1,171 +0,0 @@
import superagent from 'superagent'
const isBrowser = () => typeof window !== 'undefined'
const storageKey = 'supabase.auth.token'
class Auth {
constructor(authUrl, supabaseKey, options = { autoRefreshToken: true, persistSession: true }) {
this.authUrl = authUrl
this.accessToken = supabaseKey
this.refreshToken = null
this.supabaseKey = supabaseKey
this.currentUser = null
this.autoRefreshToken = options.autoRefreshToken === undefined ? true : options.autoRefreshToken
this.persistSession = options.persistSession === undefined ? true : options.persistSession
this.signup = async (email, password) => {
this.removeSavedSession() // clean out the old session before attempting
const response = await superagent
.post(`${authUrl}/signup`, { email, password })
.set('accept', 'json')
.set('apikey', this.supabaseKey)
if (response.status === 200 && response['body']['user']['confirmed_at']) {
this.accessToken = response.body['access_token']
this.refreshToken = response.body['refresh_token']
this.currentUser = response.body['user']
let tokenExpirySeconds = response.body['expires_in']
if (this.autoRefreshToken && tokenExpirySeconds)
setTimeout(this.callRefreshToken, (tokenExpirySeconds - 60) * 1000)
if (this.persistSession) {
const timeNow = Math.round(Date.now() / 1000)
this.saveSession(
this.accessToken,
this.refreshToken,
timeNow + tokenExpirySeconds,
this.currentUser
)
}
}
return response
}
this.login = async (email, password) => {
this.removeSavedSession() // clean out the old session before attempting
let response = await superagent
.post(`${authUrl}/token?grant_type=password`, { email, password })
.set('accept', 'json')
.set('apikey', this.supabaseKey)
if (response.status === 200) {
this.accessToken = response.body['access_token']
this.refreshToken = response.body['refresh_token']
this.currentUser = response.body['user']
let tokenExpirySeconds = response.body['expires_in']
if (this.autoRefreshToken && tokenExpirySeconds)
setTimeout(this.callRefreshToken, (tokenExpirySeconds - 60) * 1000)
if (this.persistSession) {
const timeNow = Math.round(Date.now() / 1000)
this.saveSession(
this.accessToken,
this.refreshToken,
timeNow + tokenExpirySeconds,
this.currentUser
)
}
}
return response
}
this.callRefreshToken = async () => {
let response = await superagent
.post(`${authUrl}/token?grant_type=refresh_token`, { refresh_token: this.refreshToken })
.set('accept', 'json')
.set('apikey', this.supabaseKey)
if (response.status === 200) {
this.accessToken = response.body['access_token']
this.refreshToken = response.body['refresh_token']
let tokenExpirySeconds = response.body['expires_in']
if (this.autoRefreshToken && tokenExpirySeconds)
setTimeout(this.callRefreshToken, (tokenExpirySeconds - 60) * 1000)
if (this.persistSession) {
const timeNow = Math.round(Date.now() / 1000)
this.saveSession(
this.accessToken,
this.refreshToken,
timeNow + tokenExpirySeconds,
this.currentUser
)
}
}
return response
}
this.logout = async () => {
await superagent
.post(`${authUrl}/logout`)
.set('Authorization', `Bearer ${this.accessToken}`)
.set('apikey', this.supabaseKey)
this.removeSavedSession()
}
this.user = async () => {
if (this.currentUser) return this.currentUser
let response = await superagent
.get(`${authUrl}/user`)
.set('Authorization', `Bearer ${this.accessToken}`)
.set('apikey', this.supabaseKey)
if (response.status === 200) {
this.currentUser = response.body
this.currentUser['access_token'] = this.accessToken
this.currentUser['refresh_token'] = this.refreshToken
}
return this.currentUser
}
this.saveSession = (accessToken, refreshToken, expiresAt, currentUser) => {
const data = { accessToken, refreshToken, expiresAt, currentUser }
isBrowser() && localStorage.setItem(storageKey, JSON.stringify(data))
}
this.removeSavedSession = () => {
this.currentUser = null
this.refreshToken = null
this.accessToken = this.supabaseKey
isBrowser() && localStorage.removeItem(storageKey)
}
this.authHeader = () => {
let json = isBrowser() && localStorage.getItem(storageKey)
let persisted = json ? JSON.parse(json) : null
if (persisted?.accessToken) return `Bearer ${persisted.accessToken}`
else if (this.accessToken) return `Bearer ${this.accessToken}`
else return null
}
this.recoverSession = () => {
const json = isBrowser() && localStorage.getItem(storageKey)
if (json) {
try {
const data = JSON.parse(json)
const { accessToken, refreshToken, currentUser, expiresAt } = data
const timeNow = Math.round(Date.now() / 1000)
if (expiresAt < timeNow) {
console.log('saved session has expired')
this.removeSavedSession()
} else {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.currentUser = currentUser
// schedule a refresh 60 seconds before token due to expire
setTimeout(this.callRefreshToken, (expiresAt - timeNow - 60) * 1000)
}
} catch (err) {
console.error(err)
return null
}
}
return null
}
this.recoverSession()
}
}
export { Auth }
@@ -1,155 +0,0 @@
import { Socket } from '@supabase/realtime-js'
import * as ChangeMapper from './utils/ChangeMapper'
class Realtime {
constructor(
tableName,
realtimeUrl,
schema,
apikey,
uuid,
eventType,
callbackFunction,
queryFilters
) {
this.tableName = tableName
this.realtimeUrl = realtimeUrl
this.schema = schema
this.apikey = apikey
this.uuid = uuid
this.socket = null
this.channel = null
this.listeners = {}
this.queryFilters = queryFilters
this.on(eventType, callbackFunction)
}
/**
* REALTIME FUNCTIONALITY
*/
createListener() {
let socketUrl = `${this.realtimeUrl}`
var filterString = ''
this.queryFilters.forEach((queryFilter) => {
switch (queryFilter.filter) {
case 'filter':
// temporary solution
// this is the only thing we are supporting at the moment
if (queryFilter.operator === 'eq') {
// right now, the string is replaced instead of being stacked
// the server does not support multiple eq. statements
// as such, we will only process the very last eq. statement provided
filterString = `:${queryFilter.columnName}=${queryFilter.operator}.${queryFilter.criteria}`
}
break
default:
break
}
})
let channel =
this.tableName == '*'
? 'realtime:*'
: `realtime:${this.schema}:${this.tableName}${filterString}`
this.socket = new Socket(socketUrl, { params: { apikey: this.apikey } })
this.channel = this.socket.channel(channel)
this.socket.onOpen(() => {
console.debug(`${this.realtimeUrl}: REALTIME CONNECTED`)
})
this.socket.onClose(() => {
console.debug(`${this.realtimeUrl}: REALTIME DISCONNECTED`)
})
}
on(eventType, callbackFunction) {
if (this.socket == null) this.createListener()
this.channel.on(eventType, (payload) => {
let payloadEnriched = {
schema: payload.schema,
table: payload.table,
commit_timestamp: payload.commit_timestamp,
}
let newData = {}
let oldData = {}
let oldDataEnriched = {}
switch (payload.type) {
case 'INSERT':
newData = ChangeMapper.convertChangeData(payload.columns, payload.record)
payloadEnriched.eventType = 'INSERT'
payloadEnriched.new = newData
break
case 'UPDATE':
oldData = ChangeMapper.convertChangeData(payload.columns, payload.old_record)
newData = ChangeMapper.convertChangeData(payload.columns, payload.record)
Object.keys(oldData).forEach((key) => {
if (oldData[key] != null) oldDataEnriched[key] = oldData[key]
})
payloadEnriched.eventType = 'UPDATE'
payloadEnriched.old = oldDataEnriched
payloadEnriched.new = newData
break
case 'DELETE':
oldData = ChangeMapper.convertChangeData(payload.columns, payload.old_record)
Object.keys(oldData).forEach((key) => {
if (oldData[key] != null) oldDataEnriched[key] = oldData[key]
})
payloadEnriched.eventType = 'DELETE'
payloadEnriched.old = oldDataEnriched
break
default:
break
}
callbackFunction(payloadEnriched)
})
this.listeners[eventType] = callbackFunction
return this
}
subscribe() {
if (this.socket == null) this.createListener()
this.socket.connect()
if (this.channel.state !== 'joined') {
this.channel
.join()
.receive('ok', (resp) =>
console.debug(`${this.realtimeUrl}: Joined Realtime successfully `, resp)
)
.receive('error', (resp) => console.debug(`${this.realtimeUrl}: Unable to join `, resp))
.receive('timeout', () =>
console.debug(`${this.realtimeUrl}: Network timeout. Still waiting...`)
)
}
return this
}
unsubscribe() {
if (this.socket) this.socket.disconnect()
return this
}
}
export default Realtime
@@ -1,246 +0,0 @@
import { uuid } from './utils/Helpers'
import Realtime from './Realtime'
import { Auth } from './Auth'
import { PostgrestClient } from '@supabase/postgrest-js'
const DEPRICATED_KEY_LENGTH = 45
class SupabaseClient {
constructor(supabaseUrl, supabaseKey, options = { autoRefreshToken: true }) {
this.supabaseUrl = null
this.supabaseKey = null
this.restUrl = null
this.realtimeUrl = null
this.authUrl = null
this.schema = 'public'
this.subscriptions = {}
this.accessToken = null
this.tableName = null
this.queryFilters = []
if (options.schema) this.schema = options.schema
this.authenticate(supabaseUrl, supabaseKey)
this.auth = new Auth(this.authUrl, supabaseKey, { autoRefreshToken: options.autoRefreshToken })
}
/**
* General Functionalities
*/
authenticate(supabaseUrl, supabaseKey) {
this.supabaseUrl = supabaseUrl
this.supabaseKey = supabaseKey
this.restUrl = `${supabaseUrl}/rest/v1`
this.realtimeUrl = `${supabaseUrl}/realtime/v1`.replace('http', 'ws')
this.authUrl = `${supabaseUrl}/auth/v1`
}
clear() {
this.tableName = null
this.queryFilters = []
}
from(tableName) {
this.tableName = tableName
return this
}
/**
* Realtime Functionalities
*/
on(eventType, callbackFunction) {
let identifier = uuid()
this.subscriptions[identifier] = new Realtime(
this.tableName,
this.realtimeUrl,
this.schema,
this.supabaseKey,
identifier,
eventType,
callbackFunction,
this.queryFilters
)
this.clear()
return this.subscriptions[identifier]
}
getSubscriptions() {
return Object.values(this.subscriptions)
}
removeSubscription(mySubscription) {
mySubscription.unsubscribe()
delete this.subscriptions[mySubscription.uuid]
}
/**
* REST Functionalities
*/
rpc(functionName, functionParameters = null) {
let rest = new PostgrestClient(this.restUrl, {
headers: { apikey: this.supabaseKey },
schema: this.schema,
})
return rest.rpc(functionName, functionParameters)
}
initClient() {
let headers = { apikey: this.supabaseKey }
if (this.supabaseKey.length > DEPRICATED_KEY_LENGTH && this.accessToken)
headers['Authorization'] = `Bearer ${this.accessToken}`
// if (this.supabaseKey.length > DEPRICATED_KEY_LENGTH && this.auth.authHeader())
// headers['Authorization'] = this.auth.authHeader()
let rest = new PostgrestClient(this.restUrl, {
headers,
schema: this.schema,
})
let api = rest.from(this.tableName)
// go through queryFilters
this.queryFilters.forEach((queryFilter) => {
switch (queryFilter.filter) {
case 'filter':
api.filter(queryFilter.columnName, queryFilter.operator, queryFilter.criteria)
break
case 'match':
api.match(queryFilter.query)
break
case 'order':
api.order(queryFilter.property, queryFilter.ascending, queryFilter.nullsFirst)
break
case 'range':
api.range(queryFilter.from, queryFilter.to)
break
case 'single':
api.single()
break
default:
break
}
})
this.clear()
return api
}
select(columnQuery = '*', options = {}) {
let api = this.initClient()
return api.select(columnQuery, options)
}
insert(data, options = {}) {
let api = this.initClient()
return api.insert(data, options)
}
update(data, options = {}) {
let api = this.initClient()
return api.update(data, options)
}
delete(options = {}) {
let api = this.initClient()
return api.delete(options)
}
filter(columnName, operator, criteria) {
this.queryFilters.push({
filter: 'filter',
columnName,
operator,
criteria,
})
return this
}
match(query) {
this.queryFilters.push({
filter: 'match',
query,
})
return this
}
order(property, ascending = false, nullsFirst = false) {
this.queryFilters.push({
filter: 'order',
property,
ascending,
nullsFirst,
})
return this
}
range(from, to) {
this.queryFilters.push({
filter: 'range',
from,
to,
})
return this
}
single() {
this.queryFilters.push({ filter: 'single' })
return this
}
setAccessToken(value) {
this.accessToken = value
}
}
// pre-empts if any of the filters are used before select
const advancedFilters = [
'eq',
'neq',
'gt',
'lt',
'gte',
'lte',
'like',
'ilike',
'is',
'in',
'cs',
'cd',
'ova',
'ovr',
'sl',
'sr',
'nxr',
'nxl',
'adj',
]
advancedFilters.forEach(
(operator) =>
(SupabaseClient.prototype[operator] = function filterValue(columnName, criteria) {
return this.filter(columnName, operator, criteria)
})
)
const createClient = (supabaseUrl, supabaseKey, options = {}) => {
return new SupabaseClient(supabaseUrl, supabaseKey, options)
}
export { createClient }
@@ -1,189 +0,0 @@
// # Lifted from epgsql (src/epgsql_binary.erl), this module licensed under
// # 3-clause BSD found here: https://raw.githubusercontent.com/epgsql/epgsql/devel/LICENSE
/**
* Takes an array of columns and an object of string values then converts each string value
* to its mapped type
* @param {{name: String, type: String}[]} columns
* @param {Object} records
* @param {Object} options The map of various options that can be applied to the mapper
* @param {Array} options.skipTypes The array of types that should not be converted
*
* @example convertChangeData([{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], {first_name: 'Paul', age:'33'}, {})
* //=>{ first_name: 'Paul', age: 33 }
*/
export const convertChangeData = (columns, records, options = {}) => {
let result = {}
let skipTypes = typeof options.skipTypes !== 'undefined' ? options.skipTypes : []
Object.entries(records).map(([key, value]) => {
result[key] = convertColumn(key, columns, records, skipTypes)
})
return result
}
/**
* Converts the value of an individual column
* @param {String} columnName The column that you want to convert
* @param {{name: String, type: String}[]} columns All of the columns
* @param {Object} records The map of string values
* @param {Array} skipTypes An array of types that should not be converted
* @return {object} Useless information
*
* @example convertColumn('age', [{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], ['Paul', '33'], [])
* //=> 33
* @example convertColumn('age', [{name: 'first_name', type: 'text'}, {name: 'age', type: 'int4'}], ['Paul', '33'], ['int4'])
* //=> "33"
*/
export const convertColumn = (columnName, columns, records, skipTypes) => {
let column = columns.find(x => x.name == columnName)
if(skipTypes.includes(column.type)) return noop(records[columnName])
else return convertCell(column.type, records[columnName])
}
/**
* If the value of the cell is `null`, returns null.
* Otherwise converts the string value to the correct type.
* @param {String} type A postgres column type
* @param {String} stringValue The cell value
*
* @example convertCell('bool', 'true')
* //=> true
* @example convertCell('int8', '10')
* //=> 10
* @example convertCell('_int4', '{1,2,3,4}')
* //=> [1,2,3,4]
*/
export const convertCell = (type, stringValue) => {
try {
if (stringValue === null) return null
// if data type is an array
if (type.charAt(0) === '_') {
let arrayValue = type.slice(1, type.length)
return toArray(stringValue, arrayValue)
}
// If not null, convert to correct type.
switch (type) {
case 'abstime':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'bool':
return toBoolean(stringValue)
case 'date':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'daterange':
return toDateRange(stringValue)
case 'float4':
return toFloat(stringValue)
case 'float8':
return toFloat(stringValue)
case 'int2':
return toInt(stringValue)
case 'int4':
return toInt(stringValue)
case 'int4range':
return toIntRange(stringValue)
case 'int8':
return toInt(stringValue)
case 'int8range':
return toIntRange(stringValue)
case 'json':
return toJson(stringValue)
case 'jsonb':
return toJson(stringValue)
case 'money':
return toFloat(stringValue)
case 'numeric':
return toFloat(stringValue)
case 'oid':
return toInt(stringValue)
case 'reltime':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'time':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'timestamp':
return toTimestampString(stringValue) // Format to be consistent with PostgREST
case 'timestamptz':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'timetz':
return noop(stringValue) // To allow users to cast it based on Timezone
case 'tsrange':
return toDateRange(stringValue)
case 'tstzrange':
return toDateRange(stringValue)
default:
// All the rest will be returned as strings
return noop(stringValue)
}
} catch (error) {
console.log(`Could not convert cell of type ${type} and value ${stringValue}`)
console.log(`This is the error: ${error}`)
return stringValue
}
}
export const noop = stringValue => {
return stringValue
}
export const toBoolean = stringValue => {
switch (stringValue) {
case 't':
return true
case 'f':
return false
default:
return null
}
}
export const toDate = stringValue => {
return new Date(stringValue)
}
export const toDateRange = stringValue => {
let arr = JSON.parse(stringValue)
return [new Date(arr[0]), new Date(arr[1])]
}
export const toFloat = stringValue => {
return parseFloat(stringValue)
}
export const toInt = stringValue => {
return parseInt(stringValue)
}
export const toIntRange = stringValue => {
let arr = JSON.parse(stringValue)
return [parseInt(arr[0]), parseInt(arr[1])]
}
export const toJson = stringValue => {
return JSON.parse(stringValue)
}
/**
* Converts a Postgres Array into a native JS array
* @example toArray('{1,2,3,4}', 'int4')
* //=> [1,2,3,4]
* @example toArray('{}', 'int4')
* //=> []
*/
export const toArray = (stringValue, type) => {
// this takes off the '{' & '}'
let stringEnriched = stringValue.slice(1, stringValue.length - 1)
// converts the string into an array
// if string is empty (meaning the array was empty), an empty array will be immediately returned
let stringArray = stringEnriched.length > 0 ? stringEnriched.split(',') : []
let array = stringArray.map(string => {
return convertCell(type, string)
})
return array
}
/**
* Fixes timestamp to be ISO-8601. Swaps the space between the date and time for a 'T'
* See https://github.com/supabase/supabase/issues/18
* @returns {string}
* @example toTimestampString('2019-09-10 00:00:00')
* //=> '2019-09-10T00:00:00'
*/
export const toTimestampString = stringValue => {
return stringValue.replace(' ', 'T')
}
@@ -1,8 +0,0 @@
export function uuid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1)
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4()
}
@@ -1,14 +0,0 @@
function RoundToFixDecimals(value, numDecimals = 5) {
function addZero(s, size) {
while (s.length <= (size || 2)) { s = s + "0"; }
return s;
}
const temp = addZero("1", numDecimals)
const tempNum = parseInt(temp, 10)
return Math.round((value + Number.EPSILON) * tempNum) / tempNum
}
export {
RoundToFixDecimals
}
File diff suppressed because it is too large. Load diff
@@ -1,24 +0,0 @@
{
"name": "supabase-realtime-map-v2",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@supabase/doctest-js": "^0.1.0",
"@supabase/postgrest-js": "^0.17.0",
"@supabase/realtime-js": "^0.9.0",
"dotenv": "^8.2.0",
"leaflet": "^1.6.0",
"micro-api-client": "^3.3.0",
"nanoid": "^3.1.12",
"next": "9.5.2",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-leaflet": "^3.0.0-beta.4",
"superagent": "^6.0.0"
}
}
@@ -1,60 +0,0 @@
import '../styles/globals.css'
import React, { useState, useEffect } from 'react'
import UserContext from 'lib/UserContext'
import { auth, supabase } from 'lib/Store'
function MyApp({ Component, pageProps }) {
const [user, setUser] = useState(null)
useEffect(() => {
async function loadUser() {
try {
const authUser = auth.currentUser()
if (!authUser) return
const { data: user, error } = await supabase
.from('users')
.match({ id: authUser.id })
.select('*')
.single()
if (error) {
throw new Error(error)
}
setUser(user)
} catch (error) {
console.log(error)
}
}
loadUser()
}, [])
function onSignOut() {
try {
const user = auth.currentUser()
user
.signOut()
.then((response) => {
console.log('User logged out')
window.location.reload()
})
.catch((error) => {
console.log('Failed to logout user: %o', error)
})
} catch (error) {
console.log(error)
}
}
return (
<UserContext.Provider
value={{
user: user,
signOut: onSignOut,
}}
>
<Component {...pageProps} />
</UserContext.Provider>
)
}
export default MyApp
@@ -1,14 +0,0 @@
import { auth, supabase } from "lib/Store"
export default async function (req, res) {
try {
const { email, password } = req.body
const authBody = await auth.login(email, password)
return res.status(200).json({ refresh_token: authBody.token.refresh_token })
} catch (error) {
console.error('error', error)
res.status(error.status || 500).end(error.message)
}
}
@@ -1,17 +0,0 @@
import { supabase, auth } from "lib/Store"
export default async function (req, res) {
try {
const { email, password, role } = req.body
const authBody = await auth.signUp({email, password})
supabase.setAccessToken(authBody.data.access_token)
const { data: user } = await supabase.from('users').insert([{ id: authBody.user.id, username: email, role }]).single()
return res.status(200).json({ ...user, refresh_token: authBody.refresh_token })
} catch (error) {
console.error('error', error)
res.status(error.status || 500).end(error.message)
}
}
@@ -1,99 +0,0 @@
import { useContext } from 'react'
import Head from 'next/head'
import dynamic from 'next/dynamic'
import styles from 'styles/Map.module.css'
import { supabase } from 'lib/Store'
import SignIn from 'components/SignIn'
import UserContext from 'lib/UserContext'
const MapInput = dynamic(
() => import('components/MapInput'),
{ ssr: false }
)
function randomPosition() {
var array = [
{
lat: 1.3489728457596013,
lng: 103.77043978311998
},
{
lat: 1.37462,
lng: 103.77694
},
{
lat: 1.41006,
lng: 103.77144
},
{
lat: 1.34313,
lng: 103.84191
},
{
lat: 1.35454,
lng: 103.69746
}
];
return array[Math.floor(Math.random() * array.length)];
}
export default function Page() {
const { user, signOut } = useContext(UserContext)
const center = randomPosition()
const zoomLevel = 14
return (
<div className={styles.container}>
<Head>
<title>Driver View</title>
<link rel="icon" href="/favicon.ico" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossOrigin=""
/>
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
crossOrigin=""></script>
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Driver View
</h1>
<p className={styles.description}>
Get started by drag-drop the marker around to simulate location update
</p>
<div className={styles.grid}>
{!user && <SignIn />}
{user && (
<div className={styles.card}>
{
user.role === "DRIVER"
? <MapInput supabase={supabase} clientRef={user?.id} center={center} zoom={zoomLevel} />
: <p className={styles.error}>Sorry, You need to sign in as driver</p>
}
<div className={styles.profile_container}>
Signed in as {user.username} [{user.role}]<br />
<button className={styles.sign_out} onClick={signOut}>Sign out</button>
</div>
</div>
)}
</div>
</main>
<footer className="footer">
<a
href="https://supabase.io"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/supabase.svg" alt="Supabase Logo" className="logo" />
</a>
</footer>
</div >
)
}
@@ -1,54 +0,0 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Head from 'next/head'
import styles from 'styles/Home.module.css'
export default function Page() {
return (
<div className={styles.container}>
<Head>
<title>Live Tracker</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Live Tracker
</h1>
<p className={styles.description}>
Get started by choosing your role as Driver or Manager.
</p>
<p className={styles.sub_description}>
You can only login 1 role at a time. Please use incognito window to test multi users.
</p>
<div className={styles.grid}>
<Link href="/driver">
<a className={styles.card}>
<h3>Driver View &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
</Link>
<Link href="/manager">
<a className={styles.card}>
<h3>Manager View &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
</Link>
</div>
</main>
<footer className="footer">
<a
href="https://supabase.io"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/supabase.svg" alt="Supabase Logo" className="logo" />
</a>
</footer>
</div>
)
}
@@ -1,77 +0,0 @@
import React, { useContext } from 'react'
import Head from 'next/head'
import styles from 'styles/Map.module.css'
import dynamic from 'next/dynamic'
import { supabase } from 'lib/Store'
import SignIn from 'components/SignIn'
import UserContext from 'lib/UserContext'
const MapView = dynamic(
() => import('components/MapView'),
{ ssr: false }
)
export default function Page() {
const { user, signOut } = useContext(UserContext)
const center = {
lat: 1.3489728457596013,
lng: 103.77043978311998
}
const zoomLevel = 12
return (
<div className={styles.container}>
<Head>
<title>Manager View</title>
<link rel="icon" href="/favicon.ico" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossOrigin=""
/>
<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
crossOrigin=""></script>
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Manager View
</h1>
<p className={styles.description}>
As manager, you can view all drivers' locations as long as the drivers are ONLINE
</p>
<div className={styles.grid}>
{!user && <SignIn role="MANAGER" />}
{user && (
<div className={styles.card}>
{
user.role === "MANAGER"
? <MapView supabase={supabase} center={center} zoom={zoomLevel} />
: <p className={styles.error}>Sorry, you need to sign in as manager</p>
}
<div className={styles.profile_container}>
Signed in as {user.username} [{user.role}]<br />
<button className={styles.sign_out} onClick={signOut}>Sign out</button>
</div>
</div>
)}
</div>
</main>
<footer className="footer">
<a
href="https://supabase.io"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/supabase.svg" alt="Supabase Logo" className="logo" />
</a>
</footer>
</div>
)
}
@@ -1,62 +1,77 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { MapContainer, TileLayer, Marker, Popup, Circle } from 'react-leaflet'
import { RoundToFixDecimals } from "lib/Utils"
import TextLog from "components/TextLog"
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { MapContainer, TileLayer, Marker, Popup, Circle } from "react-leaflet";
import { RoundToFixDecimals } from "lib/utils";
import { supabase } from "lib/api";
import TextLog from "components/TextLog";
function DraggableMarker({ initialPos, addCircle }) {
const [position, setPosition] = useState(initialPos)
const markerRef = useRef(null)
const [position, setPosition] = useState(initialPos);
const markerRef = useRef(null);
const eventHandlers = useMemo(
() => ({
dragend() {
const marker = markerRef.current
const pos = marker.getLatLng()
if (addCircle) addCircle(pos)
if (marker) setPosition(pos)
const marker = markerRef.current;
const pos = marker.getLatLng();
if (addCircle) addCircle(pos);
if (marker) setPosition(pos);
},
}),
[addCircle],
)
[addCircle]
);
return (
<Marker
draggable={true}
eventHandlers={eventHandlers}
position={position}
ref={markerRef}>
ref={markerRef}
>
<Popup minWidth={90}>
{`lat: ${position.lat}`}<br />{`long: ${position.lng}`}
{`lat: ${position.lat}`}
<br />
{`long: ${position.lng}`}
</Popup>
</Marker>
)
);
}
function MapInput({ supabase, clientRef, center, zoom }) {
const [log, setLog] = useState(undefined)
const [circles, setCircles] = useState([])
function MapInput({ clientRef, center, zoom }) {
const [log, setLog] = useState(undefined);
const [circles, setCircles] = useState([]);
useEffect(() => {
let newLog = `Ref: ${clientRef}\nReady to send location...`
newLog += circles.map(item => { return `\nsent lat=${RoundToFixDecimals(item.lat)} long=${RoundToFixDecimals(item.lng)}` })
setLog(newLog)
}, [circles, clientRef])
let newLog = `Ref: ${clientRef}\nReady to send location...`;
newLog += circles.map((item) => {
return `\nsent lat=${RoundToFixDecimals(
item.lat
)} long=${RoundToFixDecimals(item.lng)}`;
});
setLog(newLog);
}, [circles, clientRef]);
const onAddCircle = useCallback(
async (pos) => {
setCircles([...circles, pos])
setCircles([...circles, pos]);
// insert new location
await supabase
.from('locations')
.from("locations")
.insert([
{ latitude: pos.lat, longitude: pos.lng, user_id: clientRef },
])
]);
},
[setCircles, circles, supabase, clientRef]
);
function renderCircles() {
return circles.map((item, index) => <Circle key={`${index}`} center={item} pathOptions={{ fillColor: 'blue' }} radius={20} />)
return circles.map((item, index) => (
<Circle
key={`${index}`}
center={item}
pathOptions={{ fillColor: "blue" }}
radius={20}
/>
));
}
return (
@@ -81,6 +96,6 @@ function MapInput({ supabase, clientRef, center, zoom }) {
}
`}</style>
</div>
)
);
}
export default MapInput
export default MapInput;
@@ -0,0 +1,91 @@
import { useEffect, useReducer } from "react";
import { MapContainer, TileLayer, Polyline } from "react-leaflet";
import { RoundToFixDecimals } from "lib/utils";
import { supabase } from "lib/api";
import TextLog from "components/TextLog";
export default function MapView({ center, zoom }) {
const [state, dispatch] = useReducer(mapReducer, initialState);
useEffect(() => {
// Listen to INSERT event on locations table
const subscription = supabase
.from("locations")
.on("INSERT", (payload) => {
console.log("New Position received!", payload);
dispatch({ payload: payload.new });
})
.subscribe();
return () => {
if (subscription) supabase.removeSubscription(subscription);
};
}, []);
function drawPolylines() {
let result = Object.keys(state.userPositions).map((key, idx) => {
const item = state.userPositions[key];
if (!item) return;
const { color, data } = item;
const polyline = data.map((item) => {
if (!item) return;
const { lat, lng } = item;
return [lat, lng];
});
return (
<Polyline
key={`line-${idx}`}
pathOptions={{ color: color }}
positions={polyline}
/>
);
});
return result;
}
return (
<div className="map-view">
<MapContainer center={center} zoom={zoom || 15} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{drawPolylines()}
</MapContainer>
<TextLog log={state.log} />
</div>
);
}
function randomColor() {
var array = ["black", "magenta", "blue", "indigo", "green", "blueviolet"];
return array[Math.floor(Math.random() * array.length)];
}
const initialState = {
log: "Start listerning...",
userPositions: {},
};
const mapReducer = (state, action) => {
const { id, user_id, latitude: lat, longitude: lng } = action.payload;
const position = { id, user_id, lat, lng };
let positions = state.userPositions[user_id];
if (positions) positions.data = [...positions.data, position];
else positions = { color: randomColor(), data: [position] };
const userPositions = { ...state.userPositions, [user_id]: positions };
let log = state.log;
log += `\nuser_id=${user_id}`;
log += ` lat=${RoundToFixDecimals(lat)}`;
log += ` long=${RoundToFixDecimals(lng)}`;
return {
log,
userPositions,
};
};
@@ -0,0 +1,19 @@
import Head from "next/head";
export default function PageLayout({ title, children }) {
return (
<div className="container">
<Head>
<title>{title}</title>
</Head>
<main className="main">{children}</main>
<footer className="footer">
<a href="https://supabase.io" target="_blank" rel="noopener noreferrer">
Powered by{" "}
<img src="/supabase.svg" alt="Supabase Logo" className="logo" />
</a>
</footer>
</div>
);
}
@@ -0,0 +1,97 @@
import React, { useState } from "react";
import { supabase } from "lib/api";
/**
* Sign in with username and password
*
* @param {String} role DRIVER/MANAGER. default as DRIVER
*/
export default function SignIn({ role = "DRIVER" }) {
const [formData, updateFormData] = useState({ role });
const [action, setAction] = useState(null);
function onChange(e) {
updateFormData({
...formData,
// Trimming any whitespace
[e.target.name]: e.target.value.trim(),
});
}
async function updateUserRole(user) {
if (!user || user.user_metadata?.role) return;
// update user role
await supabase.auth.update({
data: { role },
});
}
async function onSubmit(event) {
event.preventDefault();
try {
if (action === "SIGNUP") {
const { email, password, role } = formData;
const { error, user } = await supabase.auth.signUp({ email, password });
if (error) throw error;
// update user role
await supabase.auth.update({
data: { role },
});
} else if (action === "LOGIN") {
const { error } = await supabase.auth.signIn(formData);
if (error) throw error;
}
} catch (error) {
alert(error.message);
}
}
return (
<form className="form-container" onSubmit={onSubmit}>
<label>
Email
<input name="email" type="email" onChange={onChange} />
</label>
<label>
Password
<input name="password" type="password" onChange={onChange} />
</label>
<button type="submit" onClick={() => setAction("SIGNUP")}>
Sign up
</button>
<button type="submit" onClick={() => setAction("LOGIN")}>
Login
</button>
<style jsx>{`
.form-container {
min-width: 20rem;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
}
label {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
input {
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 1rem;
}
button {
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 1rem;
}
`}</style>
</form>
);
}
@@ -0,0 +1,10 @@
import { createClient } from "@supabase/supabase-js";
import {
NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_KEY,
} from "./constants";
export const supabase = createClient(
NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_KEY
);
@@ -0,0 +1,8 @@
import { createContext } from "react";
export const NEXT_PUBLIC_SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
export const NEXT_PUBLIC_SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_KEY;
export const DEFAULT_AVATARS_BUCKET = "avatars";
export const AppContext = createContext();
@@ -0,0 +1,12 @@
export function RoundToFixDecimals(value, numDecimals = 5) {
function addZero(s, size) {
while (s.length <= (size || 2)) {
s = s + "0";
}
return s;
}
const temp = addZero("1", numDecimals);
const tempNum = parseInt(temp, 10);
return Math.round((value + Number.EPSILON) * tempNum) / tempNum;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,19 @@
{
"name": "supabase-realtime-map-v2",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@supabase/supabase-js": "^1.6.0",
"leaflet": "^1.7.1",
"nanoid": "^3.1.12",
"next": "^10.0.9",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-leaflet": "^3.1.0"
}
}
@@ -0,0 +1,25 @@
import "../styles/globals.css";
import React, { useState, useEffect } from "react";
import { supabase } from "lib/api";
import { AppContext } from "lib/constants";
function MyApp({ Component, pageProps }) {
const [session, setSession] = useState(null);
useEffect(() => {
setSession(supabase.auth.session());
supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
console.log("session state change", session);
});
}, []);
return (
<AppContext.Provider value={{ session: session }}>
<Component {...pageProps} />
</AppContext.Provider>
);
}
export default MyApp;
@@ -0,0 +1,30 @@
import Document, { Html, Head, Main, NextScript } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link rel="icon" href="/favicon.ico" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossOrigin=""
/>
<script
src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
crossOrigin=""
></script>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
@@ -0,0 +1,89 @@
import { useContext, useEffect, useState } from "react";
import { AppContext } from "lib/constants";
import { supabase } from "lib/api";
import dynamic from "next/dynamic";
import styles from "styles/Map.module.css";
import SignIn from "components/SignIn";
import PageLayout from "components/PageLayout";
const MapInput = dynamic(() => import("components/MapInput"), { ssr: false });
function randomPosition() {
var array = [
{
lat: 1.3489728457596013,
lng: 103.77043978311998,
},
{
lat: 1.37462,
lng: 103.77694,
},
{
lat: 1.41006,
lng: 103.77144,
},
{
lat: 1.34313,
lng: 103.84191,
},
{
lat: 1.35454,
lng: 103.69746,
},
];
return array[Math.floor(Math.random() * array.length)];
}
const center = randomPosition();
const zoomLevel = 14;
export default function Page() {
const { session } = useContext(AppContext);
const [userRole, setRole] = useState(null);
const [username, setUsername] = useState(null);
useEffect(() => {
setRole(session?.user?.user_metadata?.role);
setUsername(session?.user?.user_metadata?.username);
}, [session]);
async function onSignOut() {
const { error } = await supabase.auth.signOut();
if (error) console.log("Error logging out:", error.message);
}
return (
<PageLayout title="Driver View">
<h1 className={styles.title}>Driver View</h1>
<p className={styles.description}>
Get started by drag-drop the marker around to simulate location update
</p>
<div className={styles.grid}>
{!session && <SignIn />}
{session && (
<div className={styles.card}>
{userRole?.toUpperCase() == "DRIVER" && (
<MapInput
clientRef={session.user?.id}
center={center}
zoom={zoomLevel}
/>
)}
{userRole?.toUpperCase() == "MANAGER" && (
<p className={styles.error}>
Sorry, You need to sign in as driver
</p>
)}
<div className={styles.profile_container}>
Signed in as {username} [{userRole}]<br />
<button className={styles.sign_out} onClick={onSignOut}>
Sign out
</button>
</div>
</div>
)}
</div>
</PageLayout>
);
}
@@ -0,0 +1,42 @@
import { useContext, useEffect } from "react";
import { AppContext } from "lib/constants";
import Link from "next/link";
import styles from "styles/Home.module.css";
import PageLayout from "components/PageLayout";
export default function Page() {
const { session } = useContext(AppContext);
useEffect(() => {
console.log("session: ", session);
}, []);
return (
<PageLayout title="Live Tracker">
<h1 className={styles.title}>Live Tracker</h1>
<p className={styles.description}>
Get started by choosing your role as Driver or Manager.
</p>
<p className={styles.sub_description}>
You can only login 1 role at a time. Please use incognito mode to test
multi users.
</p>
<div className={styles.grid}>
<Link href="/driver">
<a className={styles.card}>
<h3>Driver View &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
</Link>
<Link href="/manager">
<a className={styles.card}>
<h3>Manager View &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
</Link>
</div>
</PageLayout>
);
}
@@ -0,0 +1,63 @@
import { useContext, useEffect, useState } from "react";
import { AppContext } from "lib/constants";
import { supabase } from "lib/api";
import styles from "styles/Map.module.css";
import dynamic from "next/dynamic";
import SignIn from "components/SignIn";
import PageLayout from "components/PageLayout";
const MapView = dynamic(() => import("components/MapView"), { ssr: false });
const center = {
lat: 1.3489728457596013,
lng: 103.77043978311998,
};
const zoomLevel = 12;
export default function Page() {
const { session } = useContext(AppContext);
const [userRole, setRole] = useState(null);
const [username, setUsername] = useState(null);
useEffect(() => {
setRole(session?.user?.user_metadata?.role);
setUsername(session?.user?.user_metadata?.username);
}, [session]);
async function onSignOut() {
const { error } = await supabase.auth.signOut();
if (error) console.log("Error logging out:", error.message);
}
return (
<PageLayout title="Manager View">
<h1 className={styles.title}>Manager View</h1>
<p className={styles.description}>
As manager, you can view all drivers' locations as long as the drivers
are ONLINE
</p>
<div className={styles.grid}>
{!session && <SignIn role="MANAGER" />}
{session && (
<div className={styles.card}>
{userRole?.toUpperCase() == "MANAGER" && (
<MapView center={center} zoom={zoomLevel} />
)}
{userRole?.toUpperCase() == "DRIVER" && (
<p className={styles.error}>
Sorry, you need to sign in as manager
</p>
)}
<div className={styles.profile_container}>
Signed in as {username} [{userRole}]<br />
<button className={styles.sign_out} onClick={onSignOut}>
Sign out
</button>
</div>
</div>
)}
</div>
</PageLayout>
);
}
@@ -1,21 +1,3 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 3rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.title {
margin: 0;
line-height: 1.15;
@@ -84,4 +66,4 @@
width: 100%;
flex-direction: column;
}
}
}
@@ -1,21 +1,3 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 3rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.title {
margin: 0;
line-height: 1.15;
@@ -74,4 +56,4 @@
.card {
width: 100%;
}
}
}
@@ -2,15 +2,32 @@ html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
* {
box-sizing: border-box;
}
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 3rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 60px;
@@ -41,4 +58,4 @@ body {
.loading-spinner {
height: 2rem;
width: 2rem;
}
}