Collaborative session implementation, 1
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1838
frontend/package-lock.json
generated
Normal file
1838
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.27.2",
|
||||
"uuidv7": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0-beta.13"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.13"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
25
frontend/src/app.css
Normal file
25
frontend/src/app.css
Normal file
@@ -0,0 +1,25 @@
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.preact:hover {
|
||||
filter: drop-shadow(0 0 2em #673ab8aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
263
frontend/src/app.tsx
Normal file
263
frontend/src/app.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import './app.css'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { useSyncExternalStore, version } from 'preact/compat'
|
||||
|
||||
const UPDATE_ENDPOINT = "http://localhost:8000/update"
|
||||
const EVENTS_ENDPOINT = "http://localhost:8000/events"
|
||||
|
||||
type Snapshot = { cursor: number, values: Record<string, RRecord> }
|
||||
type LocalTweak = { cursor: number, tweak: Update };
|
||||
|
||||
type ObjectId = string;
|
||||
type RNull = { type: "null", id: ObjectId };
|
||||
type RUser = { type: "user", id: ObjectId; name: string; }
|
||||
type RMessage = { type: "message", id: ObjectId; sender: string, content: string }
|
||||
type RRecord = RNull | RUser | RMessage;
|
||||
|
||||
type CTrue = { type: "true" }
|
||||
type CEquivalentTo = { type: "equivalent_to", expected: RRecord }
|
||||
type Condition = CTrue | CEquivalentTo;
|
||||
|
||||
type Update = {
|
||||
condition: Condition,
|
||||
new: RRecord,
|
||||
}
|
||||
type UpdateResult = {
|
||||
index: number,
|
||||
}
|
||||
|
||||
class Tracker {
|
||||
#remoteCursor: number
|
||||
#remoteValues: Record<string, RRecord>
|
||||
#localTweaks: LocalTweak[];
|
||||
#mergedSnapshot: Record<string, RRecord>
|
||||
#stopListening: (() => void) | null;
|
||||
#onChangedCallbacks: (() => void)[];
|
||||
|
||||
constructor() {
|
||||
this.#remoteCursor = 0;
|
||||
this.#remoteValues = {};
|
||||
this.#localTweaks = [];
|
||||
this.#mergedSnapshot = {};
|
||||
this.#stopListening = null;
|
||||
this.#onChangedCallbacks = [];
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#stopListening != null) {
|
||||
throw "already started!";
|
||||
}
|
||||
let body = async() => {
|
||||
while (true) {
|
||||
if (cancellation.canceled) { return; }
|
||||
let result = await fetch(EVENTS_ENDPOINT + "?" + new URLSearchParams({ start: this.#remoteCursor.toString() }).toString(),
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
let versions: RRecord[] = await result.json();
|
||||
for (let version of versions) {
|
||||
console.log(`Got version: ${JSON.stringify(version)}`)
|
||||
this.#remoteCursor += 1;
|
||||
this.#remoteValues[version.id] = version;
|
||||
}
|
||||
if (versions.length > 0) {
|
||||
console.log("Local tweaks before:");
|
||||
console.log(this.#localTweaks);
|
||||
this.#localTweaks = this.#localTweaks.filter((i) => i.cursor >= this.#remoteCursor);
|
||||
console.log("Local tweaks after:");
|
||||
console.log(this.#localTweaks);
|
||||
this.#rebuildSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
var cancellation = { canceled: false };
|
||||
this.#stopListening = () => { cancellation.canceled = true; };
|
||||
body();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.#stopListening == null) {
|
||||
throw "never started";
|
||||
}
|
||||
this.#stopListening();
|
||||
}
|
||||
|
||||
async sendUpdate(update: Update) {
|
||||
let tweak = {
|
||||
cursor: Number.MAX_SAFE_INTEGER,
|
||||
tweak: update,
|
||||
};
|
||||
this.#localTweaks.push(tweak);
|
||||
this.#rebuildSnapshot();
|
||||
let result = await fetch(UPDATE_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(update)
|
||||
});
|
||||
if (result.status == 409) {
|
||||
let ix = this.#localTweaks.indexOf(tweak);
|
||||
if (ix > -1) {
|
||||
this.#localTweaks.splice(ix, 1);
|
||||
} else {
|
||||
throw new Error("tweak unexpectedly disappeared");
|
||||
}
|
||||
this.#rebuildSnapshot();
|
||||
} else {
|
||||
let updateResult: UpdateResult = await result.json();
|
||||
console.log("updateResult: ")
|
||||
console.log(updateResult)
|
||||
tweak.cursor = updateResult.index;
|
||||
this.#localTweaks.sort((a, b) => a.cursor - b.cursor);
|
||||
this.#rebuildSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
#rebuildSnapshot() {
|
||||
let newSnapshot = {...this.#remoteValues};
|
||||
for (var i of this.#localTweaks) {
|
||||
if (i.tweak.new.type == "null") {
|
||||
delete newSnapshot[i.tweak.new.id]
|
||||
} else {
|
||||
newSnapshot[i.tweak.new.id] = i.tweak.new;
|
||||
}
|
||||
}
|
||||
this.#mergedSnapshot = newSnapshot;
|
||||
for (var cb of this.#onChangedCallbacks) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.#mergedSnapshot;
|
||||
}
|
||||
|
||||
onChanged(cb: () => void) {
|
||||
this.#onChangedCallbacks.push(cb);
|
||||
}
|
||||
}
|
||||
|
||||
export function useTrack() {
|
||||
const store = useRef(new Tracker());
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => { store.current.onChanged(callback); store.current.start(); return () => store.current.stop(); },
|
||||
[store.current]
|
||||
)
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => { return store.current.getSnapshot(); }
|
||||
)
|
||||
const sendUpdate = (update: Update) => store.current.sendUpdate(update);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
sendUpdate
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [identity, setIdentity] = useState<string | null>(null);
|
||||
if (identity == null) {
|
||||
return <>
|
||||
<p>Please set your name!</p>
|
||||
<Editor content="" onDone={setIdentity}></Editor>
|
||||
</>
|
||||
}
|
||||
return <>
|
||||
<Chat name={identity}></Chat>
|
||||
</>
|
||||
}
|
||||
export function Chat({name}: {name: string}) {
|
||||
const [me, setMe] = useState(uuidv7());
|
||||
const {snapshot, sendUpdate} = useTrack()
|
||||
|
||||
useEffect(() => {
|
||||
sendUpdate({
|
||||
condition: { type: "true" },
|
||||
new: {
|
||||
type: "user",
|
||||
id: me,
|
||||
name: name,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const sendMessage = (message: string) => {
|
||||
sendUpdate({
|
||||
condition: { type: "true" },
|
||||
new: {
|
||||
type: "message",
|
||||
id: uuidv7(),
|
||||
sender: me,
|
||||
content: message,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const users = Object.values(snapshot).filter((i) => i.type == "user").sort((a, b) => a.id.localeCompare(b.id));
|
||||
const messages = Object.values(snapshot).filter((i) => i.type == "message").sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
let usersByName: Record<string, string> = {};
|
||||
for (var u of users) {
|
||||
usersByName[u.id] = u.name;
|
||||
}
|
||||
console.log(usersByName);
|
||||
|
||||
return <>
|
||||
<h1>Users</h1>
|
||||
<ul>
|
||||
{users.map((i) => <li key={i.id}>{i.name}</li>)}
|
||||
</ul>
|
||||
<h1>Messages</h1>
|
||||
{messages.map((m) => <Message key={m.id} me={me} message={m} usersByName={usersByName} sendUpdate={sendUpdate}></Message>
|
||||
)}
|
||||
<Editor content={""} onDone={sendMessage}></Editor>
|
||||
</>
|
||||
}
|
||||
|
||||
export function Message({
|
||||
me,
|
||||
message,
|
||||
usersByName,
|
||||
sendUpdate,
|
||||
}: {
|
||||
me: string,
|
||||
message: RMessage,
|
||||
usersByName: Record<string, string>,
|
||||
sendUpdate: (update: Update) => void
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const submitNewContent = (newContent: string) => {
|
||||
sendUpdate(
|
||||
{
|
||||
condition: {type: "true"},
|
||||
new: {
|
||||
type: "message",
|
||||
id: message.id,
|
||||
sender: me,
|
||||
content: newContent,
|
||||
}
|
||||
}
|
||||
);
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
return <div>
|
||||
<span>({usersByName[message.sender]})
|
||||
{isEditing ? <>
|
||||
<Editor content={message.content} onDone={submitNewContent} ></Editor>
|
||||
</> : <>{ message.content }</>}
|
||||
{(message.sender == me) && <a onClick={() => setIsEditing(!isEditing)}>EDIT</a>}
|
||||
</span>
|
||||
</div>;
|
||||
|
||||
}
|
||||
|
||||
export function Editor({ content, onDone }: {content: string, onDone: (content: string) => void }) {
|
||||
const [currentContent, setCurrentContent] = useState(content);
|
||||
return <input onChange={(e) => setCurrentContent(e.currentTarget.value)} onKeyDown={(e) => {
|
||||
if (e.key == "Enter") { onDone(currentContent) ; }
|
||||
setCurrentContent("");
|
||||
}}>
|
||||
</input>
|
||||
}
|
||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
5
frontend/src/main.tsx
Normal file
5
frontend/src/main.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { render } from 'preact'
|
||||
import './index.css'
|
||||
import { App } from './app.tsx'
|
||||
|
||||
render(<App />, document.getElementById('app')!)
|
||||
33
frontend/tsconfig.app.json
Normal file
33
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"react": ["./node_modules/preact/compat/"],
|
||||
"react-dom": ["./node_modules/preact/compat/"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import preact from '@preact/preset-vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
})
|
||||
Reference in New Issue
Block a user