Compare commits
No commits in common. "master" and "old-master" have entirely different histories.
master
...
old-master
46 changed files with 11705 additions and 4854 deletions
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
|||
use flake
|
11
.gitignore
vendored
11
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
/target
|
||||
/node_modules
|
||||
/dist
|
||||
/.direnv
|
||||
/result
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
/src/node_modules/@sapper/
|
||||
yarn-error.log
|
||||
/__sapper__/
|
||||
/test.db
|
2
.ignore
Normal file
2
.ignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
/package-lock.json
|
||||
/rollup.config.js
|
|
@ -1 +0,0 @@
|
|||
package-lock.json
|
1808
Cargo.lock
generated
1808
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
27
Cargo.toml
27
Cargo.toml
|
@ -1,27 +0,0 @@
|
|||
[package]
|
||||
name = "grub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
automerge = "0.5.1"
|
||||
axum = { version = "0.6.20", features = ["http2", "macros", "ws"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
ciborium = "0.2.1"
|
||||
clap = { version = "4.3.21", features = ["derive"] }
|
||||
dashmap = "5.5.0"
|
||||
derivative = "2.2.0"
|
||||
flume = "0.10.14"
|
||||
futures = "0.3.28"
|
||||
mime_guess = "2.0.4"
|
||||
rust-embed = "6.8.1"
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
serde_bytes = "0.11.12"
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.30.0", features = ["full"] }
|
||||
uuid = "1.4.1"
|
||||
|
||||
[dependencies.mzlib]
|
||||
git = "https://git.mzhang.io/michael/mzlib"
|
||||
features = ["axum"]
|
16
README.md
16
README.md
|
@ -1,15 +1,11 @@
|
|||
# grub
|
||||
|
||||
To build:
|
||||
grub
|
||||
====
|
||||
|
||||
```
|
||||
npm ci
|
||||
npm run build
|
||||
cargo build --release
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To run:
|
||||
u know the deal
|
||||
|
||||
```
|
||||
grub [--port 6106]
|
||||
```
|
||||
license: WTFPL
|
|
@ -1,21 +0,0 @@
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./global.css";
|
||||
|
||||
import { v4 } from "uuid";
|
||||
import Room from "./Room";
|
||||
import { Container } from "react-bootstrap";
|
||||
|
||||
export default function App() {
|
||||
const x = new URL(window.location.href);
|
||||
const roomId = x.pathname.replace(/^\//, "");
|
||||
|
||||
if (!roomId) {
|
||||
location.href = `/${v4()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Room roomId={roomId} />
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import { useContext, useState } from "react";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
import { wsUrl } from "./constants";
|
||||
import CBOR from "cbor";
|
||||
import * as uuid from "uuid";
|
||||
import { RoomContext } from "./lib/roomContext";
|
||||
|
||||
export default function Chat() {
|
||||
const { roomId, clientId } = useContext(RoomContext);
|
||||
const [chats, setChats] = useState([]);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
lastJsonMessage,
|
||||
readyState: newReadyState,
|
||||
} = useWebSocket(wsUrl, {
|
||||
share: true,
|
||||
onOpen: ({}) => {
|
||||
console.log("Shiet, connected.");
|
||||
},
|
||||
onMessage: async (event) => {
|
||||
const data = CBOR.decode(await event.data.arrayBuffer());
|
||||
console.log("received", data);
|
||||
|
||||
if (data.type === "ChatMessage") {
|
||||
setChats([...chats, data]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function sendCborMessage(data) {
|
||||
let cbor = CBOR.encode(data);
|
||||
sendMessage(cbor);
|
||||
}
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
sendCborMessage({
|
||||
type: "ChatMessage",
|
||||
timestamp: new Date().toISOString(),
|
||||
message_id: uuid.v4(),
|
||||
room_id: roomId,
|
||||
author: clientId,
|
||||
content: message,
|
||||
});
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Messages:
|
||||
<ul>
|
||||
{chats.map((x) => (
|
||||
<li key={x.message_id}>
|
||||
[{x.timestamp}] {uuid.stringify(x.author)}: {x.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
import * as Automerge from "@automerge/automerge";
|
||||
import { useContext, useState } from "react";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
import { wsUrl } from "./constants";
|
||||
import CBOR from "cbor";
|
||||
import { RoomContext } from "./lib/roomContext";
|
||||
|
||||
export default function Grub() {
|
||||
const { roomId, clientId } = useContext(RoomContext);
|
||||
const [doc, setDoc] = useState(Automerge.init());
|
||||
const [syncState, setSyncState] = useState(Automerge.initSyncState());
|
||||
const [addItemName, setAddItemName] = useState("");
|
||||
|
||||
function updateDoc(newDoc) {
|
||||
setDoc(newDoc);
|
||||
}
|
||||
|
||||
const { sendMessage } = useWebSocket(wsUrl, {
|
||||
share: true,
|
||||
onOpen: ({}) => {
|
||||
console.log("Shiet, connected.");
|
||||
},
|
||||
onMessage: async (event) => {
|
||||
const data = CBOR.decode(await event.data.arrayBuffer());
|
||||
|
||||
if (data.type === "Automerge") {
|
||||
const [nextDoc, nextSyncState, patch] = Automerge.receiveSyncMessage(
|
||||
doc,
|
||||
syncState,
|
||||
data.message[1]
|
||||
);
|
||||
setDoc(nextDoc);
|
||||
setSyncState(nextSyncState);
|
||||
console.log("patch", patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function sendCborMessage(data) {
|
||||
let cbor = CBOR.encode(data);
|
||||
sendMessage(cbor);
|
||||
}
|
||||
|
||||
function addItem(e) {
|
||||
e.preventDefault();
|
||||
const newDoc = Automerge.change(doc, (doc) => {
|
||||
if (!doc.items) doc.items = [];
|
||||
doc.items.push({
|
||||
id: uuid.v4(),
|
||||
content: addItemName,
|
||||
});
|
||||
});
|
||||
updateDoc(newDoc);
|
||||
const [syncMessage, binary] = Automerge.generateSyncMessage(doc, syncState);
|
||||
if (syncMessage)
|
||||
sendCborMessage({
|
||||
type: "Automerge",
|
||||
client_id: clientId,
|
||||
room_id: roomId,
|
||||
message: binary,
|
||||
});
|
||||
setAddItemName("");
|
||||
}
|
||||
|
||||
const items = doc.items || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
Grubs:
|
||||
<ul>
|
||||
{items.map((x) => (
|
||||
<li key={x.id}>{x.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={addItem}>
|
||||
<input
|
||||
type="text"
|
||||
value={addItemName}
|
||||
onChange={(e) => setAddItemName(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit">add item</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
128
client/Room.tsx
128
client/Room.tsx
|
@ -1,128 +0,0 @@
|
|||
import CBOR from "cbor";
|
||||
import { useEffect, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import * as Automerge from "@automerge/automerge";
|
||||
import * as uuid from "uuid";
|
||||
import { wsUrl } from "./constants";
|
||||
import { Tab, Tabs } from "react-bootstrap";
|
||||
import Upload from "./Upload";
|
||||
import Chat from "./Chat";
|
||||
import Grub from "./Grub";
|
||||
import { RoomContext } from "./lib/roomContext";
|
||||
import { Wifi } from "react-bootstrap-icons";
|
||||
|
||||
const connectionStatusMap = {
|
||||
[ReadyState.CONNECTING]: "Connecting",
|
||||
[ReadyState.OPEN]: "Open",
|
||||
[ReadyState.CLOSING]: "Closing",
|
||||
[ReadyState.CLOSED]: "Closed",
|
||||
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
|
||||
};
|
||||
|
||||
export default function Room({ roomId }) {
|
||||
const [readyState, setReadyState] = useState(ReadyState.CLOSED);
|
||||
const [connectedClients, setConnectedClients] = useState([]);
|
||||
const [clientId, setClientId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
lastJsonMessage,
|
||||
readyState: newReadyState,
|
||||
} = useWebSocket(wsUrl, {
|
||||
share: true,
|
||||
onOpen: ({}) => {
|
||||
console.log("Shiet, connected.");
|
||||
},
|
||||
onMessage: async (event) => {
|
||||
const data = CBOR.decode(await event.data.arrayBuffer());
|
||||
console.log("received", data);
|
||||
|
||||
if (data.type === "ServerHello") {
|
||||
setClientId(uuid.stringify(data.client_id));
|
||||
}
|
||||
|
||||
if (data.type === "RoomClientList") {
|
||||
setConnectedClients(data.clients.map((x) => uuid.stringify(x)));
|
||||
}
|
||||
|
||||
if (data.type === "Automerge") {
|
||||
const [nextDoc, nextSyncState, patch] = Automerge.receiveSyncMessage(
|
||||
doc,
|
||||
syncState,
|
||||
data.message[1]
|
||||
);
|
||||
setDoc(nextDoc);
|
||||
setSyncState(nextSyncState);
|
||||
console.log("patch", patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function sendWtfMessage(data) {
|
||||
let cbor = CBOR.encode(data);
|
||||
console.log(
|
||||
"cbor-encoded",
|
||||
[...new Uint8Array(cbor)]
|
||||
.map((x) => x.toString(16).padStart(2, "0"))
|
||||
.join(" ")
|
||||
);
|
||||
sendMessage(cbor);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
readyState === ReadyState.CONNECTING &&
|
||||
newReadyState === ReadyState.OPEN
|
||||
) {
|
||||
// On Open
|
||||
sendWtfMessage({ type: "JoinRoom", room_id: roomId });
|
||||
console.log("Sent connection message");
|
||||
}
|
||||
setReadyState(newReadyState);
|
||||
}, [newReadyState]);
|
||||
|
||||
const connectionStatus = connectionStatusMap[readyState];
|
||||
|
||||
if (newReadyState !== ReadyState.OPEN) return <>Connecting...</>;
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={{ roomId, clientId }}>
|
||||
<p>Room Id: {roomId}</p>
|
||||
<p>
|
||||
Connection status: <ConnectionStatus readyState={readyState} />
|
||||
</p>
|
||||
Connected:
|
||||
<ul>
|
||||
{connectedClients.map((x) => (
|
||||
<li key={x}>{x}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Tabs
|
||||
defaultActiveKey="file-transfer"
|
||||
id="uncontrolled-tab-example"
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab eventKey="file-transfer" title="File Transfer">
|
||||
<Upload />
|
||||
</Tab>
|
||||
<Tab eventKey="grocery-tracking" title="Grocery Tracking">
|
||||
<Grub />
|
||||
</Tab>
|
||||
<Tab eventKey="chat" title="Chat">
|
||||
<Chat />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatus({ readyState }) {
|
||||
switch (readyState) {
|
||||
case ReadyState.CONNECTING:
|
||||
return <Wifi color="yellow" />;
|
||||
case ReadyState.OPEN:
|
||||
return <Wifi color="green" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,289 +0,0 @@
|
|||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import * as sodium from "libsodium-wrappers-sumo";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
import CBOR from "cbor";
|
||||
import { wsUrl } from "./constants";
|
||||
import { RoomContext } from "./lib/roomContext";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Form,
|
||||
Badge,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
|
||||
export default function Upload() {
|
||||
const { roomId } = useContext(RoomContext);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [encryptionKey, setEncryptionKey] = useState(null);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [uploadProgress, setUploadProgress] = useState(null);
|
||||
|
||||
const setHashedPassphrase = useDebouncedCallback(
|
||||
(passphrase) => {
|
||||
if (passphrase) {
|
||||
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||
console.log("salt", salt);
|
||||
setEncryptionKey(
|
||||
sodium.crypto_pwhash(
|
||||
sodium.crypto_box_SEEDBYTES,
|
||||
passphrase,
|
||||
salt,
|
||||
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_ALG_ARGON2ID13
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
// delay in ms
|
||||
1000
|
||||
);
|
||||
|
||||
const [decryptionKey, setDecryptionKey] = useState("");
|
||||
const [selectedSaveFile, setSelectedSaveFile] = useState(null);
|
||||
const [decryptState, setDecryptState] = useState(null);
|
||||
const [pendingDecryption, setPendingDecryption] = useState([]);
|
||||
|
||||
let decryptionKeyInternal: Uint8Array;
|
||||
if (decryptionKey) {
|
||||
decryptionKeyInternal = hex2Bytes(decryptionKey);
|
||||
}
|
||||
const [writeStream, setWriteStream] = useState(null);
|
||||
const readyToDownload =
|
||||
!!decryptionKeyInternal && !!selectedSaveFile && !!writeStream;
|
||||
|
||||
const { sendMessage } = useWebSocket(wsUrl, {
|
||||
share: true,
|
||||
onMessage: async (event) => {
|
||||
const message = CBOR.decode(await event.data.arrayBuffer());
|
||||
|
||||
if (message.type === "FileUploadBegin") {
|
||||
const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
|
||||
message.header,
|
||||
decryptionKeyInternal
|
||||
);
|
||||
setDecryptState(state);
|
||||
}
|
||||
|
||||
if (message.type === "FileUploadChunk") {
|
||||
if (readyToDownload && decryptState) {
|
||||
for (const { data } of [...pendingDecryption, message]) {
|
||||
const { message } =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_pull(
|
||||
decryptState,
|
||||
data
|
||||
);
|
||||
const result = await writeStream.write(message);
|
||||
}
|
||||
setPendingDecryption([]);
|
||||
} else {
|
||||
setPendingDecryption([...pendingDecryption, message]);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "FileUploadComplete") {
|
||||
await writeStream.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function sendCborMessage(data) {
|
||||
let cbor = CBOR.encode(data);
|
||||
sendMessage(cbor);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await sodium.ready;
|
||||
if (passphrase) {
|
||||
setHashedPassphrase(passphrase);
|
||||
} else {
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
setEncryptionKey(key);
|
||||
}
|
||||
})();
|
||||
}, [passphrase]);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
if (!encryptionKey) return;
|
||||
if (!selectedFile) return;
|
||||
|
||||
const { state, header } =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(encryptionKey);
|
||||
sendCborMessage({
|
||||
type: "FileUploadBegin",
|
||||
room_id: roomId,
|
||||
header,
|
||||
});
|
||||
|
||||
const stream = selectedFile.stream();
|
||||
const reader = stream.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Encrypt this chunk
|
||||
const encryptedValue =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
state,
|
||||
value,
|
||||
null,
|
||||
0
|
||||
);
|
||||
|
||||
sendCborMessage({
|
||||
type: "FileUploadChunk",
|
||||
room_id: roomId,
|
||||
data: encryptedValue,
|
||||
});
|
||||
}
|
||||
|
||||
sendCborMessage({
|
||||
type: "FileUploadComplete",
|
||||
room_id: roomId,
|
||||
});
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}, [encryptionKey, selectedFile]);
|
||||
|
||||
async function selectSaveFile() {
|
||||
const newHandle = await window.showSaveFilePicker();
|
||||
setSelectedSaveFile(newHandle);
|
||||
|
||||
const writableStream = await newHandle.createWritable();
|
||||
setWriteStream(writableStream);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>How does this work?</h3>
|
||||
<p style={{ color: "darkred" }}>
|
||||
<b>
|
||||
RECEIVER MUST BE USING CHROME{" "}
|
||||
<a
|
||||
href="https://caniuse.com/mdn-api_window_showsavefilepicker"
|
||||
target="_blank"
|
||||
>
|
||||
[?]
|
||||
</a>
|
||||
</b>
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Uploader:</b> Copy the key and send it to whoever you're sending
|
||||
files to
|
||||
</li>
|
||||
<li>
|
||||
<b>Receiver:</b> Paste the key into the "Paste key here" box
|
||||
</li>
|
||||
<li>
|
||||
<b>Receiver:</b> Press the "select save file" button to choose where
|
||||
to save the file to
|
||||
</li>
|
||||
<li>
|
||||
<b>Receiver:</b> Tell the uploader to press "upload"
|
||||
</li>
|
||||
<li>
|
||||
<b>Uploader:</b> Press "upload"
|
||||
</li>
|
||||
</ol>
|
||||
<p>Only one person can upload at a time.</p>
|
||||
|
||||
<Row>
|
||||
<Col md={6}>
|
||||
<h3>Send file</h3>
|
||||
|
||||
{/* <p>
|
||||
Upload passphrase:
|
||||
<Form.Control
|
||||
value={passphrase}
|
||||
onChange={(e) => {
|
||||
setEncryptionKey(null);
|
||||
setPassphrase(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</p> */}
|
||||
|
||||
<p>
|
||||
Upload Key:{" "}
|
||||
<Form.Control
|
||||
type="text"
|
||||
disabled
|
||||
value={encryptionKey ? bytes2Hex(encryptionKey) : "generating..."}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
/>
|
||||
<Button onClick={uploadFile} disabled={!encryptionKey}>
|
||||
upload
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
<h3>
|
||||
Receive file
|
||||
{readyToDownload ? (
|
||||
<Badge bg="success">ready</Badge>
|
||||
) : (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip>
|
||||
you are not ready to download, make sure you enter the key
|
||||
and select a place to save
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Badge bg="warning">not ready</Badge>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={decryptionKey}
|
||||
onChange={(e) => setDecryptionKey(e.target.value)}
|
||||
placeholder="Paste key here..."
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={selectSaveFile} disabled={!!selectedSaveFile}>
|
||||
{selectedSaveFile
|
||||
? `Saving to "${selectedSaveFile.name}"`
|
||||
: "select save file"}
|
||||
</Button>
|
||||
</li>
|
||||
</ol>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function bytes2Hex(byteArray: Uint8Array): string {
|
||||
return Array.prototype.map
|
||||
.call(byteArray, function (byte: number) {
|
||||
return ("0" + (byte & 0xff).toString(16)).slice(-2);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function hex2Bytes(hexString: string): Uint8Array {
|
||||
let result = [];
|
||||
for (var i = 0; i < hexString.length; i += 2) {
|
||||
result.push(parseInt(hexString.substr(i, 2), 16));
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export const wsUrl = `ws://${location.host}/ws`;
|
|
@ -1,4 +0,0 @@
|
|||
.container {
|
||||
max-width: 980px;
|
||||
margin: auto;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const el = document.getElementById("app")!;
|
||||
const root = createRoot(el);
|
||||
root.render(App());
|
|
@ -1,8 +0,0 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
interface RoomContextProps {
|
||||
roomId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const RoomContext = createContext<RoomContextProps>(undefined);
|
162
flake.lock
162
flake.lock
|
@ -1,162 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1691648495,
|
||||
"narHash": "sha256-JULr+eKL9rjfex17hZYn0K/fBxxfK/FM9TOCcxPQay4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "6c9f0709358f212766cff5ce79f6e8300ec1eb91",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "fenix",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "flake-utils",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"napalm": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1672245824,
|
||||
"narHash": "sha256-i596lbPiA/Rfx3DiJiCluxdgxWY7oGSgYMT7OmM+zik=",
|
||||
"owner": "nix-community",
|
||||
"repo": "napalm",
|
||||
"rev": "7c25a05cef52dc405f4688422ce0046ca94aadcf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "napalm",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1691472822,
|
||||
"narHash": "sha256-XVfYZ2oB3lNPVq6sHCY9WkdQ8lHoIDzzbpg8bB6oBxA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "41c7605718399dcfa53dd7083793b6ae3bc969ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1663087123,
|
||||
"narHash": "sha256-cNIRkF/J4mRxDtNYw+9/fBNq/NOA2nCuPOa3EdIyeDs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9608ace7009ce5bc3aeb940095e01553e635cbc7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1691709280,
|
||||
"narHash": "sha256-zmfH2OlZEXwv572d0g8f6M5Ac6RiO8TxymOpY3uuqrM=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "cf73a86c35a84de0e2f3ba494327cf6fb51c0dfd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"napalm": "napalm",
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1691604464,
|
||||
"narHash": "sha256-nNc/c9r1O8ajE/LkMhGcvJGlyR6ykenR3aRkEkhutxA=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "05b061205179dab9a5cd94ae66d1c0e9b8febe08",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rust-lang",
|
||||
"ref": "nightly",
|
||||
"repo": "rust-analyzer",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
56
flake.nix
56
flake.nix
|
@ -1,56 +0,0 @@
|
|||
{
|
||||
description = "A very basic flake";
|
||||
|
||||
inputs.nixpkgs.url = "github:nixos/nixpkgs";
|
||||
inputs.napalm.url = "github:nix-community/napalm";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, fenix, napalm }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ fenix.overlays.default napalm.overlays.default ];
|
||||
};
|
||||
|
||||
toolchain = pkgs.fenix.stable;
|
||||
rustPlatform =
|
||||
pkgs.makeRustPlatform { inherit (toolchain) cargo rustc; };
|
||||
version = "0.1.0";
|
||||
in rec {
|
||||
defaultPackage = packages.grub;
|
||||
|
||||
packages = rec {
|
||||
grub = rustPlatform.buildRustPackage {
|
||||
name = "grub";
|
||||
inherit version;
|
||||
src = pkgs.symlinkJoin {
|
||||
name = "grub-src";
|
||||
paths = [
|
||||
(pkgs.nix-gitignore.gitignoreSource [ ./.gitignore ] ./.)
|
||||
frontend
|
||||
];
|
||||
};
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
outputHashes = {
|
||||
"mzlib-0.1.0" =
|
||||
"sha256-J7dEeCTPIoKmldpAkQadDBXhJP+Zv9hNlUdpMl2L5QM=";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
frontend = pkgs.buildNpmPackage {
|
||||
name = "frontend";
|
||||
inherit version;
|
||||
src = pkgs.nix-gitignore.gitignoreSource [ ./.gitignore ] ./.;
|
||||
npmDepsHash = "sha256-fmx7Ee+Idx91w9VwV/yeHgJ+4OHiVevfFJ46LTtmZl4=";
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
mv dist $out
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
devShell = pkgs.mkShell { packages = with toolchain; [ cargo rustc ]; };
|
||||
});
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="client/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
12722
package-lock.json
generated
12722
package-lock.json
generated
File diff suppressed because it is too large
Load diff
69
package.json
69
package.json
|
@ -1,39 +1,42 @@
|
|||
{
|
||||
"name": "grub",
|
||||
"version": "0.1.0",
|
||||
"name": "grubs",
|
||||
"description": "grubs",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/libsodium-wrappers": "^0.7.10",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"sass": "^1.65.1",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2"
|
||||
"dev": "sapper dev",
|
||||
"build": "sapper build --legacy",
|
||||
"export": "sapper export --legacy",
|
||||
"start": "node __sapper__/build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automerge/automerge": "^2.0.3",
|
||||
"bootstrap": "^5.3.1",
|
||||
"cbor": "npm:@jprochazk/cbor@^0.5.0",
|
||||
"libsodium": "^0.7.11",
|
||||
"libsodium-wrappers": "^0.7.11",
|
||||
"libsodium-wrappers-sumo": "^0.7.11",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
"react-bootstrap-icons": "^1.10.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-use-websocket": "^4.3.1",
|
||||
"sort-by": "^1.2.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"uuid": "^9.0.0"
|
||||
"compression": "^1.7.1",
|
||||
"express": "^4.17.1",
|
||||
"knex": "^0.21.21",
|
||||
"sequelize": "^6.6.5",
|
||||
"sirv": "^1.0.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"sqlite3": "^5.0.2",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||
"@babel/plugin-transform-runtime": "^7.0.0",
|
||||
"@babel/preset-env": "^7.0.0",
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"@rollup/plugin-babel": "^5.0.0",
|
||||
"@rollup/plugin-commonjs": "^20.0.0",
|
||||
"@rollup/plugin-node-resolve": "^8.0.0",
|
||||
"@rollup/plugin-replace": "^2.4.0",
|
||||
"@rollup/plugin-url": "^5.0.0",
|
||||
"bufferutil": "^4.0.3",
|
||||
"rollup": "^2.3.4",
|
||||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"sapper": "^0.28.0",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"svelte": "^3.17.3",
|
||||
"sveltestrap": "^5.6.0",
|
||||
"utf-8-validate": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
|
127
rollup.config.js
Normal file
127
rollup.config.js
Normal file
|
@ -0,0 +1,127 @@
|
|||
import path from 'path';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import url from '@rollup/plugin-url';
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import babel from '@rollup/plugin-babel';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import config from 'sapper/config/rollup.js';
|
||||
import pkg from './package.json';
|
||||
|
||||
const mode = process.env.NODE_ENV;
|
||||
const dev = mode === 'development';
|
||||
const legacy = !!process.env.SAPPER_LEGACY_BUILD;
|
||||
|
||||
const onwarn = (warning, onwarn) =>
|
||||
(warning.code === 'MISSING_EXPORT' && /'preload'/.test(warning.message)) ||
|
||||
(warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) ||
|
||||
onwarn(warning);
|
||||
|
||||
export default {
|
||||
client: {
|
||||
input: config.client.input(),
|
||||
output: config.client.output(),
|
||||
plugins: [
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values:{
|
||||
'process.browser': true,
|
||||
'process.env.NODE_ENV': JSON.stringify(mode)
|
||||
},
|
||||
}),
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
dev,
|
||||
hydratable: true
|
||||
}
|
||||
}),
|
||||
url({
|
||||
sourceDir: path.resolve(__dirname, 'src/node_modules/images'),
|
||||
publicPath: '/client/'
|
||||
}),
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte']
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
legacy && babel({
|
||||
extensions: ['.js', '.mjs', '.html', '.svelte'],
|
||||
babelHelpers: 'runtime',
|
||||
exclude: ['node_modules/@babel/**'],
|
||||
presets: [
|
||||
['@babel/preset-env', {
|
||||
targets: '> 0.25%, not dead'
|
||||
}]
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
['@babel/plugin-transform-runtime', {
|
||||
useESModules: true
|
||||
}]
|
||||
]
|
||||
}),
|
||||
|
||||
!dev && terser({
|
||||
module: true
|
||||
})
|
||||
],
|
||||
|
||||
preserveEntrySignatures: false,
|
||||
onwarn,
|
||||
},
|
||||
|
||||
server: {
|
||||
input: config.server.input(),
|
||||
output: config.server.output(),
|
||||
plugins: [
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values:{
|
||||
'process.browser': false,
|
||||
'process.env.NODE_ENV': JSON.stringify(mode)
|
||||
},
|
||||
}),
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
dev,
|
||||
generate: 'ssr',
|
||||
hydratable: true
|
||||
},
|
||||
emitCss: false
|
||||
}),
|
||||
url({
|
||||
sourceDir: path.resolve(__dirname, 'src/node_modules/images'),
|
||||
publicPath: '/client/',
|
||||
emitFiles: false // already emitted by client build
|
||||
}),
|
||||
resolve({
|
||||
dedupe: ['svelte']
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
external: Object.keys(pkg.dependencies).concat(require('module').builtinModules),
|
||||
preserveEntrySignatures: 'strict',
|
||||
onwarn,
|
||||
},
|
||||
|
||||
serviceworker: {
|
||||
input: config.serviceworker.input(),
|
||||
output: config.serviceworker.output(),
|
||||
plugins: [
|
||||
resolve(),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values:{
|
||||
'process.browser': true,
|
||||
'process.env.NODE_ENV': JSON.stringify(mode)
|
||||
},
|
||||
}),
|
||||
commonjs(),
|
||||
!dev && terser()
|
||||
],
|
||||
preserveEntrySignatures: false,
|
||||
onwarn,
|
||||
}
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
max_width = 80
|
||||
tab_spaces = 2
|
||||
wrap_comments = true
|
||||
fn_single_line = true
|
39
src/ambient.d.ts
vendored
Normal file
39
src/ambient.d.ts
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* These declarations tell TypeScript that we allow import of images, e.g.
|
||||
* ```
|
||||
<script lang='ts'>
|
||||
import successkid from 'images/successkid.jpg';
|
||||
</script>
|
||||
|
||||
<img src="{successkid}">
|
||||
```
|
||||
*/
|
||||
declare module "*.gif" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
4
src/client.js
Normal file
4
src/client.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import * as sapper from '@sapper/app';
|
||||
sapper.start({
|
||||
target: document.querySelector('#sapper')
|
||||
});
|
67
src/components/GrubView.svelte
Normal file
67
src/components/GrubView.svelte
Normal file
|
@ -0,0 +1,67 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Form, InputGroup, Input, Button, ListGroup, ListGroupItem } from "sveltestrap";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { socket, clientText, clientShadow } from "../lib/stores";
|
||||
import { emptyState, diffStates } from "../lib/state";
|
||||
export let urlPath;
|
||||
|
||||
let text, shadow;
|
||||
|
||||
let connected = false;
|
||||
$socket.on("connect", () => {
|
||||
connected = true;
|
||||
$socket.emit("fullSyncRequest", urlPath);
|
||||
});
|
||||
|
||||
$socket.on("fullSync", newState => {
|
||||
console.log("what the hell is this state", newState);
|
||||
clientShadow.set(newState || emptyState());
|
||||
});
|
||||
|
||||
clientText.subscribe(st => { text = st });
|
||||
clientShadow.subscribe(st => { shadow = st });
|
||||
|
||||
let newGrubValue = "";
|
||||
function newGrub() {
|
||||
let s = newGrubValue.trim();
|
||||
if (s == "") return false;
|
||||
|
||||
let key = `grub-${uuidv4()}`;
|
||||
clientText.update(o => {
|
||||
o.grubs[key] = s;
|
||||
return o;
|
||||
});
|
||||
newGrubValue = "";
|
||||
|
||||
// compute the diff
|
||||
console.log("text", text, "shadow", shadow);
|
||||
let diff = diffStates(shadow, text);
|
||||
console.log("diff", diff)
|
||||
$socket.emit("diffs", diff);
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h3>Grubs</h3>
|
||||
|
||||
<form on:submit|preventDefault|stopPropagation = {newGrub}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
bind:value = {newGrubValue}
|
||||
placeholder = "What do you need?" />
|
||||
<Button>Hellosu</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
|
||||
{connected}
|
||||
|
||||
<ListGroup>
|
||||
{#each Array.from(text.grubs) as [key, grub]}
|
||||
<ListGroupItem>
|
||||
{grub}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
28
src/lib/models.js
Normal file
28
src/lib/models.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Sequelize, DataTypes } from "sequelize";
|
||||
const sequelize = new Sequelize("sqlite:test.db");
|
||||
|
||||
const Grub = sequelize.define("Grubs", {
|
||||
id: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
},
|
||||
tag: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
},
|
||||
}, {
|
||||
// Other model options go here
|
||||
tableName: "grubs",
|
||||
|
||||
indexes: [
|
||||
{ fields: ["tag"] },
|
||||
{ fields: ["id", "tag"] },
|
||||
],
|
||||
});
|
||||
|
||||
export { sequelize, Grub };
|
31
src/lib/server-sync.js
Normal file
31
src/lib/server-sync.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Sequelize, Op } from "sequelize";
|
||||
import { sequelize, Grub } from "./models";
|
||||
import { emptyState, patchState } from "./state";
|
||||
|
||||
export function socketHandler(socket) {
|
||||
socket.on("fullSyncRequest", async msg => {
|
||||
// get the latest message for this id
|
||||
let result = await Grub.findOne({
|
||||
where: { id: { [Op.eq]: msg } },
|
||||
order: [ ["tag", "DESC" ] ],
|
||||
});
|
||||
socket.emit("fullSync", result);
|
||||
});
|
||||
|
||||
socket.on("diffs", async msg => {
|
||||
try {
|
||||
console.log("patch", msg);
|
||||
let lastGrub = await Grub.findOne({
|
||||
where: { id: { [Op.eq]: msg } },
|
||||
order: [ ["tag", "DESC" ] ],
|
||||
});
|
||||
console.log("last", lastGrub);
|
||||
let newGrub = patchState(lastGrub, msg);
|
||||
console.log("New", newGrub);
|
||||
// await newGrub.save();
|
||||
console.log(result);
|
||||
} catch(err) {
|
||||
console.log("holy Fuck error...", err);
|
||||
}
|
||||
});
|
||||
}
|
55
src/lib/state.js
Normal file
55
src/lib/state.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
function emptyState() {
|
||||
return { tag: 0, grubs: {}, recipes: {} };
|
||||
}
|
||||
|
||||
function diffMaps(a, b) {
|
||||
let deleted = Object.assign({}, a);
|
||||
let updated = {};
|
||||
for (let [key, value] of Object.entries(b)) {
|
||||
// delete keys that are still in the second map
|
||||
if (key in deleted) delete deleted[key];
|
||||
if (!(key in a)) updated[key] = value;
|
||||
else if (a[key] != b[key]) updated[key] = value;
|
||||
}
|
||||
return { deleted, updated };
|
||||
}
|
||||
|
||||
function emptyDiff(diff) {
|
||||
return diff.deleted.size == 0 && diff.updated.size == 0;
|
||||
}
|
||||
|
||||
function diffStates(prev, next) {
|
||||
let shadowTag = prev["tag"];
|
||||
let tag = next["tag"];
|
||||
let updated = {};
|
||||
for (let key in prev) {
|
||||
if (key === "tag") continue;
|
||||
let diff = diffMaps(prev[key], next[key]);
|
||||
if (!emptyDiff(diff)) updated[key] = diff;
|
||||
}
|
||||
updated["shadowTag"] = shadowTag;
|
||||
updated["tag"] = tag;
|
||||
return updated;
|
||||
}
|
||||
|
||||
function patchMap(state, diff) {
|
||||
let updated = Object.assign({}, state);
|
||||
for (let key in diff.deleted) {
|
||||
delete updated[key];
|
||||
}
|
||||
for (let [key, value] of Object.entries(diff.updated)) {
|
||||
updated[key] = value;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function patchState(state, diff) {
|
||||
let updated = Object.assign({}, state);
|
||||
for (let key in state) {
|
||||
if (key === "tag") updated[key] = diff[key];
|
||||
else if (key in diff) updated[key] = patchMap(state[key], diff[key]);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export { emptyState, diffMaps, diffStates, emptyDiff, patchMap, patchState };
|
11
src/lib/stores.js
Normal file
11
src/lib/stores.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import io from "socket.io-client";
|
||||
import { readable, writable } from 'svelte/store';
|
||||
import { emptyState } from "./state";
|
||||
|
||||
export const socket = readable(io(), (set) => {
|
||||
// do nothing lol
|
||||
});
|
||||
|
||||
export const clientShadow = writable(emptyState());
|
||||
|
||||
export const clientText = writable(emptyState());
|
363
src/main.rs
363
src/main.rs
|
@ -1,363 +0,0 @@
|
|||
pub mod message;
|
||||
|
||||
#[macro_use]
|
||||
extern crate derivative;
|
||||
|
||||
use std::{
|
||||
io::Cursor,
|
||||
mem,
|
||||
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use automerge::{
|
||||
sync::{State as SyncState, SyncDoc},
|
||||
Automerge,
|
||||
};
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router, Server,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use flume::r#async::SendSink;
|
||||
use frontend::frontend;
|
||||
use futures::{stream, FutureExt, SinkExt, StreamExt};
|
||||
use message::Message;
|
||||
use mzlib::axum_error::Result;
|
||||
use tokio::join;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Opt {
|
||||
#[clap(long, default_value = "6106")]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AppState {
|
||||
clients: Clients,
|
||||
rooms: Rooms,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Clients(Arc<DashMap<Uuid, Client>>);
|
||||
|
||||
struct Client {
|
||||
writer: SendSink<'static, Result<axum::extract::ws::Message, axum::Error>>,
|
||||
rooms: DashSet<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Rooms(Arc<DashMap<Uuid, Room>>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Room {
|
||||
document: Automerge,
|
||||
sync_state: SyncState,
|
||||
connected_clients: DashSet<Uuid>,
|
||||
current_uploader: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn split_borrow(&mut self) -> (&mut Automerge, &mut SyncState) {
|
||||
(&mut self.document, &mut self.sync_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let opts = Opt::parse();
|
||||
|
||||
let state = AppState::default();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(handler))
|
||||
.fallback(frontend)
|
||||
.with_state(state);
|
||||
|
||||
let service = app.into_make_service();
|
||||
|
||||
let addr6 = SocketAddr::from((Ipv6Addr::UNSPECIFIED, opts.port));
|
||||
println!("Listening on {}...", addr6);
|
||||
|
||||
let server6 = Server::bind(&addr6).serve(service);
|
||||
|
||||
server6.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
mod frontend {
|
||||
use axum::{
|
||||
body::{Body, HttpBody},
|
||||
http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "dist/"]
|
||||
struct Frontend;
|
||||
|
||||
pub async fn frontend(uri: Uri) -> impl IntoResponse {
|
||||
let path = uri.path().trim_start_matches("/").to_owned();
|
||||
|
||||
match Frontend::get(&path).or_else(|| Frontend::get("index.html")) {
|
||||
Some(file) => {
|
||||
let guess = mime_guess::from_path(path);
|
||||
let mut headers = HeaderMap::default();
|
||||
if let Some(guess) = guess.first() {
|
||||
headers.append(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(&guess.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let data = file.data.as_ref();
|
||||
let body = Body::from(data.to_owned());
|
||||
let body = body.boxed();
|
||||
(headers, body).into_response()
|
||||
}
|
||||
None => {
|
||||
(StatusCode::NOT_FOUND, format!("No route for {}", uri)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(|socket| handle_socket(state, socket).map(|res| res.unwrap()))
|
||||
}
|
||||
|
||||
async fn handle_socket(state: AppState, socket: WebSocket) -> Result<()> {
|
||||
let (socket_tx, socket_rx) = socket.split();
|
||||
|
||||
// Generate an ID for this connection
|
||||
let client_id = Uuid::new_v4();
|
||||
println!("Connected client {client_id:?}");
|
||||
|
||||
// First, let's create multiplexed versions of these
|
||||
let mut socket_tx = {
|
||||
let (tx, rx) = flume::unbounded();
|
||||
tokio::spawn(rx.into_stream().forward(socket_tx));
|
||||
tx.into_sink()
|
||||
};
|
||||
let mut socket_rx = {
|
||||
let (tx, rx) = flume::unbounded();
|
||||
tokio::spawn(
|
||||
socket_rx
|
||||
.forward(tx.into_sink().sink_map_err(|err| axum::Error::new(err))),
|
||||
);
|
||||
rx.into_stream()
|
||||
};
|
||||
|
||||
// Now register this client into the set of clients
|
||||
let rooms = DashSet::default();
|
||||
let client = Client {
|
||||
writer: socket_tx.clone(),
|
||||
rooms: rooms.clone(),
|
||||
};
|
||||
state.clients.0.insert(client_id, client);
|
||||
|
||||
// Send a hello message
|
||||
socket_tx
|
||||
.send(Ok(
|
||||
Message::ServerHello {
|
||||
timestamp: Utc::now(),
|
||||
client_id: client_id.clone(),
|
||||
}
|
||||
.into_ws_message()?,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Wait for messages
|
||||
while let Some(msg) = socket_rx.next().await {
|
||||
match msg {
|
||||
WsMessage::Binary(ref bin) => {
|
||||
let cursor = Cursor::new(bin);
|
||||
let message = ciborium::from_reader::<Message, _>(cursor)?;
|
||||
// println!("received message: {message:?}");
|
||||
|
||||
match &message {
|
||||
Message::Automerge {
|
||||
client_id,
|
||||
room_id,
|
||||
message: inner_message,
|
||||
} => {
|
||||
let mut room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
rooms.insert(room_id.clone());
|
||||
|
||||
// Update local state
|
||||
{
|
||||
let (document, sync_state) = room.split_borrow();
|
||||
document
|
||||
.receive_sync_message(sync_state, inner_message.clone().0)?;
|
||||
}
|
||||
// println!("inner doc: {:?}", room.document);
|
||||
|
||||
// Remove current client, so send to everyone else
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
connected_clients.remove(client_id);
|
||||
|
||||
// Send to everyone else
|
||||
let message = message.clone();
|
||||
broadcast(&state, connected_clients, message).await;
|
||||
}
|
||||
|
||||
Message::JoinRoom { room_id } => {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
rooms.insert(room_id.clone());
|
||||
|
||||
// Add to the list of connected clients
|
||||
room.connected_clients.insert(client_id);
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
println!("Added to room");
|
||||
|
||||
// Tell each clients which clients are connected
|
||||
broadcast(
|
||||
&state,
|
||||
connected_clients.clone(),
|
||||
Message::RoomClientList {
|
||||
room_id: room_id.clone(),
|
||||
clients: connected_clients.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Message::ChatMessage {
|
||||
timestamp: _,
|
||||
message_id: _,
|
||||
room_id,
|
||||
author: _,
|
||||
content: _,
|
||||
} => {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
|
||||
// Add to the list of connected clients
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
println!("Added to room");
|
||||
|
||||
// Broadcast to the room
|
||||
broadcast(&state, connected_clients.clone(), message).await;
|
||||
}
|
||||
|
||||
Message::FileUploadBegin { room_id, .. }
|
||||
| Message::FileUploadComplete { room_id }
|
||||
| Message::FileUploadChunk { room_id, .. } => {
|
||||
let mut room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
connected_clients.remove(&client_id);
|
||||
|
||||
match &message {
|
||||
Message::FileUploadBegin { .. } => match room.current_uploader {
|
||||
Some(v) if v == client_id => {}
|
||||
Some(_v) => continue,
|
||||
None => room.current_uploader = Some(client_id.clone()),
|
||||
},
|
||||
Message::FileUploadComplete { .. } => {
|
||||
room.current_uploader = None;
|
||||
}
|
||||
Message::FileUploadChunk { .. } => {}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
broadcast(&state, connected_clients, message).await;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
state.clients.0.remove(&client_id);
|
||||
println!("Disconnecting client {client_id:?}");
|
||||
|
||||
// Tell other clients about the disconnection
|
||||
stream::iter(rooms)
|
||||
.for_each(|room_id| {
|
||||
let room = state
|
||||
.rooms
|
||||
.0
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(|| Room::default());
|
||||
|
||||
room.connected_clients.remove(&client_id);
|
||||
let connected_clients = room.connected_clients.clone();
|
||||
mem::drop(room);
|
||||
|
||||
let room_id = room_id.clone();
|
||||
{
|
||||
let state_ref = &state;
|
||||
async move {
|
||||
broadcast(
|
||||
state_ref,
|
||||
connected_clients.clone(),
|
||||
Message::RoomClientList {
|
||||
room_id: room_id,
|
||||
clients: connected_clients.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast<I>(state: &AppState, client_ids: I, message: Message)
|
||||
where
|
||||
I: IntoIterator<Item = Uuid>,
|
||||
{
|
||||
let client_ids = client_ids.into_iter().collect::<Vec<_>>();
|
||||
// println!("Broadcasting message to {} clients:", client_ids.len());
|
||||
// println!(" - message: {message:?}");
|
||||
|
||||
stream::iter(
|
||||
client_ids
|
||||
.into_iter()
|
||||
.filter_map(|client_id| state.clients.0.get(&client_id))
|
||||
.map(|client| client.writer.clone()),
|
||||
)
|
||||
.for_each_concurrent(None, move |mut writer| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
writer.send(Ok(message.into_ws_message().unwrap())).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
104
src/message.rs
104
src/message.rs
|
@ -1,104 +0,0 @@
|
|||
use std::collections::HashSet;
|
||||
use std::fmt::Formatter;
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::extract::ws;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use mzlib::axum_error::Result;
|
||||
use serde::de::Visitor;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Derivative, Clone, Serialize, Deserialize)]
|
||||
#[derivative(Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Message {
|
||||
ServerHello {
|
||||
timestamp: DateTime<Utc>,
|
||||
client_id: Uuid,
|
||||
},
|
||||
JoinRoom {
|
||||
room_id: Uuid,
|
||||
},
|
||||
RoomClientList {
|
||||
room_id: Uuid,
|
||||
clients: HashSet<Uuid>,
|
||||
},
|
||||
ChatMessage {
|
||||
timestamp: DateTime<Utc>,
|
||||
message_id: Uuid,
|
||||
room_id: Uuid,
|
||||
author: Uuid,
|
||||
content: String,
|
||||
},
|
||||
Automerge {
|
||||
client_id: Uuid,
|
||||
room_id: Uuid,
|
||||
#[derivative(Debug = "ignore")]
|
||||
message: AutomergeMessage,
|
||||
},
|
||||
FileUploadBegin {
|
||||
room_id: Uuid,
|
||||
#[serde(with = "serde_bytes")]
|
||||
header: Vec<u8>,
|
||||
},
|
||||
FileUploadChunk {
|
||||
room_id: Uuid,
|
||||
#[serde(with = "serde_bytes")]
|
||||
data: Vec<u8>,
|
||||
},
|
||||
FileUploadComplete {
|
||||
room_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn into_ws_message(self) -> Result<ws::Message> {
|
||||
let vec = Vec::new();
|
||||
let mut cursor = Cursor::new(vec);
|
||||
ciborium::into_writer(&self, &mut cursor)?;
|
||||
let vec = cursor.into_inner();
|
||||
Ok(ws::Message::Binary(vec))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AutomergeMessage(pub automerge::sync::Message);
|
||||
|
||||
impl Serialize for AutomergeMessage {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let bytes = self.0.clone().encode();
|
||||
serializer.serialize_bytes(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AutomergeMessage {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ThisVisitor;
|
||||
impl<'v> Visitor<'v> for ThisVisitor {
|
||||
type Value = AutomergeMessage;
|
||||
|
||||
fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
|
||||
write!(f, "an automerge sync message")
|
||||
}
|
||||
|
||||
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
automerge::sync::Message::decode(v)
|
||||
.map(AutomergeMessage)
|
||||
.map_err(|_err| E::custom("invalid automerge sync message"))
|
||||
}
|
||||
}
|
||||
let visitor = ThisVisitor;
|
||||
deserializer.deserialize_bytes(visitor)
|
||||
}
|
||||
}
|
14
src/routes/[grubs].svelte
Normal file
14
src/routes/[grubs].svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script context="module">
|
||||
export async function preload({ params }) {
|
||||
return { grubs: params.grubs };
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { socket } from "../lib/stores";
|
||||
import GrubView from "../components/GrubView.svelte";
|
||||
export let grubs;
|
||||
</script>
|
||||
|
||||
<GrubView urlPath = {grubs} />
|
40
src/routes/_error.svelte
Normal file
40
src/routes/_error.svelte
Normal file
|
@ -0,0 +1,40 @@
|
|||
<script>
|
||||
export let status;
|
||||
export let error;
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
h1, p {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.8em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
h1 {
|
||||
font-size: 4em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{status}</h1>
|
||||
|
||||
<p>{error.message}</p>
|
||||
|
||||
{#if dev && error.stack}
|
||||
<pre>{error.stack}</pre>
|
||||
{/if}
|
17
src/routes/_layout.svelte
Normal file
17
src/routes/_layout.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
main {
|
||||
position: relative;
|
||||
max-width: 56em;
|
||||
background-color: white;
|
||||
padding: 2em;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
5
src/routes/index.svelte
Normal file
5
src/routes/index.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
import GrubView from "../components/GrubView.svelte";
|
||||
</script>
|
||||
|
||||
TODO: redirect to soemthing
|
31
src/server.js
Normal file
31
src/server.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import express from "express";
|
||||
import { Server } from "socket.io";
|
||||
import http from "http";
|
||||
import sirv from "sirv";
|
||||
import * as sapper from "@sapper/server";
|
||||
|
||||
import { socketHandler } from "./lib/server-sync";
|
||||
import { sequelize } from "./lib/models";
|
||||
|
||||
const { PORT, NODE_ENV } = process.env;
|
||||
const dev = NODE_ENV === "development";
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server);
|
||||
|
||||
app.use(sirv("static", { dev }));
|
||||
app.use(sapper.middleware());
|
||||
|
||||
io.on("connection", socketHandler);
|
||||
|
||||
async function start() {
|
||||
await sequelize.sync();
|
||||
|
||||
const port = PORT || 3000;
|
||||
server.listen(port, () => {
|
||||
console.log("listening on ", port);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
0
src/service-worker.js
Normal file
0
src/service-worker.js
Normal file
36
src/template.html
Normal file
36
src/template.html
Normal file
|
@ -0,0 +1,36 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="theme-color" content="#333333">
|
||||
|
||||
%sapper.base%
|
||||
|
||||
<link rel="stylesheet" href="global.css">
|
||||
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
<link rel="apple-touch-icon" href="logo-192.png">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
|
||||
|
||||
<!-- Sapper creates a <script> tag containing `src/client.js`
|
||||
and anything else it needs to hydrate the app and
|
||||
initialise the router -->
|
||||
%sapper.scripts%
|
||||
|
||||
<!-- Sapper generates a <style> tag containing critical CSS
|
||||
for the current page. CSS for the rest of the app is
|
||||
lazily loaded when it precaches secondary pages -->
|
||||
%sapper.styles%
|
||||
|
||||
<!-- This contains the contents of the <svelte:head> component, if
|
||||
the current page has one -->
|
||||
%sapper.head%
|
||||
</head>
|
||||
<body>
|
||||
<!-- The application will be rendered inside this element,
|
||||
because `src/client.js` references it -->
|
||||
<div id="sapper">%sapper.html%</div>
|
||||
</body>
|
||||
</html>
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
36
static/global.css
Normal file
36
static/global.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: menlo, inconsolata, monospace;
|
||||
font-size: calc(1em - 2px);
|
||||
color: #555;
|
||||
background-color: #f0f0f0;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 400px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
BIN
static/logo-192.png
Normal file
BIN
static/logo-192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
static/logo-512.png
Normal file
BIN
static/logo-512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
22
static/manifest.json
Normal file
22
static/manifest.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#333333",
|
||||
"name": "TODO",
|
||||
"short_name": "TODO",
|
||||
"display": "minimal-ui",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { defineConfig } from "vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), wasm(), topLevelAwait()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/ws": {
|
||||
target: "ws://localhost:6106/ws",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/ws/, ""),
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue