mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat: new live tracker map example
This commit is contained in:
1 parent
e5bb9bfcff
commit
02092cdcfc
46 files changed
+5124
-12224
No files matched your search
@@ -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='© <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
|
||||
}
|
||||
-10333
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 →</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 →</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>
|
||||
)
|
||||
}
|
||||
+43
-28
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
+4501
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 →</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 →</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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-19
@@ -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
-19
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user