community: add streaming-subscription-chat example
GITHUB_PR_NUMBER: 8588 GITHUB_PR_URL: https://github.com/hasura/graphql-engine/pull/8588 PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4731 Co-authored-by: arjunyel <11153289+arjunyel@users.noreply.github.com> GitOrigin-RevId: 0db7eabcc8a05a2c98f50cd767c38757d534d648
@ -0,0 +1 @@
|
||||
node_modules/
|
21
community/sample-apps/streaming-subscriptions-chat/.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
@ -0,0 +1,15 @@
|
||||
FROM node:16 as builder
|
||||
ENV NODE_ENV=PRODUCTION
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:16-alpine
|
||||
RUN npm -g install serve
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/build .
|
||||
CMD ["serve", "-s", "-p", "3000"]
|
192
community/sample-apps/streaming-subscriptions-chat/README.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Realtime Chat using Streaming Subscriptions
|
||||
|
||||
This is the source code for a fully working group chat app that uses [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) in Hasura GraphQL Engine. It is built using React and Apollo.
|
||||
|
||||
Run this example with Docker: `docker compose up -d --build`
|
||||
|
||||
[![Edit chat-app](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat?fontsize=14)
|
||||
|
||||
- [Fully working app](https://realtime-chat.demo.hasura.io/)
|
||||
- [Backend](https://realtime-chat.demo.hasura.io/console)
|
||||
|
||||
Adapted from the [original blogpost by Rishichandra Wawhal](https://hasura.io/blog/building-a-realtime-chat-app-with-graphql-subscriptions-d68cd33e73f).
|
||||
|
||||
[![Deploy to Hasura Cloud](https://graphql-engine-cdn.hasura.io/img/deploy_to_hasura.png)](https://cloud.hasura.io/signup)
|
||||
|
||||
## TLDR
|
||||
|
||||
- Hasura allows us to build a real-time GraphQL API without writing any backend code.
|
||||
|
||||
- Using [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) we fetch the last 100 messages then stream new messages.
|
||||
|
||||
- Every two seconds our frontend runs a user_online mutation to populate an online users list.
|
||||
|
||||
- A user_typing mutation is run whenever we type a few characters and a subscription is used to fetch the last typing user.
|
||||
|
||||
## Introduction
|
||||
|
||||
Hasura allows us to instantly create a real-time GraphQL API from our data. In this tutorial we walk through creating a group chat application, without needing to write any backend code, using React and Apollo. The focus is on data models that we store in Postgres rather than full chat functionality.
|
||||
|
||||
## Data Modelling
|
||||
|
||||
### Users
|
||||
|
||||
When a user signs up we insert their chosen username and generate their ID. We also track when they last typed and were seen.
|
||||
|
||||
```sql
|
||||
user (
|
||||
id SERIAL PRIMARY KEY
|
||||
username TEXT UNIQUE
|
||||
last_seen timestamp with time zone
|
||||
last_typed timestamp with time zone
|
||||
)
|
||||
```
|
||||
|
||||
### Messages
|
||||
|
||||
For our tutorial we will just be inserting messages, not editing or deleting, but if we wanted to in the future Hasura will autogenerate the mutations for us. We could also extend this by adding features such as multiple different chatrooms.
|
||||
|
||||
```sql
|
||||
message (
|
||||
id SERIAL NOT NULL PRIMARY KEY
|
||||
"text" TEXT NOT NULL
|
||||
username INT FOREIGN KEY REFERENCES user(username) NOT NULL
|
||||
"timestamp" timestamp with time zone DEFAULT now() NOT NULL
|
||||
)
|
||||
```
|
||||
|
||||
### Online users
|
||||
|
||||
To query the users online we create a Postgres view that fetches all users with last_seen less than 10 seconds ago.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW public."user_online" AS
|
||||
SELECT "user".id,
|
||||
"user".username,
|
||||
"user".last_typed,
|
||||
"user".last_seen
|
||||
FROM "user"
|
||||
WHERE ("user".last_seen > (now() - '00:00:10'::interval));
|
||||
```
|
||||
|
||||
### Typing Indicator
|
||||
|
||||
To query the last person typing we create a similar view with last_typed within the past 2 seconds.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW public."user_typing" AS
|
||||
SELECT "user".id,
|
||||
"user".username,
|
||||
"user".last_typed,
|
||||
"user".last_seen
|
||||
FROM "user"
|
||||
WHERE ("user".last_typed > (now() - '00:00:02'::interval));
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Creating a user
|
||||
|
||||
At user signup we insert a single row into the users table
|
||||
|
||||
```gql
|
||||
mutation ($username: String!) {
|
||||
insert_user_one(object: { username: $username }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We take the returned id and username and store it in our app's state management.
|
||||
|
||||
### User online events
|
||||
|
||||
Every two seconds we run a mutation to update our user's last_seen value. For example, with React using UseEffect or componentDidMount we could call the mutation inside a setInterval. [Be mindful when using setInterval inside a React hook.](https://overreacted.io/making-setinterval-declarative-with-react-hooks/)
|
||||
|
||||
```gql
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(pk_columns: { id: $userId }, _set: { last_seen: "now()" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Subscribing to new messages
|
||||
|
||||
Using [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) we fetch the last N messages (the example uses 100). Then new messages are streamed using graphql-ws.
|
||||
|
||||
```gql
|
||||
# We can pass in how far back we want to fetch messages
|
||||
subscription ($last_received_ts: timestamptz) {
|
||||
message_stream(
|
||||
cursor: { initial_value: { timestamp: $last_received_ts } }
|
||||
batch_size: 100
|
||||
) {
|
||||
id
|
||||
username
|
||||
text
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With a bit of magic from streaming subscriptions we saved our users a ton of data by only fetching new messages.
|
||||
|
||||
### Sending messages
|
||||
|
||||
A user sends a message by inserting a row into the messages table. In the future if we setup [JWT based authentication in the future](https://hasura.io/docs/latest/graphql/core/auth/authentication/jwt.html) we can automatically [set the username from the JWT custom claims](https://hasura.io/docs/latest/graphql/core/auth/authorization/roles-variables.html#dynamic-session-variables).
|
||||
|
||||
```gql
|
||||
mutation insert_message($message: message_insert_input!) {
|
||||
insert_message_one(object: $message) {
|
||||
id
|
||||
timestamp
|
||||
text
|
||||
username
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UI Typing Indicator
|
||||
|
||||
1. Similar to the user online events, when a user types a few characters we run a mutation on their last_typing timestamp.
|
||||
|
||||
```gql
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(
|
||||
pk_columns: { id: $userId }
|
||||
_set: { last_typed: "now()" }
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. Then we subscribe to our Postgres view of the last user typing, making sure to exclude ourselves.
|
||||
|
||||
```gql
|
||||
subscription ($selfId: Int) {
|
||||
user_typing(
|
||||
where: { id: { _neq: $selfId } }
|
||||
limit: 1
|
||||
order_by: { last_typed: desc }
|
||||
) {
|
||||
last_typed
|
||||
username
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Online Users list
|
||||
|
||||
We can easily get a list of all users online by subscribing to the user_online view we created.
|
||||
|
||||
```gql
|
||||
subscription {
|
||||
user_online(order_by: { username: asc }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
```
|
@ -0,0 +1,37 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
restart: always
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
graphql-engine:
|
||||
image: hasura/graphql-engine:v2.7.0.cli-migrations-v3
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- "postgres"
|
||||
restart: always
|
||||
environment:
|
||||
## postgres database to store Hasura metadata
|
||||
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
|
||||
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
|
||||
## enable debugging mode. It is recommended to disable this in production
|
||||
HASURA_GRAPHQL_DEV_MODE: "true"
|
||||
HASURA_GRAPHQL_EXPERIMENTAL_FEATURES: "streaming_subscriptions"
|
||||
volumes:
|
||||
- ./hasura/metadata:/hasura-metadata
|
||||
- ./hasura/migrations:/hasura-migrations
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: ./Dockerfile
|
||||
context: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- "graphql"
|
||||
volumes:
|
||||
db_data:
|
@ -0,0 +1,19 @@
|
||||
version: 3
|
||||
endpoint: http://localhost:8080/
|
||||
api_paths:
|
||||
v1_query: v1/query
|
||||
v2_query: v2/query
|
||||
v1_metadata: v1/metadata
|
||||
graphql: v1/graphql
|
||||
config: v1alpha1/config
|
||||
pg_dump: v1alpha1/pg_dump
|
||||
version: v1/version
|
||||
metadata_directory: metadata
|
||||
migrations_directory: migrations
|
||||
seeds_directory: seeds
|
||||
actions:
|
||||
kind: synchronous
|
||||
handler_webhook_baseurl: http://localhost:3000
|
||||
codegen:
|
||||
framework: ""
|
||||
output_dir: ""
|
@ -0,0 +1,2 @@
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
@ -0,0 +1 @@
|
||||
[]
|
@ -0,0 +1 @@
|
||||
[]
|
@ -0,0 +1,16 @@
|
||||
- name: default
|
||||
kind: postgres
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: PG_DATABASE_URL
|
||||
pool_settings:
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
retries: 1
|
||||
tables:
|
||||
- "!include default/tables/public_message.yaml"
|
||||
- "!include default/tables/public_user.yaml"
|
||||
- "!include default/tables/public_user_online.yaml"
|
||||
- "!include default/tables/public_user_typing.yaml"
|
||||
functions: []
|
@ -0,0 +1,3 @@
|
||||
table:
|
||||
name: message
|
||||
schema: public
|
@ -0,0 +1,3 @@
|
||||
table:
|
||||
name: user
|
||||
schema: public
|
@ -0,0 +1,3 @@
|
||||
table:
|
||||
name: user_online
|
||||
schema: public
|
@ -0,0 +1,3 @@
|
||||
table:
|
||||
name: user_typing
|
||||
schema: public
|
@ -0,0 +1 @@
|
||||
[]
|
@ -0,0 +1 @@
|
||||
[]
|
@ -0,0 +1 @@
|
||||
version: 3
|
@ -0,0 +1,53 @@
|
||||
CREATE TABLE public.message (
|
||||
id integer NOT NULL,
|
||||
username text NOT NULL,
|
||||
text text NOT NULL,
|
||||
"timestamp" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE SEQUENCE public.message_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.message_id_seq OWNED BY public.message.id;
|
||||
CREATE TABLE public."user" (
|
||||
id integer NOT NULL,
|
||||
username text NOT NULL,
|
||||
last_typed timestamp with time zone,
|
||||
last_seen timestamp with time zone
|
||||
);
|
||||
COMMENT ON TABLE public."user" IS 'This table stores user data';
|
||||
CREATE SEQUENCE public.user_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id;
|
||||
CREATE VIEW public.user_online AS
|
||||
SELECT "user".id,
|
||||
"user".username,
|
||||
"user".last_typed,
|
||||
"user".last_seen
|
||||
FROM public."user"
|
||||
WHERE ("user".last_seen > (now() - '00:00:10'::interval));
|
||||
CREATE VIEW public.user_typing AS
|
||||
SELECT "user".id,
|
||||
"user".username,
|
||||
"user".last_typed,
|
||||
"user".last_seen
|
||||
FROM public."user"
|
||||
WHERE ("user".last_typed > (now() - '00:00:02'::interval));
|
||||
ALTER TABLE ONLY public.message ALTER COLUMN id SET DEFAULT nextval('public.message_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);
|
||||
ALTER TABLE ONLY public.message
|
||||
ADD CONSTRAINT message_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public."user"
|
||||
ADD CONSTRAINT user_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public."user"
|
||||
ADD CONSTRAINT user_username_key UNIQUE (username);
|
||||
ALTER TABLE ONLY public.message
|
||||
ADD CONSTRAINT message_username_fkey FOREIGN KEY (username) REFERENCES public."user"(username);
|
26931
community/sample-apps/streaming-subscriptions-chat/package-lock.json
generated
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "streaming-subscription-chat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.8",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"moment": "^2.29.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"graphql-ws": "^5.9.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-graphql": "^4.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
|
||||
<link href="https://afeld.github.io/emoji-css/emoji.css" rel="stylesheet">
|
||||
<link href="https://use.fontawesome.com/releases/v5.0.7/css/all.css" rel="stylesheet" crossorigin="anonymous">
|
||||
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
|
||||
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Realtime group chat | Powered by Hasura</title>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-129818961-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-129818961-1');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Realtime Group Chat",
|
||||
"name": "Realtime Group Chat Powered by Hasura",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
666
community/sample-apps/streaming-subscriptions-chat/src/App.css
Normal file
@ -0,0 +1,666 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Raleway:400,600');
|
||||
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400');
|
||||
|
||||
body {
|
||||
font-family: 'Open Sans';
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.noPadd
|
||||
{
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.removePaddLeft {
|
||||
padding-left: 0;
|
||||
}
|
||||
.addPaddTop
|
||||
{
|
||||
padding-top: 10px;
|
||||
clear: both;
|
||||
}
|
||||
/* Landing section */
|
||||
.wd10 {
|
||||
width: 10%;
|
||||
display: inline-block;
|
||||
}
|
||||
.wd90 {
|
||||
width: 90%;
|
||||
display: inline-block;
|
||||
}
|
||||
.removePaddBottom {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.gradientBgColor {
|
||||
background-color: #a0b4cc;
|
||||
/* Safari 4-5, Chrome 1-9 */
|
||||
background: -webkit-gradient(
|
||||
linear,
|
||||
0% 0%,
|
||||
0% 100%,
|
||||
from(#a0b4cc),
|
||||
to(#c2a899)
|
||||
);
|
||||
/* Safari 5.1, Chrome 10+ */
|
||||
background: -webkit-linear-gradient(top, #a0b4cc, #c2a899);
|
||||
/* Firefox 3.6+ */
|
||||
background: -moz-linear-gradient(top, #a0b4cc, #c2a899);
|
||||
/* IE 10 */
|
||||
background: -ms-linear-gradient(top, #a0b4cc, #c2a899);
|
||||
/* Opera 11.10+ */
|
||||
background: -o-linear-gradient(top, #a0b4cc, #c2a899);
|
||||
}
|
||||
.bgImage
|
||||
{
|
||||
position: fixed;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-image: url('./images/chat-app-bg.jpg');
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-position: 0 0;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.bgImage::before
|
||||
{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-image: linear-gradient(to bottom right,#000,#a92101);
|
||||
opacity: .9;
|
||||
}
|
||||
.minHeight {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
.headerWrapper {
|
||||
padding: 30px 0;
|
||||
padding-left: 75px;
|
||||
min-height: 15vh
|
||||
}
|
||||
.headerDescription {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
font-family: "Raleway";
|
||||
font-weight: 700;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
.headerDescription a {
|
||||
color: #fff;
|
||||
}
|
||||
.headerDescription a:hover {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
.loginBtn {
|
||||
text-align: right;
|
||||
padding-right: 75px;
|
||||
}
|
||||
.loginBtn button {
|
||||
background-color: #f93c18;
|
||||
padding: 10px 30px;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-family: "raleway";
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 25px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.loginBtn button:hover {
|
||||
background-color: #e0270e;
|
||||
}
|
||||
.loginBtn button:focus {
|
||||
outline: none;
|
||||
}
|
||||
.mainWrapper {
|
||||
padding-left: 75px;
|
||||
width: 100%;
|
||||
float: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.description {
|
||||
font-size: 15px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.appstackWrapper {
|
||||
margin-top: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 30px;
|
||||
-webkit-box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
|
||||
-moz-box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
float: left;
|
||||
color: #606060;
|
||||
}
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: -100px;
|
||||
top: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
.arrow img {
|
||||
width: 120px;
|
||||
display: inline-block;
|
||||
}
|
||||
.appStack {
|
||||
width: 100%;
|
||||
float: left;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.appStack i {
|
||||
font-size: 16px;
|
||||
}
|
||||
.checkBox
|
||||
{
|
||||
color: #00bc00;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
.appStackIconWrapper {
|
||||
width: 100%;
|
||||
float: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.appStackIcon img {
|
||||
width: 70%;
|
||||
}
|
||||
.formGroupWrapper
|
||||
{
|
||||
padding-top: 20px;
|
||||
width: 100%;
|
||||
float: left;
|
||||
}
|
||||
.inputGroup
|
||||
{
|
||||
width: 100%;
|
||||
}
|
||||
.inputGroup input
|
||||
{
|
||||
width: 68% !important;
|
||||
display: inline-block;
|
||||
height: 40px;
|
||||
}
|
||||
.inputGroup .groupAppend
|
||||
{
|
||||
width: 32% !important;
|
||||
display: inline-block;
|
||||
}
|
||||
.groupAppend button
|
||||
{
|
||||
width: 100%;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
border: 0;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
background-color: #f93c18;
|
||||
color: #fff;
|
||||
font-family: "raleway";
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.groupAppend button:hover
|
||||
{
|
||||
color: #fff;
|
||||
background-color: #e0270e;
|
||||
}
|
||||
.groupAppend button:focus
|
||||
{
|
||||
outline: none;
|
||||
}
|
||||
.footer {
|
||||
padding-top: 10px;
|
||||
text-align: center;
|
||||
clear: both;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
.footer a {
|
||||
color: #fff;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
.footer i {
|
||||
color: #ed2908;
|
||||
margin: 0 5px;
|
||||
}
|
||||
.tutorialImg {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.tutorialImg img {
|
||||
width: 95%;
|
||||
display: inline-block;
|
||||
}
|
||||
/* Landing section */
|
||||
.app {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.messageOdd {
|
||||
font-size: 18px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
background-color: #fff;
|
||||
padding-left: 5px;
|
||||
margin: 20px;
|
||||
border-radius: 5px;
|
||||
width: 50%;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.selfMessage {
|
||||
font-size: 16px;
|
||||
background-color: #eee;
|
||||
padding-left: 5px;
|
||||
margin: 20px;
|
||||
border-radius: 5px;
|
||||
width: 50%;
|
||||
padding: 5px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.newMessageEven {
|
||||
font-size: 18px;
|
||||
background-color: #98FB98;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.newMessageOdd {
|
||||
font-size: 18px;
|
||||
background-color: #8FBC8F;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.messageWrapperNew {
|
||||
padding-bottom: 75px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
background-color: #20c40f;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
font-family: 'raleway';
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.oldNewSeparator {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#chatbox {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 90px);
|
||||
background-color: #f8f9f9;
|
||||
}
|
||||
|
||||
.textboxWrapper {
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
bottom: 87px;
|
||||
width: 75%;
|
||||
background-color: #fff;
|
||||
padding: 1%;
|
||||
}
|
||||
|
||||
.textbox {}
|
||||
|
||||
.sendButton {
|
||||
width: 20%;
|
||||
background-color:'green';
|
||||
}
|
||||
|
||||
.login {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loginTextbox {
|
||||
font-size: 16px;
|
||||
height: 43px;
|
||||
width: 74%;
|
||||
margin-right: 1%;
|
||||
font-weight: 300;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
padding-left: 10px;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
|
||||
.typoTextbox {
|
||||
font-size: 16px;
|
||||
height: 43px;
|
||||
width: 75%;
|
||||
margin-right: 1%;
|
||||
font-weight: 300;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
padding-left: 10px;
|
||||
display: inline-block;
|
||||
background-color: #f6f6f7;
|
||||
}
|
||||
|
||||
.loginTextbox:focus, .typoTextbox:focus {
|
||||
outline: none;
|
||||
border-color: #016d95;
|
||||
}
|
||||
|
||||
.typoTextbox:focus {
|
||||
outline: none;
|
||||
border-color: #bbbdbd;
|
||||
}
|
||||
|
||||
.loginButton {
|
||||
height: 45px;
|
||||
width: 21%;
|
||||
display: inline-block;
|
||||
border-radius: 5px;
|
||||
background-color: 'green' !important;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 1%;
|
||||
}
|
||||
|
||||
.typoButton {
|
||||
height: 45px;
|
||||
width: 20%;
|
||||
display: inline-block;
|
||||
border-radius: 5px;
|
||||
background-color: #ffca27;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 1%;
|
||||
border: 0;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.typoButton:hover {
|
||||
background-color: #dba203;
|
||||
}
|
||||
|
||||
.loginButton:focus, .typoButton:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.loginButton:hover {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.loginHeading {
|
||||
text-align: center;
|
||||
font-family: 'Raleway';
|
||||
margin-top: 0;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.loginWrapper {
|
||||
width: 450px;
|
||||
padding: 30px;
|
||||
margin: 0 auto;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
text-align: center;
|
||||
color: 'red';
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.wd25 {
|
||||
width: 25%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wd75 {
|
||||
width: 75%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.onlineUsers {
|
||||
background-color: #4f5050;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.messageName, .messsageTime {
|
||||
width: 49%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.messageName {
|
||||
color: #1d5d01;
|
||||
}
|
||||
|
||||
.messsageTime {
|
||||
text-align: right;
|
||||
padding-right: 5px;
|
||||
font-size: 12px;
|
||||
color: #01999b;
|
||||
}
|
||||
|
||||
.userList {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
-webkit-padding-start: 0px;
|
||||
}
|
||||
|
||||
.userList li {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chatWrapper {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.userListHeading {
|
||||
font-weight: 600;
|
||||
padding: 15px 10px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.typingIndicator {
|
||||
text-align: left;
|
||||
padding-bottom: 10px;
|
||||
padding-left: 1%;
|
||||
}
|
||||
|
||||
.displayFlex
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hasura-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hasura-logo a {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.hasura-logo a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.hasura-logo img {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.App-header {
|
||||
background-color: #222;
|
||||
height: 50px;
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
.App-footer{
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 10px;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.footer-small-text{
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.headerDescription
|
||||
{
|
||||
text-align: center;
|
||||
}
|
||||
.headerWrapper {
|
||||
display: block !important;
|
||||
}
|
||||
.description {
|
||||
text-align: center;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.loginBtn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.wd75
|
||||
{
|
||||
width: 100%;
|
||||
}
|
||||
.message
|
||||
{
|
||||
width: 90%;
|
||||
}
|
||||
.textboxWrapper
|
||||
{
|
||||
width: 100%;
|
||||
}
|
||||
.mobileview
|
||||
{
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
bottom: 152px;
|
||||
width: 50%;
|
||||
}
|
||||
.mobileuserListHeading
|
||||
{
|
||||
font-size: 14px;
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
.mobileuserListHeading i
|
||||
{
|
||||
margin-left: 10px;
|
||||
}
|
||||
.mobileUserList
|
||||
{
|
||||
background-color: #4f5050;
|
||||
padding-inline-start: 0px;
|
||||
-webkit-padding-start: 0px;
|
||||
-moz-padding-start: 0px;
|
||||
-o-padding-start: 0px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mobileUserList li
|
||||
{
|
||||
list-style-type: none;
|
||||
color: #fff;
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
.hasura-logo a {
|
||||
padding: 0 0px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.headerWrapper {
|
||||
display: flex;
|
||||
padding-left: 0;
|
||||
height: auto;
|
||||
}
|
||||
.mainWrapper {
|
||||
min-height: auto;
|
||||
padding-left: 0;
|
||||
height: auto;
|
||||
}
|
||||
.minHeight {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.loginBtn {
|
||||
padding-right: 0;
|
||||
}
|
||||
.appstackWrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
.appStack {
|
||||
display: flex;
|
||||
}
|
||||
.flexWidth {
|
||||
flex: 1;
|
||||
}
|
||||
.description {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.appStackIconWrapper {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.inputGroup input
|
||||
{
|
||||
width: 63% !important;
|
||||
}
|
||||
.inputGroup .groupAppend
|
||||
{
|
||||
width: 37% !important;
|
||||
}
|
||||
.groupAppend button {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import Main from './components/Main';
|
||||
import './App.css' ;
|
||||
|
||||
const App = () => {
|
||||
return <div className="app"> <Main/> </div>;
|
||||
}
|
||||
|
||||
export default App;
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<App />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
import '../App.css';
|
||||
|
||||
const Banner = (props) => {
|
||||
return (
|
||||
<div className="banner" onClick={props.scrollToNewMessage}>
|
||||
You have {props.numOfNewMessages} new message(s)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner;
|
@ -0,0 +1,73 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import ChatWrapper from './ChatWrapper';
|
||||
import '../App.css';
|
||||
import { useInterval } from '../hooks/useInterval';
|
||||
|
||||
const emitOnlineEvent = gql`
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(
|
||||
pk_columns: { id: $userId }
|
||||
_set: { last_seen: "now()" }
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function Chat(props) {
|
||||
/**
|
||||
* Every 3 seconds emit an online event
|
||||
*/
|
||||
useInterval(async () => {
|
||||
await props.client.mutate({
|
||||
mutation: emitOnlineEvent,
|
||||
variables: {
|
||||
userId: props.userId,
|
||||
},
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChatWrapper userId={props.userId} username={props.username} />
|
||||
<footer className="App-footer">
|
||||
<div className="hasura-logo">
|
||||
<img
|
||||
src="https://graphql-engine-cdn.hasura.io/img/powered_by_hasura_black.svg"
|
||||
onClick={() => window.open('https://hasura.io')}
|
||||
alt="Powered by Hasura"
|
||||
/>
|
||||
|
|
||||
<a
|
||||
href="https://realtime-chat.hasura.app/console"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Backend
|
||||
</a>
|
||||
|
|
||||
<a
|
||||
href="https://github.com/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
|
|
||||
<a
|
||||
href="https://hasura.io/blog/building-a-realtime-chat-app-with-graphql-subscriptions-d68cd33e73f"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Blogpost
|
||||
</a>
|
||||
</div>
|
||||
<div className="footer-small-text">
|
||||
<span>(The database resets every 24 hours)</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chat;
|
@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import RenderMessages from './RenderMessages';
|
||||
import Textbox from './Textbox';
|
||||
import OnlineUsers from './OnlineUsers';
|
||||
import '../App.css';
|
||||
|
||||
export default function RenderMessagesProxy(props) {
|
||||
const [mutationCallback, setMutationCallback] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="chatWrapper">
|
||||
<div className="wd25 hidden-xs">
|
||||
<OnlineUsers userId={props.userId} username={props.username} />
|
||||
</div>
|
||||
<div className="mobileview visible-xs">
|
||||
<OnlineUsers userId={props.userId} username={props.username} />
|
||||
</div>
|
||||
<div className="wd75">
|
||||
<RenderMessages
|
||||
setMutationCallback={setMutationCallback}
|
||||
username={props.username}
|
||||
userId={props.userId}
|
||||
/>
|
||||
<Textbox
|
||||
username={props.username}
|
||||
mutationCallback={mutationCallback}
|
||||
userId={props.userId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { gql, useMutation } from '@apollo/client';
|
||||
import '../App.css';
|
||||
|
||||
import reactLogo from '../images/React-logo.png';
|
||||
import graphql from '../images/graphql.png';
|
||||
import hasuraLogo from '../images/green-logo-white.svg';
|
||||
import apolloLogo from '../images/apollo.png';
|
||||
import rightImg from '../images/chat-app.png';
|
||||
|
||||
const addUser = gql`
|
||||
mutation ($username: String!) {
|
||||
insert_user_one(object: { username: $username }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LandingPage = (props) => {
|
||||
const [addUserHandler, { loading }] = useMutation(addUser, {
|
||||
variables: {
|
||||
username: props.username,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
props.login(data.insert_user_one.id);
|
||||
},
|
||||
onError: () => {
|
||||
alert('Please try again with a different username.');
|
||||
props.setUsername('');
|
||||
},
|
||||
});
|
||||
const handleKeyPress = (key, mutate, loading) => {
|
||||
if (!loading && key.charCode === 13) {
|
||||
mutate();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="container-fluid minHeight">
|
||||
<div className="bgImage"></div>
|
||||
<div>
|
||||
<div className="headerWrapper">
|
||||
<div className="headerDescription">Realtime Chat App</div>
|
||||
</div>
|
||||
<div className="mainWrapper">
|
||||
<div className="col-md-5 col-sm-6 col-xs-12 noPadd">
|
||||
<div className="appstackWrapper">
|
||||
<div className="appStack">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em---1" />
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description">
|
||||
Try out a realtime app that uses
|
||||
</div>
|
||||
<div className="appStackIconWrapper">
|
||||
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
|
||||
<div className="appStackIcon">
|
||||
<img
|
||||
className="img-responsive"
|
||||
src={reactLogo}
|
||||
alt="React logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-8 col-sm-8 col-xs-8 noPadd">
|
||||
<div className="appStackIcon">
|
||||
<img
|
||||
className="img-responsive"
|
||||
src={graphql}
|
||||
alt="GraphQL logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appStack">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em-rocket" />
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description">Powered by</div>
|
||||
<div className="appStackIconWrapper">
|
||||
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
|
||||
<div className="appStackIcon">
|
||||
<img
|
||||
className="img-responsive"
|
||||
src={apolloLogo}
|
||||
alt="apollo logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
|
||||
<div className="appStackIcon">
|
||||
<img
|
||||
className="img-responsive"
|
||||
src={hasuraLogo}
|
||||
alt="Hasura logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appStack">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em-sunglasses" />
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description removePaddBottom">
|
||||
Explore the Hasura GraphQL backend and try out some queries
|
||||
& mutations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appStack removePaddBottom">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="fas fa-check-square checkBox"></i>
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description removePaddBottom">
|
||||
What you get...
|
||||
</div>
|
||||
<div className="addPaddTop">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em-hammer_and_wrench"></i>
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description removePaddBottom">
|
||||
Source code
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="addPaddTop">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em-closed_lock_with_key"></i>
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description removePaddBottom">
|
||||
Access to GraphQL Backend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="addPaddTop">
|
||||
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
|
||||
<i className="em em-zap" />
|
||||
</div>
|
||||
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
|
||||
<div className="description removePaddBottom">
|
||||
Full Tutorial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="formGroupWrapper">
|
||||
<div className="input-group inputGroup">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Enter your username"
|
||||
value={props.username}
|
||||
onChange={(e) => props.setUsername(e.target.value)}
|
||||
onKeyPress={(key) =>
|
||||
handleKeyPress(key, addUserHandler, loading)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="input-group-append groupAppend">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.username.match(/^[a-z0-9_-]{3,15}$/g)) {
|
||||
addUserHandler();
|
||||
} else {
|
||||
alert(
|
||||
'Invalid username. Spaces and special characters not allowed. Please try again'
|
||||
);
|
||||
props.setUsername('');
|
||||
}
|
||||
}}
|
||||
disabled={loading || props.username === ''}
|
||||
>
|
||||
{loading ? 'Please wait ...' : 'Get Started'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
<div className="footer">
|
||||
Built with
|
||||
<i className="fas fa-heart" />
|
||||
by{" "}
|
||||
<a
|
||||
href="https://hasura.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Hasura
|
||||
</a>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
<div className="tutorialImg col-md-6 col-sm-6 col-xs-12 hidden-xs noPadd">
|
||||
<img className="img-responsive" src={rightImg} alt="View" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LandingPage.propTypes = {
|
||||
auth: PropTypes.object,
|
||||
isAuthenticated: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default LandingPage;
|
@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { ApolloConsumer } from '@apollo/client';
|
||||
import Chat from './Chat';
|
||||
import LandingPage from './LandingPage';
|
||||
import '../App.css';
|
||||
|
||||
export default function Main() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [userId, setUserId] = useState(null);
|
||||
|
||||
// check usernme and perform login
|
||||
const login = (id) => {
|
||||
setIsLoggedIn(true);
|
||||
setUserId(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{!isLoggedIn ? (
|
||||
<LandingPage
|
||||
setUsername={setUsername}
|
||||
login={login}
|
||||
username={username}
|
||||
/>
|
||||
) : (
|
||||
<ApolloConsumer>
|
||||
{(client) => {
|
||||
return <Chat userId={userId} username={username} client={client} />;
|
||||
}}
|
||||
</ApolloConsumer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import '../App.js';
|
||||
import '../App.css';
|
||||
import moment from 'moment';
|
||||
|
||||
export default function MessageList(props) {
|
||||
return (
|
||||
<div className={props.isNew ? 'messageWrapperNew' : 'messageWrapper'}>
|
||||
{props.messages.map((m) => {
|
||||
return (
|
||||
<div key={m.id} className="message">
|
||||
<div className="messageNameTime">
|
||||
<div className="messageName">
|
||||
<b>{m.username}</b>
|
||||
</div>
|
||||
<div className="messsageTime">
|
||||
<i>{moment(m.timestamp).fromNow()} </i>
|
||||
</div>
|
||||
</div>
|
||||
<div className="messageText">{m.text}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ height: 0 }} id="lastMessage"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { gql, useSubscription } from '@apollo/client';
|
||||
|
||||
const fetchOnlineUsersSubscription = gql`
|
||||
subscription {
|
||||
user_online(order_by: { username: asc }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function OnlineUsers() {
|
||||
const [showMobileView, setMobileView] = useState(false);
|
||||
|
||||
const { data } = useSubscription(fetchOnlineUsersSubscription);
|
||||
|
||||
const toggleMobileView = () => {
|
||||
setMobileView(!showMobileView);
|
||||
};
|
||||
|
||||
const subscriptionData = (isMobileView) => (
|
||||
<div>
|
||||
<p
|
||||
className={isMobileView ? 'mobileuserListHeading' : 'userListHeading'}
|
||||
onClick={toggleMobileView}
|
||||
>
|
||||
Online Users ({!data?.user_online ? 0 : data?.user_online.length}){' '}
|
||||
{isMobileView && <i className="fa fa-angle-up"></i>}
|
||||
</p>
|
||||
{((isMobileView && showMobileView) || !isMobileView) && (
|
||||
<ul className={isMobileView ? 'mobileUserList' : 'userList'}>
|
||||
{data?.user_online.map((u) => {
|
||||
return <li key={u.id}>{u.username}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="onlineUsers hidden-xs">{subscriptionData(false)}</div>
|
||||
<div className="mobileonlineUsers visible-xs">
|
||||
{subscriptionData(true)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnlineUsers;
|
@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { gql, useSubscription } from "@apollo/client";
|
||||
import "../App.js";
|
||||
import Banner from "./Banner";
|
||||
import MessageList from "./MessageList";
|
||||
|
||||
const fetchMessages = gql`
|
||||
# We can pass in how far back we want to fetch messages
|
||||
subscription ($last_received_ts: timestamptz) {
|
||||
message_stream(
|
||||
cursor: { initial_value: { timestamp: $last_received_ts } }
|
||||
batch_size: 100
|
||||
) {
|
||||
id
|
||||
username
|
||||
text
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RenderMessages({
|
||||
setMutationCallback,
|
||||
username,
|
||||
userId,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [newMessages, setNewMessages] = useState([]);
|
||||
const [bottom, setBottom] = useState(true);
|
||||
|
||||
// add old (read) messages to state
|
||||
const addOldMessages = (newMessages) => {
|
||||
const oldMessages = [...messages, ...newMessages];
|
||||
setMessages(oldMessages);
|
||||
setNewMessages([]);
|
||||
};
|
||||
|
||||
useSubscription(fetchMessages, {
|
||||
variables: {
|
||||
// Arbitrary large date to fetch all messages
|
||||
last_received_ts: "2018-08-21T19:58:46.987552+00:00",
|
||||
},
|
||||
onSubscriptionData: async ({ subscriptionData }) => {
|
||||
if (subscriptionData) {
|
||||
if (!isViewScrollable()) {
|
||||
addOldMessages(subscriptionData.data.message_stream);
|
||||
} else {
|
||||
if (bottom) {
|
||||
addOldMessages(subscriptionData.data.message_stream);
|
||||
} else {
|
||||
addNewMessages(subscriptionData.data.message_stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// scroll to bottom
|
||||
const scrollToBottom = () => {
|
||||
document
|
||||
?.getElementById("lastMessage")
|
||||
?.scrollIntoView({ behavior: "instant" });
|
||||
};
|
||||
|
||||
// scroll to the new message
|
||||
const scrollToNewMessage = () => {
|
||||
document
|
||||
?.getElementById("newMessage")
|
||||
?.scrollIntoView({ behavior: "instant" });
|
||||
};
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// add message to state when text is entered
|
||||
const mutationCallback = useCallback(() => {
|
||||
return (newMessage) => {
|
||||
const allMessages = [...messages, ...newMessages];
|
||||
allMessages.push(newMessage);
|
||||
setMessages(messages);
|
||||
setNewMessages([]);
|
||||
};
|
||||
}, [messages, newMessages]);
|
||||
|
||||
// scroll handler
|
||||
const handleScroll = useCallback(() => {
|
||||
return (e) => {
|
||||
const windowHeight =
|
||||
"innerHeight" in window
|
||||
? window.innerHeight
|
||||
: document.documentElement.offsetHeight;
|
||||
const body = document.getElementById("chatbox");
|
||||
const html = document.documentElement;
|
||||
const docHeight = Math.max(
|
||||
body.scrollHeight,
|
||||
body.offsetHeight,
|
||||
html.clientHeight,
|
||||
html.scrollHeight,
|
||||
html.offsetHeight
|
||||
);
|
||||
const windowBottom = windowHeight + window.pageYOffset;
|
||||
if (windowBottom >= docHeight) {
|
||||
setBottom(true);
|
||||
} else {
|
||||
if (bottom) {
|
||||
setBottom(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [bottom]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
setMutationCallback(mutationCallback);
|
||||
}, [setMutationCallback, mutationCallback]);
|
||||
|
||||
// add new (unread) messages to state
|
||||
const addNewMessages = (incomingMessages) => {
|
||||
const allNewMessages = [...newMessages];
|
||||
incomingMessages.forEach((m) => {
|
||||
// do not add new messages from self
|
||||
if (m.username !== username) {
|
||||
allNewMessages.push(m);
|
||||
}
|
||||
});
|
||||
setNewMessages(newMessages);
|
||||
};
|
||||
|
||||
// check if the view is scrollable
|
||||
const isViewScrollable = () => {
|
||||
const isInViewport = (elem) => {
|
||||
const bounding = elem.getBoundingClientRect();
|
||||
return (
|
||||
bounding.top >= 0 &&
|
||||
bounding.left >= 0 &&
|
||||
bounding.bottom <=
|
||||
(window.innerHeight || document.documentElement.clientHeight) &&
|
||||
bounding.right <=
|
||||
(window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
};
|
||||
if (document.getElementById("lastMessage")) {
|
||||
return !isInViewport(document.getElementById("lastMessage"));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="chatbox">
|
||||
{/* show "unread messages" banner if not at bottom */}
|
||||
{!bottom && newMessages.length > 0 && isViewScrollable() ? (
|
||||
<Banner
|
||||
scrollToNewMessage={scrollToNewMessage}
|
||||
numOfNewMessages={newMessages.length}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Render old messages */}
|
||||
<MessageList messages={messages} isNew={false} username={username} />
|
||||
{/* Show old/new message separation */}
|
||||
<div id="newMessage" className="oldNewSeparator">
|
||||
{newMessages.length !== 0 ? "New messages" : null}
|
||||
</div>
|
||||
|
||||
{/* render new messages */}
|
||||
<MessageList messages={newMessages} isNew={true} username={username} />
|
||||
{/* Bottom div to scroll to */}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
import { useState } from 'react';
|
||||
import { gql, useMutation } from '@apollo/client';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
import '../App.css';
|
||||
|
||||
const insertMessage = gql`
|
||||
mutation insert_message($message: message_insert_input!) {
|
||||
insert_message_one(object: $message) {
|
||||
id
|
||||
timestamp
|
||||
text
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const emitTypingEventGql = gql`
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(
|
||||
pk_columns: { id: $userId }
|
||||
_set: { last_typed: "now()" }
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Textbox(props) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const [insertMessageHandler, { client }] = useMutation(insertMessage, {
|
||||
variables: {
|
||||
message: {
|
||||
username: props.username,
|
||||
text: text,
|
||||
},
|
||||
update: (cache, { data: { insert_message_one } }) => {
|
||||
props.mutationCallback({
|
||||
id: insert_message_one.id,
|
||||
timestamp: insert_message_one.timestamp,
|
||||
username: insert_message_one.username,
|
||||
text: insert_message_one.text,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleTyping = (text, mutate) => {
|
||||
const textLength = text.length;
|
||||
if ((textLength !== 0 && textLength % 5 === 0) || textLength === 1) {
|
||||
emitTypingEvent(mutate);
|
||||
}
|
||||
setText(text);
|
||||
};
|
||||
|
||||
const form = (sendMessage, client) => {
|
||||
return (
|
||||
<form onSubmit={sendMessage}>
|
||||
<div className="textboxWrapper">
|
||||
<TypingIndicator userId={props.userId} />
|
||||
<input
|
||||
id="textbox"
|
||||
className="textbox typoTextbox"
|
||||
value={text}
|
||||
autoFocus={true}
|
||||
onChange={(e) => {
|
||||
handleTyping(e.target.value, client.mutate);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button className="sendButton typoButton" onClick={sendMessage}>
|
||||
{' '}
|
||||
Send{' '}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const emitTypingEvent = async (mutate) => {
|
||||
if (props.userId) {
|
||||
await mutate({
|
||||
mutation: emitTypingEventGql,
|
||||
variables: {
|
||||
userId: props.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const sendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (text === '') {
|
||||
return;
|
||||
}
|
||||
insertMessageHandler();
|
||||
setText('');
|
||||
};
|
||||
return form(sendMessage, client);
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { gql, useSubscription } from '@apollo/client';
|
||||
import '../App.css';
|
||||
|
||||
const getUserTyping = gql`
|
||||
subscription ($selfId: Int) {
|
||||
user_typing(
|
||||
where: { id: { _neq: $selfId } }
|
||||
limit: 1
|
||||
order_by: { last_typed: desc }
|
||||
) {
|
||||
last_typed
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function TypingIndicator(props) {
|
||||
const { data, loading, error } = useSubscription(getUserTyping, {
|
||||
variables: {
|
||||
selfId: props.userId,
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return '';
|
||||
}
|
||||
if (error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="typingIndicator">
|
||||
{data?.user_typing?.length === 0
|
||||
? ''
|
||||
: `${data.user_typing[0].username} is typing ...`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TypingIndicator;
|
After Width: | Height: | Size: 27 KiB |
@ -0,0 +1,22 @@
|
||||
// Credit to Dan Abramov https://overreacted.io/making-setinterval-declarative-with-react-hooks/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useInterval(callback, delay) {
|
||||
const savedCallback = useRef();
|
||||
|
||||
// Remember the latest callback.
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
savedCallback.current();
|
||||
}
|
||||
if (delay !== null) {
|
||||
let id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [delay]);
|
||||
}
|
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 444 KiB |
After Width: | Height: | Size: 458 KiB |
After Width: | Height: | Size: 34 KiB |
@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 220 80" style="enable-background:new 0 0 220 80;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#102954;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M67.4,26.8c2.1-5.2,2.2-16-0.7-24.4l0,0c-0.7-1.5-3-1.1-3.1,0.7v0.6c-0.5,7.9-3.4,12.2-7.6,14.2
|
||||
c-0.7,0.3-1.8,0.2-2.5-0.2c-5.1-3.2-11-5.1-17.5-5.1s-12.4,1.9-17.5,5.1c-0.7,0.4-1.5,0.5-2.2,0.2C12,16.3,8.9,11.5,8.4,3.6V3.1
|
||||
c0-1.6-2.3-2.1-3.1-0.7c-3,8.3-2.9,19.1-0.7,24.4c1.1,2.6,1.1,5.6,0.2,8.3c-1.2,3.4-1.8,7.2-1.7,11c0.3,17.4,15.1,32.2,32.4,32.4
|
||||
c18.3,0.2,33.3-14.6,33.3-32.9c0-3.7-0.6-7.2-1.7-10.5C66.3,32.4,66.4,29.4,67.4,26.8z"/>
|
||||
</g>
|
||||
<ellipse class="st1" cx="36" cy="45.5" rx="25" ry="25"/>
|
||||
<path class="st0" d="M39.9,42.9L34,33.8c-1-1.5-2.9-1.9-4.4-1c-0.9,0.6-1.5,1.6-1.5,2.7c0,0.6,0.2,1.2,0.6,1.7l4,6.2
|
||||
c0.3,0.5,0.2,1.1-0.1,1.5l-6.2,6.8c-1.1,1.3-1.1,3.3,0.2,4.5c0.6,0.6,1.4,0.8,2.2,0.8c0.9,0,1.7-0.4,2.3-1.1l4.6-5.4
|
||||
c0.3-0.4,1-0.4,1.3,0.1l3.3,4.7c0.2,0.3,0.5,0.7,0.9,1c1.1,0.8,2.5,0.7,3.5,0.1l0,0c0.9-0.6,1.5-1.6,1.5-2.7
|
||||
c0-0.6-0.2-1.2-0.5-1.7L39.9,42.9z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M96.8,45.6h-3.9c-0.6,0-1.1-0.5-1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.4c-0.6,0-1.1,0.5-1.1,1.1v31.3
|
||||
c0,0.6,0.5,1.1,1.1,1.1h3.4c0.6,0,1.1-0.5,1.1-1.1V51.3c0-0.6,0.5-1.1,1.1-1.1h3.9c0.6,0,1.1,0.5,1.1,1.1v12.2
|
||||
c0,0.6,0.5,1.1,1.1,1.1h3.4c0.6,0,1.1-0.5,1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1H99c-0.6,0-1.1,0.5-1.1,1.1v12.3
|
||||
C97.9,45.1,97.4,45.6,96.8,45.6z"/>
|
||||
<path class="st0" d="M114.2,32l-5.5,31.3c-0.1,0.6,0.4,1.2,1,1.2h3.4c0.5,0,1-0.4,1-0.9l0.9-5.7c0.1-0.5,0.5-0.9,1-0.9h4.3
|
||||
c0.5,0,1,0.4,1,0.9l1,5.8c0.1,0.5,0.5,0.9,1,0.9h3.5c0.7,0,1.2-0.6,1-1.3L122,32c-0.1-0.5-0.5-0.9-1-0.9h-5.8
|
||||
C114.7,31.1,114.3,31.5,114.2,32z M119.3,52.3h-2.2c-0.7,0-1.1-0.6-1-1.2l1-11.5c0.2-1.2,1.9-1.2,2.1,0l1.1,11.5
|
||||
C120.4,51.7,119.9,52.3,119.3,52.3z"/>
|
||||
<path class="st0" d="M143,45.2h-3.8c-0.7,0-1.1-0.3-1.1-1.1v-7.2c0-0.7,0.4-1.1,1.1-1.1h2.2c0.7,0,1.1,0.3,1.1,1.1v3.4
|
||||
c0,0.6,0.5,1.1,1.1,1.1h3.5c0.6,0,1.1-0.5,1.1-1.1v-4.2c0-3.3-1.8-5-5.3-5h-5.1c-3.5,0-5.3,1.7-5.3,5v8.6c0,3.3,1.8,5.1,5.2,5.1
|
||||
h3.8c0.7,0,1.1,0.3,1.1,1.1v8c0,0.7-0.3,1.1-1.1,1.1h-2.2c-0.7,0-1.1-0.3-1.1-1.1v-3.4c0-0.6-0.5-1.1-1.1-1.1h-3.5
|
||||
c-0.6,0-1.1,0.5-1.1,1.1v4.2c0,3.3,1.8,5,5.3,5h5c3.5,0,5.3-1.7,5.3-5v-9.4C148.2,46.9,146.4,45.2,143,45.2z"/>
|
||||
<path class="st0" d="M164,58.8c0,0.7-0.3,1.1-1.1,1.1h-3c-0.7,0-1.1-0.3-1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.5
|
||||
c-0.6,0-1.1,0.5-1.1,1.1v27.3c0,3.3,1.8,5,5.3,5h5.8c3.5,0,5.3-1.7,5.3-5V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.3c-0.6,0-1.1,0.5-1.1,1.1
|
||||
L164,58.8L164,58.8z"/>
|
||||
<path class="st0" d="M191.8,46.3V36.1c0-3.3-1.8-5-5.3-5h-10c-0.6,0-1.1,0.5-1.1,1.1v31.3c0,0.6,0.5,1.1,1.1,1.1h3.4
|
||||
c0.6,0,1.1-0.5,1.1-1.1v-11c0-0.6,0.5-1.1,1.1-1.1l0,0c0.4,0,0.8,0.3,1,0.7l4.4,11.8c0.2,0.4,0.6,0.7,1,0.7h3.7
|
||||
c0.7,0,1.3-0.7,1-1.4L189,52.3c-0.2-0.5,0.1-1.1,0.6-1.4C190.9,50.1,191.8,48.5,191.8,46.3z M186.2,36.9v8.8
|
||||
c0,0.7-0.4,1.1-1.1,1.1H182c-0.6,0-1.1-0.5-1.1-1.1v-8.8c0-0.6,0.5-1.1,1.1-1.1h3.1C185.8,35.8,186.2,36.2,186.2,36.9z"/>
|
||||
<path class="st0" d="M210.2,31.1h-5.8c-0.5,0-1,0.4-1,0.9l-5.5,31.3c-0.1,0.6,0.4,1.2,1,1.2h3.4c0.5,0,1-0.4,1-0.9l0.9-5.7
|
||||
c0.1-0.5,0.5-0.9,1-0.9h4.3c0.5,0,1,0.4,1,0.9l1,5.8c0.1,0.5,0.5,0.9,1,0.9h3.5c0.7,0,1.2-0.6,1-1.3L211.2,32
|
||||
C211.1,31.5,210.7,31.1,210.2,31.1z M208.4,52.3h-2.1c-0.7,0-1.1-0.6-1-1.2l1-10.5c0.2-1.2,1.9-1.2,2.1,0l1.1,10.5
|
||||
C209.6,51.7,209.1,52.3,208.4,52.3z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
@ -0,0 +1,5 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
ApolloProvider,
|
||||
split,
|
||||
HttpLink,
|
||||
} from "@apollo/client";
|
||||
import { getMainDefinition } from "@apollo/client/utilities";
|
||||
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
||||
import { createClient } from "graphql-ws";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
const scheme = (proto) => {
|
||||
return window.location.protocol === "https:" ? `${proto}s` : proto;
|
||||
};
|
||||
|
||||
const HASURA_GRAPHQL_ENGINE_HOSTNAME = "localhost:8080";
|
||||
export const GRAPHQL_ENDPOINT = `${scheme(
|
||||
"http"
|
||||
)}://${HASURA_GRAPHQL_ENGINE_HOSTNAME}/v1/graphql`;
|
||||
export const WEBSOCKET_ENDPOINT = `${scheme(
|
||||
"ws"
|
||||
)}://${HASURA_GRAPHQL_ENGINE_HOSTNAME}/v1/graphql`;
|
||||
|
||||
const httpLink = new HttpLink({ uri: GRAPHQL_ENDPOINT });
|
||||
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: WEBSOCKET_ENDPOINT,
|
||||
})
|
||||
);
|
||||
|
||||
const link = split(
|
||||
// split based on operation type
|
||||
({ query }) => {
|
||||
const { kind, operation } = getMainDefinition(query);
|
||||
return kind === "OperationDefinition" && operation === "subscription";
|
||||
},
|
||||
wsLink,
|
||||
httpLink
|
||||
);
|
||||
|
||||
// Instantiate client
|
||||
const client = new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache({
|
||||
addTypename: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1,13 @@
|
||||
const reportWebVitals = (onPerfEntry) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|