mirror of https://github.com/waku-org/js-waku.git
65: Few UI improvements r=D4nte a=D4nte Resolves #51 79: Automatically add new issues to js-waku board r=D4nte a=D4nte Co-authored-by: Franck Royer <franck@status.im> Co-authored-by: Franck Royer <franck@royer.one>
This commit is contained in:
commit
f53773997f
|
@ -0,0 +1,16 @@
|
|||
on:
|
||||
issues:
|
||||
types: opened
|
||||
|
||||
jobs:
|
||||
assign_issue_to_project:
|
||||
runs-on: ubuntu-latest
|
||||
name: Assign new issue to project
|
||||
steps:
|
||||
- name: Create new project card with issue
|
||||
id: list
|
||||
uses: qmacro/action-add-issue-to-project-column@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
project: 'js-waku'
|
||||
column: 'New'
|
24
README.md
24
README.md
|
@ -34,7 +34,29 @@ For support, questions & more general topics, please join the discussion on the
|
|||
|
||||
## Examples
|
||||
|
||||
## Chat app
|
||||
## Web Chat App (ReactJS)
|
||||
|
||||
A ReactJS web app is provided as an a show case of the library used in the browser.
|
||||
|
||||
A deployed version is available at https://status-im.github.io/js-waku/
|
||||
Do note that due to some technical restrictions, it does not currently work out-of-the-box.
|
||||
If you wish to try it out, follow the instructions on the [Vac forum](status-im.github.io/js-waku/).
|
||||
It is currently unstable and likely to break.
|
||||
|
||||
To run a development version locally, do:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/status-im/js-waku/ ; cd js-waku
|
||||
npm install
|
||||
npm run build
|
||||
cd web-chat
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
Then, you can use `/help` to change your nick and connect to a server.
|
||||
|
||||
## CLI Chat App (NodeJS)
|
||||
|
||||
A node chat app is provided as a working example of the library.
|
||||
It is interoperable with the [nim-waku chat app example](https://github.com/status-im/nim-waku/blob/master/examples/v2/chat2.nim).
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { Paper } from '@material-ui/core';
|
||||
import { multiaddr } from 'multiaddr';
|
||||
import PeerId from 'peer-id';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
@ -6,6 +5,7 @@ import './App.css';
|
|||
import { ChatMessage } from 'waku-chat/chat_message';
|
||||
import { WakuMessage } from 'waku/waku_message';
|
||||
import { RelayDefaultTopic } from 'waku/waku_relay';
|
||||
import handleCommand from './command';
|
||||
import Room from './Room';
|
||||
import Waku from 'waku/waku';
|
||||
import { WakuContext } from './WakuContext';
|
||||
|
@ -18,45 +18,15 @@ export default function App() {
|
|||
let [nick, setNick] = useState<string>('web-chat');
|
||||
|
||||
useEffect(() => {
|
||||
async function initWaku() {
|
||||
try {
|
||||
const waku = await Waku.create({
|
||||
config: {
|
||||
pubsub: {
|
||||
enabled: true,
|
||||
emitSelf: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setWaku(waku);
|
||||
|
||||
// FIXME: Connect to a go-waku instance by default, temporary hack until
|
||||
// we have a go-waku instance in the fleet
|
||||
waku.libp2p.peerStore.addressBook.add(
|
||||
PeerId.createFromB58String(
|
||||
'16Uiu2HAmVVi6Q4j7MAKVibquW8aA27UNrA4Q8Wkz9EetGViu8ZF1'
|
||||
),
|
||||
[multiaddr('/ip4/134.209.113.86/tcp/9001/ws')]
|
||||
);
|
||||
} catch (e) {
|
||||
console.log('Issue starting waku ', e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewMessages = (event: { data: Uint8Array }) => {
|
||||
const wakuMsg = WakuMessage.decode(event.data);
|
||||
if (wakuMsg.payload) {
|
||||
const chatMsg = ChatMessage.decode(wakuMsg.payload);
|
||||
const messages = stateMessages.slice();
|
||||
messages.push(chatMsg);
|
||||
console.log('setState on ', messages);
|
||||
setMessages(messages);
|
||||
const chatMsg = decodeWakuMessage(event.data);
|
||||
if (chatMsg) {
|
||||
copyAndReplace([chatMsg], stateMessages, setMessages);
|
||||
}
|
||||
};
|
||||
|
||||
if (!stateWaku) {
|
||||
initWaku()
|
||||
initWaku(setWaku)
|
||||
.then(() => console.log('Waku init done'))
|
||||
.catch((e) => console.log('Waku init failed ', e));
|
||||
} else {
|
||||
|
@ -70,103 +40,73 @@ export default function App() {
|
|||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const commandHandler = (input: string) => {
|
||||
let commandResponses: string[] = [];
|
||||
const args = input.split(' ');
|
||||
const cmd = args.shift()!;
|
||||
if (!stateWaku) {
|
||||
commandResponses.push('Waku is not yet initialized');
|
||||
} else {
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
commandResponses.push('/nick <nickname>: set a new nickname');
|
||||
commandResponses.push('/info: some information about the node');
|
||||
commandResponses.push(
|
||||
'/connect <Multiaddr>: connect to the given peer'
|
||||
);
|
||||
commandResponses.push('/help: Display this help');
|
||||
break;
|
||||
case '/nick':
|
||||
const arg = args.shift();
|
||||
if (!arg) {
|
||||
commandResponses.push('No nick provided');
|
||||
} else {
|
||||
setNick(arg);
|
||||
commandResponses.push(`New nick: ${arg}`);
|
||||
}
|
||||
break;
|
||||
case '/info':
|
||||
if (!stateWaku) {
|
||||
commandResponses.push(`Waku node is starting`);
|
||||
} else {
|
||||
commandResponses.push(
|
||||
`PeerId: ${stateWaku.libp2p.peerId.toB58String()}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '/connect':
|
||||
const peer = args.shift();
|
||||
if (!peer) {
|
||||
commandResponses.push('No peer provided');
|
||||
} else {
|
||||
try {
|
||||
const peerMultiaddr = multiaddr(peer);
|
||||
const peerId = peerMultiaddr.getPeerId();
|
||||
if (!peerId) {
|
||||
commandResponses.push('Peer Id needed to dial');
|
||||
} else {
|
||||
stateWaku.libp2p.peerStore.addressBook.add(
|
||||
PeerId.createFromB58String(peerId),
|
||||
[peerMultiaddr]
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
commandResponses.push('Invalid multiaddr: ' + e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '/peers':
|
||||
stateWaku.libp2p.peerStore.peers.forEach((peer, peerId) => {
|
||||
commandResponses.push(peerId + ':');
|
||||
let addresses = ' addresses: [';
|
||||
peer.addresses.forEach(({ multiaddr }) => {
|
||||
addresses += ' ' + multiaddr.toString() + ',';
|
||||
});
|
||||
addresses = addresses.replace(/,$/, '');
|
||||
addresses += ']';
|
||||
commandResponses.push(addresses);
|
||||
let protocols = ' protocols: [';
|
||||
protocols += peer.protocols;
|
||||
protocols += ']';
|
||||
commandResponses.push(protocols);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
commandResponses.push('Unknown Command');
|
||||
}
|
||||
}
|
||||
const messages = stateMessages.slice();
|
||||
commandResponses.forEach((res) => {
|
||||
messages.push(new ChatMessage(new Date(), cmd, res));
|
||||
});
|
||||
setMessages(messages);
|
||||
};
|
||||
}, [stateWaku, stateMessages]);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<div className="chat-room">
|
||||
<WakuContext.Provider value={{ waku: stateWaku }}>
|
||||
<Paper>
|
||||
<Room
|
||||
nick={nick}
|
||||
lines={stateMessages}
|
||||
commandHandler={commandHandler}
|
||||
/>
|
||||
</Paper>
|
||||
</WakuContext.Provider>
|
||||
</div>
|
||||
<div
|
||||
className="chat-app"
|
||||
style={{ height: '100vh', width: '100vw', overflow: 'hidden' }}
|
||||
>
|
||||
<WakuContext.Provider value={{ waku: stateWaku }}>
|
||||
<Room
|
||||
nick={nick}
|
||||
lines={stateMessages}
|
||||
commandHandler={(input: string) => {
|
||||
const { command, response } = handleCommand(
|
||||
input,
|
||||
stateWaku,
|
||||
setNick
|
||||
);
|
||||
const commandMessages = response.map((msg) => {
|
||||
return new ChatMessage(new Date(), command, msg);
|
||||
});
|
||||
copyAndReplace(commandMessages, stateMessages, setMessages);
|
||||
}}
|
||||
/>
|
||||
</WakuContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function initWaku(setter: (waku: Waku) => void) {
|
||||
try {
|
||||
const waku = await Waku.create({
|
||||
config: {
|
||||
pubsub: {
|
||||
enabled: true,
|
||||
emitSelf: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setter(waku);
|
||||
|
||||
// FIXME: Connect to a go-waku instance by default, temporary hack until
|
||||
// we have a go-waku instance in the fleet
|
||||
waku.libp2p.peerStore.addressBook.add(
|
||||
PeerId.createFromB58String(
|
||||
'16Uiu2HAmVVi6Q4j7MAKVibquW8aA27UNrA4Q8Wkz9EetGViu8ZF1'
|
||||
),
|
||||
[multiaddr('/ip4/134.209.113.86/tcp/9001/ws')]
|
||||
);
|
||||
} catch (e) {
|
||||
console.log('Issue starting waku ', e);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWakuMessage(data: Uint8Array): null | ChatMessage {
|
||||
const wakuMsg = WakuMessage.decode(data);
|
||||
if (!wakuMsg.payload) {
|
||||
return null;
|
||||
}
|
||||
return ChatMessage.decode(wakuMsg.payload);
|
||||
}
|
||||
|
||||
function copyAndReplace<T>(
|
||||
newValues: Array<T>,
|
||||
currentValues: Array<T>,
|
||||
setter: (val: Array<T>) => void
|
||||
) {
|
||||
const copy = currentValues.slice();
|
||||
setter(copy.concat(newValues));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ChatMessage } from '../../build/main/chat/chat_message';
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export default function ChatList(props: Props) {
|
||||
const messages = props.messages;
|
||||
|
||||
const listItems = messages.map((message) => (
|
||||
<ListItem key={message.timestamp.toString()}>
|
||||
<ListItemText primary={<Message message={message} />} />
|
||||
</ListItem>
|
||||
));
|
||||
|
||||
return (
|
||||
<List dense={true}>
|
||||
{listItems}
|
||||
<AlwaysScrollToBottom messages={messages} />
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
function Message(props: MessageProps) {
|
||||
const chatMsg = props.message;
|
||||
const timestamp = chatMsg.timestamp.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// {`<${timestamp}> ${chatMsg.nick}: ${chatMsg.message}`}
|
||||
return (
|
||||
<Card className="chat-message" variant="outlined">
|
||||
<CardContent>
|
||||
<Typography className="chat-nick" variant="subtitle2">
|
||||
{chatMsg.nick}
|
||||
<Typography
|
||||
className="chat-timestamp"
|
||||
color="textSecondary"
|
||||
variant="caption"
|
||||
style={{ marginLeft: 3 }}
|
||||
>
|
||||
{timestamp}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography
|
||||
className="chat-message-content"
|
||||
variant="body1"
|
||||
component="p"
|
||||
>
|
||||
{chatMsg.message}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const AlwaysScrollToBottom = (props: Props) => {
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-ignore
|
||||
elementRef.current.scrollIntoView();
|
||||
}, [props.messages]);
|
||||
|
||||
// @ts-ignore
|
||||
return <div ref={elementRef} />;
|
||||
};
|
|
@ -4,16 +4,18 @@ import { useWaku } from './WakuContext';
|
|||
|
||||
interface Props {
|
||||
messageHandler: (msg: string) => void;
|
||||
sendMessage: () => void;
|
||||
sendMessage: (() => Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
export default function MessageInput(props: Props) {
|
||||
const [inputText, setInputText] = useState<string>('');
|
||||
const { waku } = useWaku();
|
||||
|
||||
const sendMessage = () => {
|
||||
props.sendMessage();
|
||||
setInputText('');
|
||||
const sendMessage = async () => {
|
||||
if (props.sendMessage) {
|
||||
await props.sendMessage();
|
||||
setInputText('');
|
||||
}
|
||||
};
|
||||
|
||||
const messageHandler = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -21,22 +23,20 @@ export default function MessageInput(props: Props) {
|
|||
props.messageHandler(event.target.value);
|
||||
};
|
||||
|
||||
const keyPressHandler = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const keyPressHandler = async (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
sendMessage();
|
||||
await sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} direction="row" alignItems="center">
|
||||
<Grid container direction="row" alignItems="center">
|
||||
<Grid item xs={11}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Send a message"
|
||||
value={inputText}
|
||||
fullWidth
|
||||
style={{ margin: 8 }}
|
||||
margin="normal"
|
||||
fullWidth={true}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Box, Grid, List, ListItem, ListItemText } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import { ChatMessage } from 'waku-chat/chat_message';
|
||||
import { WakuMessage } from 'waku/waku_message';
|
||||
import { ChatContentTopic } from './App';
|
||||
import ChatList from './ChatList';
|
||||
import MessageInput from './MessageInput';
|
||||
import { useWaku } from './WakuContext';
|
||||
|
||||
|
@ -16,78 +16,52 @@ export default function Room(props: Props) {
|
|||
let [messageToSend, setMessageToSend] = useState<string>('');
|
||||
const { waku } = useWaku();
|
||||
|
||||
const messageHandler = (msg: string) => {
|
||||
setMessageToSend(msg);
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (messageToSend.startsWith('/')) {
|
||||
props.commandHandler(messageToSend);
|
||||
} else {
|
||||
const chatMessage = new ChatMessage(
|
||||
new Date(),
|
||||
props.nick,
|
||||
messageToSend
|
||||
);
|
||||
const wakuMsg = WakuMessage.fromBytes(
|
||||
chatMessage.encode(),
|
||||
ChatContentTopic
|
||||
);
|
||||
await waku!.relay.send(wakuMsg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
height={800}
|
||||
maxHeight={800}
|
||||
style={{ flex: 1, maxHeight: '100%', overflow: 'scroll' }}
|
||||
>
|
||||
<Lines messages={props.lines} />
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<div
|
||||
className="chat-container"
|
||||
style={{ height: '98vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div
|
||||
className="chat-list"
|
||||
style={{ display: 'flex', flexGrow: 1, overflowY: 'scroll' }}
|
||||
>
|
||||
<ChatList messages={props.lines} />
|
||||
</div>
|
||||
<div className="chat-input" style={{ display: 'flex', padding: 20 }}>
|
||||
<MessageInput
|
||||
messageHandler={messageHandler}
|
||||
sendMessage={sendMessage}
|
||||
messageHandler={setMessageToSend}
|
||||
sendMessage={
|
||||
waku
|
||||
? async () => {
|
||||
return handleMessage(
|
||||
messageToSend,
|
||||
props.nick,
|
||||
props.commandHandler,
|
||||
waku.relay.send.bind(waku.relay)
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinesProps {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
const Lines = (props: LinesProps) => {
|
||||
const renderedLines = [];
|
||||
|
||||
for (const i in props.messages) {
|
||||
renderedLines.push(
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
key={'chat-message-' + i}
|
||||
primary={printMessage(props.messages[i])}
|
||||
/>
|
||||
</ListItem>
|
||||
async function handleMessage(
|
||||
message: string,
|
||||
nick: string,
|
||||
commandHandler: (cmd: string) => void,
|
||||
messageSender: (msg: WakuMessage) => Promise<void>
|
||||
) {
|
||||
if (message.startsWith('/')) {
|
||||
commandHandler(message);
|
||||
} else {
|
||||
const chatMessage = new ChatMessage(new Date(), nick, message);
|
||||
const wakuMsg = WakuMessage.fromBytes(
|
||||
chatMessage.encode(),
|
||||
ChatContentTopic
|
||||
);
|
||||
return messageSender(wakuMsg);
|
||||
}
|
||||
|
||||
return <List dense={true}>{renderedLines}</List>;
|
||||
};
|
||||
|
||||
// TODO: Make it a proper component
|
||||
function printMessage(chatMsg: ChatMessage) {
|
||||
const timestamp = chatMsg.timestamp.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `<${timestamp}> ${chatMsg.nick}: ${chatMsg.message}`;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
import { multiaddr } from 'multiaddr';
|
||||
import PeerId from 'peer-id';
|
||||
import Waku from '../../build/main/lib/waku';
|
||||
|
||||
function help(): string[] {
|
||||
return [
|
||||
'/nick <nickname>: set a new nickname',
|
||||
'/info: some information about the node',
|
||||
'/connect <Multiaddr>: connect to the given peer',
|
||||
'/help: Display this help',
|
||||
];
|
||||
}
|
||||
|
||||
function nick(
|
||||
nick: string | undefined,
|
||||
setNick: (nick: string) => void
|
||||
): string[] {
|
||||
if (!nick) {
|
||||
return ['No nick provided'];
|
||||
}
|
||||
setNick(nick);
|
||||
return [`New nick: ${nick}`];
|
||||
}
|
||||
|
||||
function info(waku: Waku | undefined): string[] {
|
||||
if (!waku) {
|
||||
return ['Waku node is starting'];
|
||||
}
|
||||
return [`PeerId: ${waku.libp2p.peerId.toB58String()}`];
|
||||
}
|
||||
|
||||
function connect(peer: string | undefined, waku: Waku | undefined): string[] {
|
||||
if (!waku) {
|
||||
return ['Waku node is starting'];
|
||||
}
|
||||
if (!peer) {
|
||||
return ['No peer provided'];
|
||||
}
|
||||
try {
|
||||
const peerMultiaddr = multiaddr(peer);
|
||||
const peerId = peerMultiaddr.getPeerId();
|
||||
if (!peerId) {
|
||||
return ['Peer Id needed to dial'];
|
||||
}
|
||||
waku.libp2p.peerStore.addressBook.add(PeerId.createFromB58String(peerId), [
|
||||
peerMultiaddr,
|
||||
]);
|
||||
return [
|
||||
`${peerId}: ${peerMultiaddr.toString()} added to address book, autodial in progress`,
|
||||
];
|
||||
} catch (e) {
|
||||
return ['Invalid multiaddr: ' + e];
|
||||
}
|
||||
}
|
||||
|
||||
function peers(waku: Waku | undefined): string[] {
|
||||
if (!waku) {
|
||||
return ['Waku node is starting'];
|
||||
}
|
||||
let response: string[] = [];
|
||||
waku.libp2p.peerStore.peers.forEach((peer, peerId) => {
|
||||
response.push(peerId + ':');
|
||||
let addresses = ' addresses: [';
|
||||
peer.addresses.forEach(({ multiaddr }) => {
|
||||
addresses += ' ' + multiaddr.toString() + ',';
|
||||
});
|
||||
addresses = addresses.replace(/,$/, '');
|
||||
addresses += ']';
|
||||
response.push(addresses);
|
||||
let protocols = ' protocols: [';
|
||||
protocols += peer.protocols;
|
||||
protocols += ']';
|
||||
response.push(protocols);
|
||||
});
|
||||
if (response.length === 0) {
|
||||
response.push('Not connected to any peer.');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export default function handleCommand(
|
||||
input: string,
|
||||
waku: Waku | undefined,
|
||||
setNick: (nick: string) => void
|
||||
): { command: string; response: string[] } {
|
||||
let response: string[] = [];
|
||||
const args = parseInput(input);
|
||||
const command = args.shift()!;
|
||||
switch (command) {
|
||||
case '/help':
|
||||
help().map((str) => response.push(str));
|
||||
break;
|
||||
case '/nick':
|
||||
nick(args.shift(), setNick).map((str) => response.push(str));
|
||||
break;
|
||||
case '/info':
|
||||
info(waku).map((str) => response.push(str));
|
||||
break;
|
||||
case '/connect':
|
||||
connect(args.shift(), waku).map((str) => response.push(str));
|
||||
break;
|
||||
case '/peers':
|
||||
peers(waku).map((str) => response.push(str));
|
||||
break;
|
||||
default:
|
||||
response.push(`Unknown Command '${command}'`);
|
||||
}
|
||||
return { command, response };
|
||||
}
|
||||
|
||||
export function parseInput(input: string): string[] {
|
||||
const clean = input.trim().replaceAll(/\s\s+/g, ' ');
|
||||
return clean.split(' ');
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
@import-normalize; /* bring in normalize.css styles */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
|
Loading…
Reference in New Issue