Add files
This commit is contained in:
BIN
frontend/bun.lockb
Normal file
BIN
frontend/bun.lockb
Normal file
Binary file not shown.
75
frontend/index.html
Normal file
75
frontend/index.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notebrook</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Theme Color (For Mobile idk) -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<!-- PWA Metadata -->
|
||||
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
|
||||
<meta name="application-name" content="Notebrook" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Notebrook" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
|
||||
|
||||
<style>
|
||||
/* Basic styles for the toasts */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
31
frontend/manifest.webmanifest
Normal file
31
frontend/manifest.webmanifest
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Notebrook",
|
||||
"short_name": "Notebrook",
|
||||
"description": "Stream of conciousness accessible note taking",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "notebrook-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
BIN
frontend/public/intro.wav
Normal file
BIN
frontend/public/intro.wav
Normal file
Binary file not shown.
BIN
frontend/public/login.wav
Normal file
BIN
frontend/public/login.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent1.wav
Normal file
BIN
frontend/public/sent1.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent2.wav
Normal file
BIN
frontend/public/sent2.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent3.wav
Normal file
BIN
frontend/public/sent3.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent4.wav
Normal file
BIN
frontend/public/sent4.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent5.wav
Normal file
BIN
frontend/public/sent5.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent6.wav
Normal file
BIN
frontend/public/sent6.wav
Normal file
Binary file not shown.
BIN
frontend/public/uploadfail.wav
Normal file
BIN
frontend/public/uploadfail.wav
Normal file
Binary file not shown.
BIN
frontend/public/water1.wav
Normal file
BIN
frontend/public/water1.wav
Normal file
Binary file not shown.
BIN
frontend/public/water10.wav
Normal file
BIN
frontend/public/water10.wav
Normal file
Binary file not shown.
BIN
frontend/public/water2.wav
Normal file
BIN
frontend/public/water2.wav
Normal file
Binary file not shown.
BIN
frontend/public/water3.wav
Normal file
BIN
frontend/public/water3.wav
Normal file
Binary file not shown.
BIN
frontend/public/water4.wav
Normal file
BIN
frontend/public/water4.wav
Normal file
Binary file not shown.
BIN
frontend/public/water5.wav
Normal file
BIN
frontend/public/water5.wav
Normal file
Binary file not shown.
BIN
frontend/public/water6.wav
Normal file
BIN
frontend/public/water6.wav
Normal file
Binary file not shown.
BIN
frontend/public/water7.wav
Normal file
BIN
frontend/public/water7.wav
Normal file
Binary file not shown.
BIN
frontend/public/water8.wav
Normal file
BIN
frontend/public/water8.wav
Normal file
Binary file not shown.
BIN
frontend/public/water9.wav
Normal file
BIN
frontend/public/water9.wav
Normal file
Binary file not shown.
103
frontend/src/api.ts
Normal file
103
frontend/src/api.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { IChannel } from "./model/channel";
|
||||
import { IChannelList } from "./model/channel-list";
|
||||
import { IMessage } from "./model/message";
|
||||
import { IUnsentMessage } from "./model/unsent-message";
|
||||
import { state } from "./state";
|
||||
|
||||
|
||||
export const API = {
|
||||
token: "",
|
||||
path: "http://localhost:3000",
|
||||
|
||||
async request(method: string, path: string, body?: any) {
|
||||
if (!API.token) {
|
||||
throw new Error("API token was not set.");
|
||||
}
|
||||
return fetch(`${API.path}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async checkToken() {
|
||||
const response = await API.request("GET", "check-token");
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Invalid token in request");
|
||||
}
|
||||
},
|
||||
|
||||
async getChannels() {
|
||||
const response = await API.request("GET", "channels");
|
||||
const json = await response.json();
|
||||
return json.channels as IChannel[];
|
||||
},
|
||||
|
||||
async getChannel(id: string) {
|
||||
const response = await API.request("GET", `channels/${id}`);
|
||||
const json = await response.json();
|
||||
return json.channel as IChannel;
|
||||
},
|
||||
|
||||
async createChannel(name: string) {
|
||||
const response = await API.request("POST", "channels", { name });
|
||||
const json = await response.json();
|
||||
return json.channel as IChannel;
|
||||
},
|
||||
|
||||
async deleteChannel(id: string) {
|
||||
await API.request("DELETE", `channels/${id}`);
|
||||
},
|
||||
|
||||
async getMessages(channelId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages`);
|
||||
const json = await response.json();
|
||||
return json.messages as IMessage[];
|
||||
},
|
||||
|
||||
async createMessage(channelId: string, content: string) {
|
||||
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
|
||||
const json = await response.json();
|
||||
return json as IMessage;
|
||||
},
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string) {
|
||||
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
|
||||
},
|
||||
|
||||
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
},
|
||||
|
||||
async mergeChannels(channelId: string, targetChannelId: string) {
|
||||
await API.request("POST", "merge-channels", { channelId, targetChannelId });
|
||||
},
|
||||
|
||||
async search(query: string, channelId?: string) {
|
||||
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
|
||||
const response = await API.request("GET", queryPath);
|
||||
const json = await response.json();
|
||||
return json.results as IMessage[];
|
||||
},
|
||||
|
||||
async getFiles(channelId: string, messageId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
|
||||
const json = await response.json();
|
||||
return json.files as string[];
|
||||
}
|
||||
}
|
25
frontend/src/chunk-processor.ts
Normal file
25
frontend/src/chunk-processor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class ChunkProcessor<T> {
|
||||
private chunkSize: number;
|
||||
|
||||
constructor(chunkSize: number = 1000) {
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
const totalChunks = Math.ceil(array.length / this.chunkSize);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
|
||||
await this.processChunk(chunk, callback);
|
||||
}
|
||||
}
|
||||
|
||||
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
callback(chunk);
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
45
frontend/src/dialogs/channel-dialog.ts
Normal file
45
frontend/src/dialogs/channel-dialog.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IChannel } from "../model/channel";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class ChannelDialog extends Dialog<IChannel> {
|
||||
private channel: IChannel;
|
||||
private nameField: TextInput;
|
||||
private makeDefault: Button;
|
||||
private mergeButton: Button;
|
||||
private deleteButton: Button;
|
||||
|
||||
public constructor(channel: IChannel) {
|
||||
super("Channel info for " + channel.name);
|
||||
this.channel = channel;
|
||||
this.nameField = new TextInput("Channel name");
|
||||
this.nameField.setPosition(25, 10, 50, 10);
|
||||
this.nameField.setValue(channel.name);
|
||||
this.makeDefault = new Button("Make default");
|
||||
this.makeDefault.setPosition(20, 70, 10, 10);
|
||||
this.makeDefault.onClick(() => {
|
||||
state.defaultChannelId = this.channel.id;
|
||||
showToast(`${channel.name} is now the default channel.`);
|
||||
});
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(40, 70, 10, 10);
|
||||
this.mergeButton.onClick(() => {
|
||||
showToast("Merge not implemented.");
|
||||
});
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(60, 70, 10, 10);
|
||||
this.deleteButton.onClick(() => {
|
||||
showToast("Delete not implemented.");
|
||||
});
|
||||
this.add(this.nameField);
|
||||
this.add(this.makeDefault);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.deleteButton);
|
||||
this.setOkAction(() => {
|
||||
this.channel.name = this.nameField.getValue();
|
||||
return this.channel;
|
||||
});
|
||||
}
|
||||
}
|
17
frontend/src/dialogs/create-channel.ts
Normal file
17
frontend/src/dialogs/create-channel.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { API } from "../api";
|
||||
import { showToast } from "../speech";
|
||||
import { TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class CreateChannelDialog extends Dialog<string> {
|
||||
private nameField: TextInput;
|
||||
|
||||
public constructor() {
|
||||
super("Create new channel");
|
||||
this.nameField = new TextInput("Name of new channel");
|
||||
this.add(this.nameField);
|
||||
this.setOkAction(() => {
|
||||
return this.nameField.getValue();
|
||||
});
|
||||
}
|
||||
}
|
47
frontend/src/dialogs/message.ts
Normal file
47
frontend/src/dialogs/message.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, Container, TextInput} from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { MultilineInput } from "../ui/multiline-input";
|
||||
export class MessageDialog extends Dialog<void> {
|
||||
private message: IMessage;
|
||||
private messageText: MultilineInput;
|
||||
private deleteButton: Button;
|
||||
private fileInfoContainer?: Container;
|
||||
|
||||
public constructor(message: IMessage) {
|
||||
super("Message");
|
||||
this.message = message;
|
||||
this.messageText = new MultilineInput("Message");
|
||||
this.messageText.setValue(message.content);
|
||||
this.messageText.setPosition(10, 10, 80, 20);
|
||||
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(10, 90, 80, 10);
|
||||
this.deleteButton.onClick(() => {
|
||||
return;
|
||||
});
|
||||
this.add(this.messageText);
|
||||
if (this.message.fileId !== null) {
|
||||
this.fileInfoContainer = new Container("File info");
|
||||
this.fileInfoContainer.setPosition(10, 50, 30, 80);
|
||||
this.add(this.fileInfoContainer);
|
||||
this.handleMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage() {
|
||||
if (this.message?.fileType?.toLowerCase().includes("audio")) {
|
||||
const audio = new Audio(`${API.path}/${this.message.filePath}`);
|
||||
audio.autoplay = true;
|
||||
}
|
||||
|
||||
// display info about files, or the image if it is an image. Also display all metadata.
|
||||
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
|
||||
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
|
||||
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
|
||||
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
|
||||
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
|
||||
}
|
||||
}
|
72
frontend/src/dialogs/record-audio.ts
Normal file
72
frontend/src/dialogs/record-audio.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Button } from "../ui";
|
||||
import { Audio } from "../ui/audio";
|
||||
import { AudioRecorder } from "../ui/audio-recorder";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class RecordAudioDialog extends Dialog<Blob> {
|
||||
private audioRecorder: AudioRecorder;
|
||||
private recordButton: Button;
|
||||
private stopButton: Button;
|
||||
private playButton: Button;
|
||||
private saveButton: Button;
|
||||
private discardButton: Button;
|
||||
private audioBlob: Blob | undefined;
|
||||
private audioPlayer?: Audio;
|
||||
|
||||
constructor() {
|
||||
super("Record audio", false);
|
||||
this.audioRecorder = new AudioRecorder("Record from microphone");
|
||||
this.audioRecorder.onRecordingComplete(() => {
|
||||
this.audioBlob = this.audioRecorder.getRecording();
|
||||
this.saveButton.setDisabled(false);
|
||||
});
|
||||
this.recordButton = new Button("Record");
|
||||
this.recordButton.setPosition(30, 30, 40, 30);
|
||||
this.recordButton.onClick(() => this.startRecording());
|
||||
this.stopButton = new Button("Stop");
|
||||
this.stopButton.setPosition(70, 40, 30, 30);
|
||||
this.stopButton.onClick(() => this.stopRecording());
|
||||
this.stopButton.setDisabled(true);
|
||||
this.saveButton = new Button("Save");
|
||||
this.saveButton.setPosition(10, 80, 50, 20);
|
||||
this.saveButton.onClick(() => this.saveRecording());
|
||||
this.saveButton.setDisabled(true);
|
||||
this.playButton = new Button("Play");
|
||||
this.playButton.setPosition(0, 40, 30, 30);
|
||||
this.playButton.onClick(() => {
|
||||
if (this.audioBlob) {
|
||||
this.audioPlayer = new Audio("Recorded audio");
|
||||
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
|
||||
this.audioPlayer.play();
|
||||
}
|
||||
});
|
||||
this.playButton.setDisabled(true);
|
||||
this.discardButton = new Button("Discard");
|
||||
this.discardButton.setPosition(50, 90, 50, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.recordButton);
|
||||
this.add(this.stopButton);
|
||||
this.add(this.playButton);
|
||||
this.add(this.saveButton);
|
||||
this.add(this.discardButton);
|
||||
}
|
||||
|
||||
private startRecording() {
|
||||
this.audioRecorder.startRecording();
|
||||
this.stopButton.setDisabled(false);
|
||||
this.recordButton.setDisabled(true);
|
||||
}
|
||||
|
||||
private stopRecording() {
|
||||
this.audioRecorder.stopRecording();
|
||||
this.recordButton.setDisabled(false);
|
||||
this.stopButton.setDisabled(true);
|
||||
this.playButton.setDisabled(false);
|
||||
}
|
||||
|
||||
private saveRecording() {
|
||||
if (this.audioBlob) {
|
||||
this.choose(this.audioBlob);
|
||||
}
|
||||
}
|
||||
}
|
42
frontend/src/dialogs/search.ts
Normal file
42
frontend/src/dialogs/search.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, List, ListItem, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
|
||||
private searchField: TextInput;
|
||||
private searchButton: Button;
|
||||
private resultsList: List;
|
||||
private closeButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Search for message", false);
|
||||
this.searchField = new TextInput("Search query");
|
||||
this.searchField.setPosition(5, 5, 80, 20);
|
||||
this.searchButton = new Button("Search");
|
||||
this.searchButton.setPosition(85, 5, 10, 20);
|
||||
this.searchButton.onClick(async () => {
|
||||
const messages = await API.search(this.searchField.getValue());
|
||||
console.log(messages);
|
||||
this.renderResults(messages);
|
||||
})
|
||||
this.resultsList = new List("Results");
|
||||
this.resultsList.setPosition(5, 20, 90, 70);
|
||||
this.closeButton = new Button("Close");
|
||||
this.closeButton.setPosition(5, 90, 90, 5);
|
||||
this.closeButton.onClick(() => this.cancel());
|
||||
this.add(this.searchField);
|
||||
this.add(this.searchButton);
|
||||
this.add(this.resultsList);
|
||||
this.add(this.closeButton);
|
||||
}
|
||||
|
||||
private renderResults(messages: IMessage[]) {
|
||||
this.resultsList.clear();
|
||||
messages.forEach((message) => {
|
||||
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
|
||||
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
|
||||
this.resultsList.add(itm);
|
||||
});
|
||||
}
|
||||
}
|
23
frontend/src/dialogs/settings.ts
Normal file
23
frontend/src/dialogs/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { state } from "../state";
|
||||
|
||||
export class SettingsDialog extends Dialog<void> {
|
||||
private resetButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Settings");
|
||||
this.resetButton = new Button("Reset frontend");
|
||||
this.resetButton.setPosition(30, 20, 30, 30);
|
||||
this.resetButton.onClick(() => {
|
||||
this.reset();
|
||||
});
|
||||
this.add(this.resetButton);
|
||||
}
|
||||
|
||||
private reset() {
|
||||
state.clear().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
30
frontend/src/dialogs/take-photo.ts
Normal file
30
frontend/src/dialogs/take-photo.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { Button } from "../ui";
|
||||
import { Camera } from "../ui/camera";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class TakePhotoDialog extends Dialog<Blob> {
|
||||
private camera: Camera;
|
||||
private takePhotoButton: Button;
|
||||
private discardButton: Button;
|
||||
|
||||
constructor() {
|
||||
super("Take photo", false);
|
||||
this.camera = new Camera("Photo camera");
|
||||
this.camera.setPosition(10, 15, 80, 75);
|
||||
this.camera.startCamera();
|
||||
this.takePhotoButton = new Button("Take photo");
|
||||
this.takePhotoButton.setPosition(10, 90, 80, 10);
|
||||
this.discardButton = new Button("Cancel");
|
||||
this.discardButton.setPosition(5, 5, 10, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.camera);
|
||||
this.add(this.takePhotoButton);
|
||||
this.add(this.discardButton);
|
||||
this.takePhotoButton.onClick(async () => {
|
||||
const photo = await this.camera.savePhotoToBlob();
|
||||
if (photo) this.choose(photo);
|
||||
});
|
||||
}
|
||||
}
|
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import './style.css'
|
||||
import { MainView } from "./views/main";
|
||||
import { ViewManager } from './views/view-manager';
|
||||
import { AuthorizeView } from './views/authorize';
|
||||
import { state } from './state';
|
||||
import { API } from './api';
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await state.load();
|
||||
const vm = new ViewManager();
|
||||
setInterval(() => {
|
||||
state.save();
|
||||
}, 10000);
|
||||
|
||||
if (state.token === "" || state.apiUrl === "") {
|
||||
vm.push(new AuthorizeView(vm));
|
||||
} else {
|
||||
vm.push(new MainView(vm));
|
||||
}
|
||||
document.body.appendChild(vm.render() as HTMLElement);
|
||||
});
|
46
frontend/src/model/channel-list.ts
Normal file
46
frontend/src/model/channel-list.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Channel, IChannel } from "./channel";
|
||||
|
||||
export interface IChannelList {
|
||||
channels: IChannel[]
|
||||
}
|
||||
|
||||
export class ChannelList implements IChannelList {
|
||||
channels: Channel[] = [];
|
||||
|
||||
constructor(channels?: IChannelList) {
|
||||
this.channels = channels?.channels?.map((chan) => new Channel(chan)) || [];
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel): void {
|
||||
this.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channelId: number): void {
|
||||
this.channels = this.channels.filter(channel => channel.id !== channelId);
|
||||
}
|
||||
|
||||
public getChannel(channelId: number): Channel|undefined {
|
||||
return this.channels.find(channel => channel.id === channelId);
|
||||
}
|
||||
|
||||
public getChannelByName(channelName: string): IChannel|undefined {
|
||||
return this.channels.find(channel => channel.name === channelName);
|
||||
}
|
||||
|
||||
public getChannels(): Channel[] {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public getChannelIds(): number[] {
|
||||
return this.channels.map(channel => channel.id);
|
||||
}
|
||||
|
||||
public getChannelNames(): string[] {
|
||||
return this.channels.map(channel => channel.name);
|
||||
}
|
||||
|
||||
public getChannelId(channelName: string): number|undefined {
|
||||
const channel = this.getChannelByName(channelName);
|
||||
return channel ? channel.id : undefined;
|
||||
}
|
||||
}
|
60
frontend/src/model/channel.ts
Normal file
60
frontend/src/model/channel.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { IMessage, Message } from "./message";
|
||||
|
||||
export interface IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: IMessage[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class Channel implements IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
private messageToIdMap: Map<number, Message>;
|
||||
|
||||
constructor(channel: IChannel) {
|
||||
this.id = channel.id;
|
||||
this.name = channel.name;
|
||||
this.messages = [];
|
||||
this.messageToIdMap = new Map();
|
||||
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
|
||||
this.createdAt = channel.createdAt;
|
||||
}
|
||||
|
||||
public addMessage(message: Message): void {
|
||||
this.messages.push(message);
|
||||
this.messageToIdMap.set(message.id, message);
|
||||
}
|
||||
|
||||
public removeMessage(messageId: number): void {
|
||||
this.messages = this.messages.filter(message => message.id !== messageId);
|
||||
this.messageToIdMap.delete(messageId);
|
||||
}
|
||||
|
||||
public getMessage(messageId: number): Message|undefined {
|
||||
return this.messageToIdMap.get(messageId);
|
||||
}
|
||||
|
||||
public getMessageByContent(content: string): Message|undefined {
|
||||
return this.messages.find(message => message.content === content);
|
||||
}
|
||||
|
||||
public getMessages(): Message[] {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
public getMessageIds(): number[] {
|
||||
return this.messages.map(message => message.id);
|
||||
}
|
||||
|
||||
public getMessageContents(): string[] {
|
||||
return this.messages.map(message => message.content);
|
||||
}
|
||||
|
||||
public getMessageId(content: string): number|undefined {
|
||||
const message = this.getMessageByContent(content);
|
||||
return message ? message.id : undefined;
|
||||
}
|
||||
}
|
33
frontend/src/model/message.ts
Normal file
33
frontend/src/model/message.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface IMessage {
|
||||
id: number;
|
||||
channelId?: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class Message implements IMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(message: IMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.fileId = message.fileId;
|
||||
this.fileType = message.fileType;
|
||||
this.filePath = message.filePath;
|
||||
this.fileSize = message.fileSize;
|
||||
this.originalName = message.originalName;
|
||||
this.createdAt = message.createdAt;
|
||||
}
|
||||
}
|
10
frontend/src/model/state.ts
Normal file
10
frontend/src/model/state.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IChannelList } from "./channel-list";
|
||||
import { IUnsentMessage } from "./unsent-message";
|
||||
|
||||
export interface IState {
|
||||
token: string;
|
||||
apiUrl: string;
|
||||
defaultChannelId: number;
|
||||
channelList: IChannelList;
|
||||
unsentMessages: IUnsentMessage[];
|
||||
}
|
23
frontend/src/model/unsent-message.ts
Normal file
23
frontend/src/model/unsent-message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
}
|
||||
|
||||
export class UnsentMessage implements IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
|
||||
constructor(message: IUnsentMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.blob = message.blob;
|
||||
this.createdAt = message.createdAt;
|
||||
this.channelId = message.channelId;
|
||||
}
|
||||
}
|
62
frontend/src/service-worker.ts
Normal file
62
frontend/src/service-worker.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const CACHE_NAME = 'notebrook-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/intro.wav',
|
||||
'/login.wav',
|
||||
'/uploadfail.wav',
|
||||
'/water1.wav',
|
||||
'/water2.wav',
|
||||
'/water3.wav',
|
||||
'/water4.wav',
|
||||
'/water5.wav',
|
||||
'/water6.wav',
|
||||
'/water7.wav',
|
||||
'/water8.wav',
|
||||
'/water9.wav',
|
||||
'/water10.wav',
|
||||
'/sent1.wav',
|
||||
'/sent2.wav',
|
||||
'/sent3.wav',
|
||||
'/sent4.wav',
|
||||
'/sent5.wav',
|
||||
'/sent6.wav',
|
||||
'/vite.svg',
|
||||
'/src/main.ts'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event: any) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: any) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return the cached response if found, otherwise fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event: any) => {
|
||||
const cacheWhitelist = [CACHE_NAME];
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
80
frontend/src/sound.ts
Normal file
80
frontend/src/sound.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
const soundFiles = {
|
||||
intro: 'intro.wav',
|
||||
login: 'login.wav',
|
||||
uploadFailed: 'uploadfail.wav'
|
||||
} as const;
|
||||
|
||||
type SoundName = keyof typeof soundFiles;
|
||||
|
||||
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
|
||||
|
||||
const waterSounds: AudioBuffer[] = [];
|
||||
const sentSounds: AudioBuffer[] = [];
|
||||
|
||||
async function loadSound(url: string): Promise<AudioBuffer> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer);
|
||||
}
|
||||
|
||||
async function loadAllSounds() {
|
||||
for (const key in soundFiles) {
|
||||
const soundName = key as SoundName;
|
||||
sounds[soundName] = await loadSound(soundFiles[soundName]);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`water${i}.wav`);
|
||||
waterSounds.push(buffer);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`sent${i}.wav`);
|
||||
sentSounds.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function playSoundBuffer(buffer: AudioBuffer) {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName) {
|
||||
const buffer = sounds[name];
|
||||
if (buffer) {
|
||||
playSoundBuffer(buffer);
|
||||
} else {
|
||||
console.error(`Sound ${name} not loaded.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function playWater() {
|
||||
if (waterSounds.length > 0) {
|
||||
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Water sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
export function playSent() {
|
||||
if (sentSounds.length > 0) {
|
||||
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Sent sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
loadAllSounds().then(() => {
|
||||
console.log('All sounds loaded and ready to play');
|
||||
}).catch(error => {
|
||||
console.error('Error loading sounds:', error);
|
||||
});
|
14
frontend/src/speech.ts
Normal file
14
frontend/src/speech.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Toast } from "./toast";
|
||||
|
||||
export function speak(text: string, interrupt: boolean = false) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
if (interrupt) {
|
||||
speechSynthesis.cancel();
|
||||
}
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export function showToast(message: string, timeout: number = 5000) {
|
||||
const toast = new Toast(timeout);
|
||||
toast.show(message);
|
||||
}
|
134
frontend/src/state.ts
Normal file
134
frontend/src/state.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { IChannel, Channel } from "./model/channel";
|
||||
import { IChannelList, ChannelList } from "./model/channel-list";
|
||||
import { IState } from "./model/state";
|
||||
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
|
||||
import { get, set, clear } from "idb-keyval";
|
||||
|
||||
|
||||
export class State implements IState {
|
||||
token!: string;
|
||||
apiUrl!: string;
|
||||
channelList!: ChannelList;
|
||||
unsentMessages!: IUnsentMessage[];
|
||||
currentChannel!: Channel | null;
|
||||
defaultChannelId!: number;
|
||||
|
||||
constructor() {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
}
|
||||
|
||||
public getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
public setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public getChannelList(): IChannelList {
|
||||
return this.channelList;
|
||||
}
|
||||
|
||||
public setChannelList(channelList: ChannelList): void {
|
||||
this.channelList = channelList;
|
||||
}
|
||||
|
||||
public getUnsentMessages(): IUnsentMessage[] {
|
||||
return this.unsentMessages;
|
||||
}
|
||||
|
||||
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
|
||||
this.unsentMessages = unsentMessages;
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
// stringify everything here except the currentChannel object.
|
||||
const { currentChannel, ...state } = this;
|
||||
await set("notebrook", state);
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
const saved = await get("notebrook");
|
||||
if (saved) {
|
||||
this.token = saved.token;
|
||||
this.apiUrl = saved.apiUrl;
|
||||
this.channelList = new ChannelList( saved.channelList);
|
||||
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
|
||||
this.defaultChannelId = saved.defaultChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.currentChannel = null;
|
||||
this.defaultChannelId = -1;
|
||||
|
||||
await clear();
|
||||
}
|
||||
|
||||
public getChannelById(id: number) {
|
||||
return this.channelList.getChannel(id);
|
||||
}
|
||||
|
||||
public getChannelByName(name: string) {
|
||||
return this.channelList.getChannelByName(name);
|
||||
}
|
||||
|
||||
public findChannelByQuery(query: string) {
|
||||
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel) {
|
||||
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channel: IChannel) {
|
||||
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
|
||||
}
|
||||
|
||||
public addUnsentMessage(message: UnsentMessage) {
|
||||
this.unsentMessages.push(message);
|
||||
}
|
||||
|
||||
public removeUnsentMessage(message: IUnsentMessage) {
|
||||
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
|
||||
}
|
||||
|
||||
public getChannels() {
|
||||
return this.channelList.channels;
|
||||
}
|
||||
|
||||
public getCurrentChannel() {
|
||||
return this.currentChannel;
|
||||
}
|
||||
|
||||
public setCurrentChannel(channel: Channel) {
|
||||
this.currentChannel = channel;
|
||||
}
|
||||
|
||||
public getDefaultChannelId() {
|
||||
return this.defaultChannelId;
|
||||
}
|
||||
|
||||
public setDefaultChannelId(id: number) {
|
||||
this.defaultChannelId = id;
|
||||
}
|
||||
|
||||
public getApiUrl() {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
public setApiUrl(url: string) {
|
||||
this.apiUrl = url;
|
||||
}
|
||||
|
||||
public getMessageById(id: number) {
|
||||
return this.currentChannel!.getMessage(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new State();
|
96
frontend/src/style.css
Normal file
96
frontend/src/style.css
Normal file
@@ -0,0 +1,96 @@
|
||||
:root {
|
||||
font-family: Inter, 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;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #3178c6aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
32
frontend/src/toast.ts
Normal file
32
frontend/src/toast.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export class Toast {
|
||||
private container: HTMLElement;
|
||||
private timeout: number;
|
||||
|
||||
constructor(timeout: number = 3000) {
|
||||
this.container = document.querySelector('.toast-container') as HTMLElement;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public show(message: string): void {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.hide(toast);
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
private hide(toast: HTMLElement): void {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
}
|
1
frontend/src/typescript.svg
Normal file
1
frontend/src/typescript.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="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
After Width: | Height: | Size: 1.4 KiB |
74
frontend/src/ui/audio-recorder.ts
Normal file
74
frontend/src/ui/audio-recorder.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class AudioRecorder extends UINode {
|
||||
private audioElement: HTMLAudioElement;
|
||||
private mediaRecorder: MediaRecorder | null;
|
||||
private audioChunks: Blob[];
|
||||
private stream: MediaStream | null;
|
||||
private recording?: Blob;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.audioElement = document.createElement("audio");
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.stream = null;
|
||||
|
||||
this.audioElement.setAttribute("controls", "true");
|
||||
this.audioElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.audioElement);
|
||||
|
||||
this.setRole("audio-recorder");
|
||||
}
|
||||
|
||||
public async startRecording() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
this.audioChunks.push(event.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
this.recording = audioBlob;
|
||||
this.audioChunks = [];
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
this.audioElement.src = audioUrl;
|
||||
this.triggerRecordingComplete(audioUrl);
|
||||
};
|
||||
this.mediaRecorder.start();
|
||||
} catch (error) {
|
||||
console.error("Error accessing microphone:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stopRecording() {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public onRecordingComplete(callback: (audioUrl: string) => void) {
|
||||
this.element.addEventListener("recording-complete", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
callback(customEvent.detail.audioUrl);
|
||||
});
|
||||
}
|
||||
|
||||
protected triggerRecordingComplete(audioUrl: string) {
|
||||
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
|
||||
this.element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
public getRecording() {
|
||||
return this.recording;
|
||||
}
|
||||
}
|
58
frontend/src/ui/audio.ts
Normal file
58
frontend/src/ui/audio.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Audio extends UINode {
|
||||
private audioElement: HTMLAudioElement;
|
||||
|
||||
public constructor(title: string, src: string | MediaStream = "") {
|
||||
super(title);
|
||||
this.audioElement = document.createElement("audio");
|
||||
if (typeof src === "string") {
|
||||
this.audioElement.src = src; // Set src if it's a string URL
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
|
||||
}
|
||||
this.audioElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.audioElement);
|
||||
this.setRole("audio");
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.audioElement;
|
||||
}
|
||||
|
||||
public setSource(src: string | MediaStream) {
|
||||
if (typeof src === "string") {
|
||||
this.audioElement.src = src;
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.audioElement.srcObject = src;
|
||||
}
|
||||
}
|
||||
|
||||
public play() {
|
||||
this.audioElement.play();
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this.audioElement.pause();
|
||||
}
|
||||
|
||||
public setControls(show: boolean) {
|
||||
this.audioElement.controls = show;
|
||||
}
|
||||
|
||||
public setLoop(loop: boolean) {
|
||||
this.audioElement.loop = loop;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean) {
|
||||
this.audioElement.muted = muted;
|
||||
}
|
||||
|
||||
public setAutoplay(autoplay: boolean) {
|
||||
this.audioElement.autoplay = autoplay;
|
||||
}
|
||||
|
||||
public setVolume(volume: number) {
|
||||
this.audioElement.volume = volume;
|
||||
}
|
||||
}
|
35
frontend/src/ui/button.ts
Normal file
35
frontend/src/ui/button.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Button extends UINode {
|
||||
private buttonElement: HTMLButtonElement;
|
||||
public constructor(title: string, hasPopup: boolean = false) {
|
||||
super(title);
|
||||
this.buttonElement = document.createElement("button");
|
||||
this.buttonElement.innerText = title;
|
||||
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
|
||||
this.element.appendChild(this.buttonElement);
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.buttonElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.buttonElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.buttonElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.buttonElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
}
|
||||
|
||||
public setDisabled(val: boolean) {
|
||||
this.buttonElement.disabled = val;
|
||||
}
|
||||
}
|
77
frontend/src/ui/camera.ts
Normal file
77
frontend/src/ui/camera.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Camera extends UINode {
|
||||
private videoElement: HTMLVideoElement;
|
||||
private canvasElement: HTMLCanvasElement;
|
||||
private stream: MediaStream | null;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.videoElement = document.createElement("video");
|
||||
this.canvasElement = document.createElement("canvas");
|
||||
this.stream = null;
|
||||
|
||||
this.videoElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.videoElement);
|
||||
this.element.appendChild(this.canvasElement);
|
||||
|
||||
this.setRole("camera");
|
||||
}
|
||||
|
||||
public async startCamera() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
this.videoElement.srcObject = this.stream;
|
||||
this.videoElement.play();
|
||||
} catch (error) {
|
||||
console.error("Error accessing camera:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.videoElement.pause();
|
||||
this.videoElement.srcObject = null;
|
||||
}
|
||||
|
||||
public takePhoto(): HTMLCanvasElement | null {
|
||||
if (this.stream) {
|
||||
const context = this.canvasElement.getContext("2d");
|
||||
if (context) {
|
||||
this.canvasElement.width = this.videoElement.videoWidth;
|
||||
this.canvasElement.height = this.videoElement.videoHeight;
|
||||
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhoto(): string | null {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
return photoCanvas.toDataURL("image/png");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhotoToBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
photoCanvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
}
|
24
frontend/src/ui/canvas.ts
Normal file
24
frontend/src/ui/canvas.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Canvas extends UINode {
|
||||
private canvasElement: HTMLCanvasElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.canvasElement = document.createElement("canvas");
|
||||
|
||||
this.canvasElement.setAttribute("tabindex", "-1");
|
||||
this.element.appendChild(this.canvasElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.canvasElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.canvasElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
46
frontend/src/ui/checkbox.ts
Normal file
46
frontend/src/ui/checkbox.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Checkbox extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private checkboxElement: HTMLInputElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.id = `chkbx_title_${this.id}`;
|
||||
this.checkboxElement = document.createElement("input");
|
||||
this.checkboxElement.id = `chkbx_${this.id}`;
|
||||
this.checkboxElement.type = "checkbox";
|
||||
this.titleElement.appendChild(this.checkboxElement);
|
||||
this.titleElement.appendChild(document.createTextNode(this.title));
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.checkboxElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.checkboxElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.checkboxElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.element.setAttribute("aria-roledescription", "checkbox");
|
||||
}
|
||||
|
||||
public isChecked(): boolean {
|
||||
return this.checkboxElement.checked;
|
||||
}
|
||||
|
||||
public setChecked(value: boolean) {
|
||||
this.checkboxElement.checked = value;
|
||||
}
|
||||
}
|
40
frontend/src/ui/collapsable-container.ts
Normal file
40
frontend/src/ui/collapsable-container.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Container } from "./container";
|
||||
|
||||
export class CollapsableContainer extends Container {
|
||||
private detailsElement: HTMLDetailsElement;
|
||||
private summaryElement: HTMLElement;
|
||||
private wrapperElement: HTMLDivElement;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.wrapperElement = document.createElement("div");
|
||||
this.detailsElement = document.createElement("details");
|
||||
this.summaryElement = document.createElement("summary");
|
||||
|
||||
this.summaryElement.innerText = title;
|
||||
this.detailsElement.appendChild(this.summaryElement);
|
||||
this.detailsElement.appendChild(this.containerElement);
|
||||
this.wrapperElement.appendChild(this.detailsElement);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.wrapperElement;
|
||||
}
|
||||
|
||||
public setTitle(text: string): void {
|
||||
this.title = text;
|
||||
this.summaryElement.innerText = text;
|
||||
}
|
||||
|
||||
public isCollapsed(): boolean {
|
||||
return this.detailsElement.hasAttribute("open");
|
||||
}
|
||||
|
||||
public expand(val: boolean) {
|
||||
if (val) {
|
||||
this.detailsElement.setAttribute("open", "true");
|
||||
} else {
|
||||
this.detailsElement.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
}
|
51
frontend/src/ui/container.ts
Normal file
51
frontend/src/ui/container.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Container extends UINode {
|
||||
public children: UINode[];
|
||||
protected containerElement: HTMLDivElement;
|
||||
private focused: number = 0;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.children = [];
|
||||
this.containerElement = document.createElement("div");
|
||||
this.containerElement.setAttribute("tabindex", "-1");
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.containerElement.focus();
|
||||
}
|
||||
|
||||
public _onFocus() {
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
|
||||
public add(node: UINode) {
|
||||
this.children.push(node);
|
||||
node._onConnect();
|
||||
this.containerElement.appendChild(node.render());
|
||||
}
|
||||
|
||||
public remove(node: UINode) {
|
||||
this.children.splice(this.children.indexOf(node), 1);
|
||||
node._onDisconnect();
|
||||
this.containerElement.removeChild(node.render());
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.containerElement;
|
||||
}
|
||||
|
||||
public getChildren(): UINode[] {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
public getElement() {
|
||||
return this.containerElement;
|
||||
}
|
||||
|
||||
public setAriaLabel(text: string): void {
|
||||
this.containerElement.setAttribute("aria-label", text);
|
||||
}
|
||||
}
|
41
frontend/src/ui/date-picker.ts
Normal file
41
frontend/src/ui/date-picker.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
|
||||
export class DatePicker extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private inputElement: HTMLInputElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `datepicker_title_${this.id}`;
|
||||
this.inputElement = document.createElement("input");
|
||||
this.inputElement.id = `datepicker_${this.id}`;
|
||||
this.inputElement.type = "date";
|
||||
this.titleElement.appendChild(this.inputElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.inputElement.focus();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.inputElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.inputElement.value;
|
||||
}
|
||||
|
||||
public setValue(value: string) {
|
||||
this.inputElement.value = value;
|
||||
}
|
||||
}
|
76
frontend/src/ui/dialog.ts
Normal file
76
frontend/src/ui/dialog.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { UIWindow } from "./window";
|
||||
import { Button } from "./button";
|
||||
|
||||
export class Dialog<T> extends UIWindow {
|
||||
private resolvePromise!: (value: T | PromiseLike<T>) => void;
|
||||
private rejectPromise!: (reason?: any) => void;
|
||||
private promise: Promise<T>;
|
||||
private dialogElement!: HTMLDialogElement;
|
||||
private okButton?: Button;
|
||||
private cancelButton?: Button;
|
||||
|
||||
private previouslyFocusedElement!: HTMLElement;
|
||||
|
||||
public constructor(title: string, addButtons: boolean = true) {
|
||||
super(title, "dialog", false);
|
||||
this.dialogElement = document.createElement("dialog");
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
this.resolvePromise = resolve;
|
||||
this.rejectPromise = reject;
|
||||
});
|
||||
|
||||
// Automatically add OK and Cancel buttons
|
||||
if (addButtons) {
|
||||
this.okButton = new Button("OK");
|
||||
this.okButton.setPosition(70, 90, 10, 5);
|
||||
this.okButton.onClick(() => this.choose(undefined));
|
||||
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(20, 90, 10, 5);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
}
|
||||
}
|
||||
|
||||
public setOkAction(action: () => T): void {
|
||||
if (!this.okButton) return;
|
||||
this.okButton.onClick(() => {
|
||||
const result = action();
|
||||
this.choose(result);
|
||||
});
|
||||
}
|
||||
|
||||
public setCancelAction(action: () => void): void {
|
||||
if (!this.cancelButton) return;
|
||||
this.cancelButton.onClick(() => {
|
||||
action();
|
||||
this.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
public choose(item: T | undefined) {
|
||||
this.resolvePromise(item as T);
|
||||
document.body.removeChild(this.dialogElement);
|
||||
this.hide();
|
||||
this.previouslyFocusedElement.focus();
|
||||
}
|
||||
|
||||
public cancel(reason?: any) {
|
||||
this.rejectPromise(reason);
|
||||
|
||||
document.body.removeChild(this.dialogElement);
|
||||
this.hide();
|
||||
this.previouslyFocusedElement.focus();
|
||||
}
|
||||
|
||||
public open(): Promise<T> {
|
||||
this.previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||
this.dialogElement.appendChild(this.show()!);
|
||||
if (this.okButton) this.add(this.okButton);
|
||||
if (this.cancelButton) this.add(this.cancelButton);
|
||||
document.body.appendChild(this.dialogElement);
|
||||
this.dialogElement.showModal();
|
||||
this.container.focus();
|
||||
|
||||
return this.promise;
|
||||
}
|
||||
}
|
70
frontend/src/ui/dropdown.ts
Normal file
70
frontend/src/ui/dropdown.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Dropdown extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private selectElement: HTMLSelectElement;
|
||||
|
||||
public constructor(title: string, options: { key: string; value: string }[]) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `dd_title_${this.id}`;
|
||||
this.selectElement = document.createElement("select");
|
||||
this.selectElement.id = `dd_${this.id}`;
|
||||
this.titleElement.appendChild(this.selectElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.selectElement.focus();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.selectElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getSelectedValue(): string {
|
||||
return this.selectElement.value;
|
||||
}
|
||||
|
||||
public setSelectedValue(value: string) {
|
||||
this.selectElement.value = value;
|
||||
}
|
||||
|
||||
public setOptions(options: { key: string; value: string }[]) {
|
||||
this.clearOptions();
|
||||
options.forEach((option) => {
|
||||
this.addOption(option.key, option.value);
|
||||
});
|
||||
}
|
||||
|
||||
public addOption(key: string, value: string) {
|
||||
const optionElement = document.createElement("option");
|
||||
optionElement.value = key;
|
||||
optionElement.innerText = value;
|
||||
this.selectElement.appendChild(optionElement);
|
||||
}
|
||||
|
||||
public removeOption(key: string) {
|
||||
const options = Array.from(this.selectElement.options);
|
||||
const optionToRemove = options.find(option => option.value === key);
|
||||
if (optionToRemove) {
|
||||
this.selectElement.removeChild(optionToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public clearOptions() {
|
||||
while (this.selectElement.firstChild) {
|
||||
this.selectElement.removeChild(this.selectElement.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
44
frontend/src/ui/file-input.ts
Normal file
44
frontend/src/ui/file-input.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class FileInput extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private inputElement: HTMLInputElement;
|
||||
public constructor(title: string, multiple: boolean = false) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `fileinpt_title_${this.id}`;
|
||||
this.inputElement = document.createElement("input");
|
||||
this.inputElement.id = `fileinpt_${this.id}`;
|
||||
this.inputElement.type = "file";
|
||||
if (multiple) {
|
||||
this.inputElement.multiple = true;
|
||||
}
|
||||
this.titleElement.appendChild(this.inputElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.inputElement.focus();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.inputElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getFiles(): FileList | null {
|
||||
return this.inputElement.files;
|
||||
}
|
||||
|
||||
public setAccept(accept: string) {
|
||||
this.inputElement.accept = accept;
|
||||
}
|
||||
}
|
30
frontend/src/ui/image.ts
Normal file
30
frontend/src/ui/image.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Image extends UINode {
|
||||
private imgElement: HTMLImageElement;
|
||||
public constructor(title: string, src: string, altText: string = "") {
|
||||
super(title);
|
||||
this.imgElement = document.createElement("img");
|
||||
this.imgElement.src = src;
|
||||
this.imgElement.alt = altText;
|
||||
this.element.appendChild(this.imgElement);
|
||||
this.element.setAttribute("aria-label", title);
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.imgElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.element.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
public setSource(src: string) {
|
||||
this.imgElement.src = src;
|
||||
}
|
||||
|
||||
public setAltText(altText: string) {
|
||||
this.imgElement.alt = altText;
|
||||
}
|
||||
}
|
12
frontend/src/ui/index.ts
Normal file
12
frontend/src/ui/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { UIWindow } from "./window";
|
||||
export { Button } from "./button";
|
||||
export { Container } from "./container";
|
||||
export { UINode } from "./node";
|
||||
export { List } from "./list";
|
||||
export { Text } from "./text";
|
||||
export { ListItem } from "./list-item";
|
||||
export { Checkbox } from "./checkbox";
|
||||
export { TextInput } from "./text-input";
|
||||
export { TabBar } from "./tab-bar";
|
||||
export { TabbedView } from "./tabbed-view";
|
||||
export { Canvas } from "./canvas";
|
34
frontend/src/ui/list-item.ts
Normal file
34
frontend/src/ui/list-item.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class ListItem extends UINode {
|
||||
private listElement: HTMLLIElement;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.listElement = document.createElement("li");
|
||||
this.listElement.innerText = this.title;
|
||||
this.listElement.setAttribute("tabindex", "-1");
|
||||
this.element.appendChild(this.listElement);
|
||||
this.listElement.setAttribute("aria-label", this.title);
|
||||
this.listElement.setAttribute("role", "option");
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.listElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.listElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.listElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.listElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.listElement.setAttribute("aria-label", this.title);
|
||||
}
|
||||
}
|
164
frontend/src/ui/list.ts
Normal file
164
frontend/src/ui/list.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
|
||||
export class List extends UINode {
|
||||
public children: UINode[];
|
||||
protected listElement: HTMLUListElement;
|
||||
private focused: number;
|
||||
protected selectCallback?: (id: number) => void;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.children = [];
|
||||
this.listElement = document.createElement("ul");
|
||||
this.listElement.setAttribute("role", "listbox");
|
||||
this.listElement.style.listStyle = "none";
|
||||
this.element.appendChild(this.listElement);
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public add(node: UINode) {
|
||||
this.children.push(node);
|
||||
node._onConnect();
|
||||
this.listElement.appendChild(node.render());
|
||||
if (this.children.length === 1) this.calculateTabIndex();
|
||||
node.onFocus(() => this.calculateFocused(node));
|
||||
}
|
||||
|
||||
public addNodeAtIndex(node: UINode, index: number) {
|
||||
index = Math.max(0, Math.min(index, this.children.length));
|
||||
this.children.splice(index, 0, node);
|
||||
node._onConnect();
|
||||
this.listElement.insertBefore(node.render(), this.listElement.children[index]);
|
||||
if (this.children.length === 1) this.calculateTabIndex();
|
||||
node.onFocus(() => this.calculateFocused(node));
|
||||
}
|
||||
|
||||
public remove(node: UINode) {
|
||||
const idx = this.children.indexOf(node);
|
||||
this.children.splice(idx, 1);
|
||||
node._onDisconnect();
|
||||
this.listElement.removeChild(node.render());
|
||||
if (idx === this.focused) {
|
||||
if (this.focused > 0) this.focused--;
|
||||
this.calculateTabIndex();
|
||||
}
|
||||
}
|
||||
|
||||
public _onFocus() {
|
||||
super._onFocus();
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
|
||||
public _onClick() {
|
||||
// this.children[this.focused]._onClick();
|
||||
}
|
||||
|
||||
public _onSelect(id: number) {
|
||||
if (this.selectCallback) this.selectCallback(id);
|
||||
}
|
||||
|
||||
protected calculateStyle(): void {
|
||||
super.calculateStyle();
|
||||
this.element.style.overflowY = "scroll";
|
||||
this.listElement.style.overflowY = "scroll";
|
||||
}
|
||||
|
||||
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = Math.max(0, this.focused - 1);
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
break;
|
||||
case "ArrowDown":
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = Math.min(this.children.length - 1, this.focused + 1);
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
break;
|
||||
case "Enter":
|
||||
this.children[this.focused].click();
|
||||
return true;
|
||||
break;
|
||||
case "Home":
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = 0;
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
break;
|
||||
case "End":
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = this.children.length - 1;
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
break;
|
||||
default:
|
||||
return this.children[this.focused]._onKeydown(key);
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected renderAsListItem(node: UINode) {
|
||||
let li = document.createElement("li");
|
||||
li.appendChild(node.render());
|
||||
return li;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.listElement;
|
||||
}
|
||||
|
||||
public isItemFocused(): boolean {
|
||||
const has = this.children.find((child) => child.isFocused);
|
||||
if (has) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private calculateTabIndex() {
|
||||
if (this.children.length < 1) return;
|
||||
this.children[this.focused].setTabbable(true);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.children.forEach((child) => this.remove(child));
|
||||
this.children = [];
|
||||
this.listElement.innerHTML = '';
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public getFocusedChild() {
|
||||
return this.children[this.focused];
|
||||
}
|
||||
|
||||
public getFocus() {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
public onSelect(f: (id: number) => void) {
|
||||
this.selectCallback = f;
|
||||
}
|
||||
|
||||
protected calculateFocused(node: UINode) {
|
||||
const idx = this.children.indexOf(node);
|
||||
this._onSelect(idx);
|
||||
this.focused = idx;
|
||||
}
|
||||
|
||||
public scrollToBottom() {
|
||||
this.children.forEach((child) => child.setTabbable(false));
|
||||
const node = this.children[this.children.length - 1];
|
||||
node.getElement().scrollIntoView();
|
||||
// set the focused element for tab index without focusing directly.
|
||||
this.focused = this.children.length - 1;
|
||||
this.children[this.focused].setTabbable(true);
|
||||
}
|
||||
}
|
43
frontend/src/ui/multiline-input.ts
Normal file
43
frontend/src/ui/multiline-input.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class MultilineInput extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private textareaElement: HTMLTextAreaElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `txtarea_title_${this.id}`;
|
||||
this.textareaElement = document.createElement("textarea");
|
||||
this.textareaElement.id = `txtarea_${this.id}`;
|
||||
this.titleElement.appendChild(this.textareaElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.textareaElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.textareaElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.textareaElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.textareaElement.value;
|
||||
}
|
||||
|
||||
public setValue(value: string) {
|
||||
this.textareaElement.value = value;
|
||||
}
|
||||
}
|
155
frontend/src/ui/node.ts
Normal file
155
frontend/src/ui/node.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { UITab } from "./tab";
|
||||
|
||||
export class UINode {
|
||||
protected title: string;
|
||||
protected element: HTMLDivElement;
|
||||
protected position!: {x: number, y: number, width: number, height: number};
|
||||
protected positionType: string = "fixed";
|
||||
protected calculateOwnStyle: boolean = true;
|
||||
protected keyDownCallback!: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void | undefined;
|
||||
protected focusCallback?: () => void;
|
||||
protected blurCallback?: () => void;
|
||||
protected clickCallback?: () => void;
|
||||
protected globalKeydown: boolean = false;
|
||||
protected visible: boolean;
|
||||
public isFocused: boolean;
|
||||
private userdata: any;
|
||||
|
||||
public constructor(title: string) {
|
||||
this.title = title;
|
||||
this.element = document.createElement("div");
|
||||
this.element.setAttribute("tabindex", "-1");
|
||||
this.visible = false;
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.element.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.element.click();
|
||||
}
|
||||
|
||||
public _onConnect() {
|
||||
this.calculateStyle();
|
||||
this.addListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
public _onDisconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
public _onFocus() {
|
||||
if (this.focusCallback) this.focusCallback();
|
||||
this.isFocused = true;
|
||||
return;
|
||||
}
|
||||
|
||||
public _onBlur() {
|
||||
if (this.blurCallback) this.blurCallback();
|
||||
this.isFocused = false;
|
||||
return;
|
||||
}
|
||||
|
||||
public _onClick() {
|
||||
if (this.clickCallback) this.clickCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
|
||||
if (this.keyDownCallback) {
|
||||
if (this.globalKeydown || (!this.globalKeydown && document.activeElement === this.getElement())) {
|
||||
this.keyDownCallback(key, alt, shift, ctrl);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public render(): HTMLElement {
|
||||
this.visible = true;
|
||||
return this.element;
|
||||
}
|
||||
|
||||
protected addListeners() {
|
||||
const elem = this.element;
|
||||
this.getElement().addEventListener("focus", (e) => this._onFocus());
|
||||
elem.addEventListener("blur", (e) => this._onBlur());
|
||||
elem.addEventListener("click", (e) => this._onClick());
|
||||
elem.addEventListener("keydown", e => this._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey));
|
||||
}
|
||||
|
||||
protected calculateStyle() {
|
||||
if (!this.calculateOwnStyle || !this.position) return;
|
||||
this.element.style.position = this.positionType;
|
||||
this.element.style.left = `${this.position.x}%`;
|
||||
this.element.style.top = `${this.position.y}%`;
|
||||
this.element.style.width = `${this.position.width}%`;
|
||||
this.element.style.height = `${this.position.height}%`;
|
||||
}
|
||||
|
||||
public setPosition(x: number, y: number, width: number, height: number, type: string = "fixed") {
|
||||
this.position = {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
};
|
||||
this.positionType = type;
|
||||
this.calculateOwnStyle = true;
|
||||
this.calculateStyle();
|
||||
}
|
||||
|
||||
public onClick(f: () => void) {
|
||||
this.clickCallback = f;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onFocus(f: () => void) {
|
||||
this.focusCallback = f;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onKeyDown(f: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void, global: boolean = false) {
|
||||
this.keyDownCallback = f;
|
||||
this.globalKeydown = global;
|
||||
return this;
|
||||
}
|
||||
|
||||
public onBlur(f: () => void) {
|
||||
this.blurCallback = f;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public setTabbable(val: boolean) {
|
||||
this.getElement().setAttribute("tabindex",
|
||||
(val === true) ? "0" :
|
||||
"-1");
|
||||
}
|
||||
|
||||
public setAriaLabel(text: string) {
|
||||
this.element.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
public setRole(role: string) {
|
||||
this.getElement().setAttribute("role", role);
|
||||
}
|
||||
|
||||
public getUserData(): any {
|
||||
return this.userdata;
|
||||
}
|
||||
|
||||
public setUserData(obj: any) {
|
||||
this.userdata = obj;
|
||||
}
|
||||
|
||||
public setAccessKey(key: string) {
|
||||
this.getElement().accessKey = key;
|
||||
}
|
||||
}
|
37
frontend/src/ui/progress-bar.ts
Normal file
37
frontend/src/ui/progress-bar.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class ProgressBar extends UINode {
|
||||
private progressElement: HTMLProgressElement;
|
||||
public constructor(title: string, max: number) {
|
||||
super(title);
|
||||
this.progressElement = document.createElement("progress");
|
||||
this.progressElement.max = max;
|
||||
this.element.appendChild(this.progressElement);
|
||||
this.element.setAttribute("aria-label", title);
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.progressElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.element.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.progressElement.value;
|
||||
}
|
||||
|
||||
public setValue(value: number) {
|
||||
this.progressElement.value = value;
|
||||
}
|
||||
|
||||
public getMax(): number {
|
||||
return this.progressElement.max;
|
||||
}
|
||||
|
||||
public setMax(max: number) {
|
||||
this.progressElement.max = max;
|
||||
}
|
||||
}
|
76
frontend/src/ui/radio-group.ts
Normal file
76
frontend/src/ui/radio-group.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class RadioGroup extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLegendElement;
|
||||
private containerElement: HTMLFieldSetElement;
|
||||
private radioElements: Map<string, HTMLInputElement>;
|
||||
private radioLabels: Map<string, HTMLLabelElement>;
|
||||
|
||||
public constructor(title: string, options: { key: string; value: string }[]) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("legend");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `rdgrp_title_${this.id}`;
|
||||
this.containerElement = document.createElement("fieldset");
|
||||
this.containerElement.appendChild(this.titleElement);
|
||||
this.element.appendChild(this.containerElement);
|
||||
|
||||
this.radioElements = new Map();
|
||||
this.radioLabels = new Map();
|
||||
|
||||
options.forEach((option) => {
|
||||
const radioId = `rd_${this.id}_${option.key}`;
|
||||
const radioElement = document.createElement("input");
|
||||
radioElement.id = radioId;
|
||||
radioElement.type = "radio";
|
||||
radioElement.name = `rdgrp_${this.id}`;
|
||||
radioElement.value = option.key;
|
||||
radioElement.setAttribute("aria-labeledby", `${radioId}_label`);
|
||||
|
||||
const radioLabel = document.createElement("label");
|
||||
radioLabel.innerText = option.value;
|
||||
radioLabel.id = `${radioId}_label`;
|
||||
radioLabel.setAttribute("for", radioId);
|
||||
|
||||
this.radioElements.set(option.key, radioElement);
|
||||
this.radioLabels.set(option.key, radioLabel);
|
||||
|
||||
this.containerElement.appendChild(radioElement);
|
||||
this.containerElement.appendChild(radioLabel);
|
||||
});
|
||||
}
|
||||
|
||||
public focus() {
|
||||
const firstRadioElement = this.radioElements.values().next().value;
|
||||
if (firstRadioElement) {
|
||||
firstRadioElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.containerElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getSelectedValue(): string | null {
|
||||
for (const [key, radioElement] of this.radioElements.entries()) {
|
||||
if (radioElement.checked) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public setSelectedValue(value: string) {
|
||||
const radioElement = this.radioElements.get(value);
|
||||
if (radioElement) {
|
||||
radioElement.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
47
frontend/src/ui/slider.ts
Normal file
47
frontend/src/ui/slider.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Slider extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private sliderElement: HTMLInputElement;
|
||||
public constructor(title: string, min: number, max: number, step: number = 1) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `sldr_title_${this.id}`;
|
||||
this.sliderElement = document.createElement("input");
|
||||
this.sliderElement.id = `sldr_${this.id}`;
|
||||
this.sliderElement.type = "range";
|
||||
this.sliderElement.min = min.toString();
|
||||
this.sliderElement.max = max.toString();
|
||||
this.sliderElement.step = step.toString();
|
||||
this.titleElement.appendChild(this.sliderElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.sliderElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.sliderElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.sliderElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return parseInt(this.sliderElement.value);
|
||||
}
|
||||
|
||||
public setValue(value: number) {
|
||||
this.sliderElement.value = value.toString();
|
||||
}
|
||||
}
|
97
frontend/src/ui/tab-bar.ts
Normal file
97
frontend/src/ui/tab-bar.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { UINode } from "./node";
|
||||
import { UITab } from "./tab";
|
||||
|
||||
export class TabBar extends UINode {
|
||||
private tabs: UITab[];
|
||||
private tabBarContainer: HTMLDivElement;
|
||||
private onTabChangeCallback?: (index: number) => void;
|
||||
private focused: number;
|
||||
|
||||
public constructor(title: string = "tab bar") {
|
||||
super(title);
|
||||
this.tabs = [];
|
||||
this.tabBarContainer = document.createElement("div");
|
||||
this.tabBarContainer.setAttribute("role", "tablist");
|
||||
this.tabBarContainer.style.display = "flex";
|
||||
this.tabBarContainer.style.alignItems = "center";
|
||||
// this.tabBarContainer.style.justifyContent = "space-between";
|
||||
this.tabBarContainer.style.overflow = "hidden";
|
||||
|
||||
this.element.appendChild(this.tabBarContainer);
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public _onFocus() {
|
||||
this.tabs[this.focused].focus();
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.tabs[this.focused].focus();
|
||||
}
|
||||
|
||||
public add(title: string) {
|
||||
const idx = this.tabs.length;
|
||||
const elem = new UITab(title);
|
||||
elem.onClick(() => {
|
||||
this.selectTab(idx);
|
||||
});
|
||||
this.tabs.push(elem);
|
||||
this.tabBarContainer.appendChild(elem.render());
|
||||
elem._onConnect();
|
||||
if (this.tabs.length === 1) this.calculateTabIndex();
|
||||
}
|
||||
|
||||
public onTabChange(f: (index: number) => void) {
|
||||
this.onTabChangeCallback = f;
|
||||
}
|
||||
|
||||
private selectTab(idx: number) {
|
||||
if (idx !== this.focused) {
|
||||
this.tabs[this.focused].setTabbable(false);
|
||||
this.focused = idx;
|
||||
}
|
||||
if (!this.onTabChangeCallback) return;
|
||||
this.onTabChangeCallback(idx);
|
||||
this.tabs[idx].setTabbable(true);
|
||||
this.tabs[idx].focus();
|
||||
this.updateView();
|
||||
}
|
||||
|
||||
public _onKeydown(key: string): boolean {
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
this.tabs[this.focused].setTabbable(false);
|
||||
this.focused = Math.max(0, this.focused - 1);
|
||||
this.tabs[this.focused].setTabbable(true);
|
||||
this.selectTab(this.focused);
|
||||
return true;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.tabs[this.focused].setTabbable(false);
|
||||
this.focused = Math.min(this.tabs.length - 1, this.focused + 1);
|
||||
this.tabs[this.focused].setTabbable(true);
|
||||
this.selectTab(this.focused);
|
||||
return true;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private updateView() {
|
||||
for (let i = 0; i < this.tabs.length; i++) {
|
||||
this.tabs[i].setSelected(i === this.focused);
|
||||
}
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
|
||||
public calculateTabIndex() {
|
||||
this.tabs[this.focused].setTabbable(true);
|
||||
}
|
||||
}
|
40
frontend/src/ui/tab.ts
Normal file
40
frontend/src/ui/tab.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class UITab extends UINode {
|
||||
private textElement: HTMLButtonElement;
|
||||
private selected: boolean;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.title = title;
|
||||
this.textElement = document.createElement("button");
|
||||
this.textElement.innerText = title;
|
||||
this.textElement.setAttribute("tabindex", "-1");
|
||||
this.textElement.setAttribute("role", "tab");
|
||||
this.textElement.setAttribute("aria-selected", "false");
|
||||
this.element.appendChild(this.textElement);
|
||||
this.selected = false;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.textElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.textElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.textElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.textElement.innerText = text;
|
||||
}
|
||||
|
||||
public setSelected(val: boolean) {
|
||||
this.selected = val;
|
||||
this.textElement.setAttribute("aria-selected", this.selected.toString());
|
||||
}
|
||||
}
|
50
frontend/src/ui/tabbed-view.ts
Normal file
50
frontend/src/ui/tabbed-view.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { UINode } from "./node";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { Container } from "./container";
|
||||
|
||||
|
||||
export class TabbedView extends UINode {
|
||||
private bar: TabBar;
|
||||
private containers: Container[];
|
||||
private containerElement: HTMLDivElement;
|
||||
private barAtTop: boolean;
|
||||
private currentView?: Container;
|
||||
public constructor(title: string, barAtTop: boolean = true) {
|
||||
super(title);
|
||||
this.bar = new TabBar();
|
||||
this.bar._onConnect();
|
||||
this.bar.onTabChange((index: number) => this.onTabChanged(index));
|
||||
this.containers = [];
|
||||
this.containerElement = document.createElement("div");
|
||||
this.element.appendChild(this.bar.render());
|
||||
this.element.appendChild(this.containerElement);
|
||||
this.element.setAttribute("tabindex", "-1");
|
||||
this.barAtTop = barAtTop;
|
||||
}
|
||||
|
||||
public add(name: string, container: Container) {
|
||||
this.bar.add(name);
|
||||
container.setRole("tabpanel");
|
||||
this.containers.push(container);
|
||||
}
|
||||
|
||||
private onTabChanged(idx: number) {
|
||||
if (this.currentView) {
|
||||
this.containerElement.removeChild(this.currentView.render());
|
||||
}
|
||||
this.currentView = this.containers[idx];
|
||||
this.containerElement.appendChild(this.currentView.render());
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.containerElement;
|
||||
}
|
||||
|
||||
protected calculateStyle(): void {
|
||||
if (this.barAtTop) {
|
||||
this.bar.setPosition(0, 0, 100, 5);
|
||||
} else {
|
||||
this.bar.setPosition(0, 90, 100, 5);
|
||||
}
|
||||
}
|
||||
}
|
44
frontend/src/ui/text-input.ts
Normal file
44
frontend/src/ui/text-input.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class TextInput extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private inputElement: HTMLInputElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `inpt_title_${this.id}`;
|
||||
this.inputElement = document.createElement("input");
|
||||
this.inputElement.id = `inpt_${this.id}`;
|
||||
this.inputElement.type = "text";
|
||||
this.titleElement.appendChild(this.inputElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.inputElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.inputElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.inputElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.inputElement.value;
|
||||
}
|
||||
|
||||
public setValue(value: string) {
|
||||
this.inputElement.value = value;
|
||||
}
|
||||
}
|
29
frontend/src/ui/text.ts
Normal file
29
frontend/src/ui/text.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Text extends UINode {
|
||||
private textElement: HTMLSpanElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.textElement = document.createElement("span");
|
||||
this.textElement.innerText = title;
|
||||
this.textElement.setAttribute("tabindex", "-1");
|
||||
this.element.appendChild(this.textElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.textElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.textElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.textElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.textElement.innerText = text;
|
||||
}
|
||||
}
|
40
frontend/src/ui/time-picker.ts
Normal file
40
frontend/src/ui/time-picker.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class TimePicker extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private inputElement: HTMLInputElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.innerText = title;
|
||||
this.titleElement.id = `timepicker_title_${this.id}`;
|
||||
this.inputElement = document.createElement("input");
|
||||
this.inputElement.id = `timepicker_${this.id}`;
|
||||
this.inputElement.type = "time";
|
||||
this.titleElement.appendChild(this.inputElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.inputElement.focus();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.inputElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.inputElement.value;
|
||||
}
|
||||
|
||||
public setValue(value: string) {
|
||||
this.inputElement.value = value;
|
||||
}
|
||||
}
|
0
frontend/src/ui/treelist-item.ts
Normal file
0
frontend/src/ui/treelist-item.ts
Normal file
0
frontend/src/ui/treelist.ts
Normal file
0
frontend/src/ui/treelist.ts
Normal file
198
frontend/src/ui/treeview-item.ts
Normal file
198
frontend/src/ui/treeview-item.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { UINode } from "./node";
|
||||
import { Treeview } from "./treeview";
|
||||
|
||||
export class TreeviewItem extends UINode {
|
||||
private listElement: HTMLLIElement;
|
||||
private childContainer!: HTMLUListElement;
|
||||
|
||||
public children: TreeviewItem[];
|
||||
|
||||
private expanded!: boolean;
|
||||
|
||||
private focused: number;
|
||||
|
||||
private parent?: TreeviewItem;
|
||||
|
||||
private root!: Treeview;
|
||||
|
||||
private previousItem?: TreeviewItem;
|
||||
private nextItem?: TreeviewItem;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.listElement = document.createElement("li");
|
||||
this.listElement.innerText = this.title;
|
||||
this.listElement.setAttribute("tabindex", "-1");
|
||||
this.listElement.setAttribute("role", "treeitem");
|
||||
this.element.appendChild(this.listElement);
|
||||
this.listElement.setAttribute("aria-label", this.title);
|
||||
this.children = [];
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.listElement.focus();
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.listElement.click();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.listElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.listElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.listElement.setAttribute("aria-label", this.title);
|
||||
}
|
||||
|
||||
public add(node: TreeviewItem) {
|
||||
this.children.push(node);
|
||||
node.setParent(this);
|
||||
this.setExpanded(false);
|
||||
if (this.children.length > 0) {
|
||||
this.previousItem = this.children[this.children.length - 1];
|
||||
this.previousItem.setNextItem(node);
|
||||
}
|
||||
}
|
||||
|
||||
public remove(node: TreeviewItem) {
|
||||
const idx = this.children.indexOf(node);
|
||||
if (idx > -1) {
|
||||
this.children.splice(idx, 1);
|
||||
this.updateShownItems();
|
||||
}
|
||||
}
|
||||
|
||||
public expand() {
|
||||
if (!this.isExpandable()) return;
|
||||
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
|
||||
this.children[this.focused].expand();
|
||||
return;
|
||||
}
|
||||
this.setExpanded(true);
|
||||
this.updateShownItems();
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
|
||||
public collapse(ignoreFocus: boolean = false) {
|
||||
if (!this.isExpandable()) {
|
||||
if (this.getElement() !== document.activeElement && ignoreFocus) return;
|
||||
this.parent?.collapse(true);
|
||||
return;
|
||||
}
|
||||
this.setExpanded(false);
|
||||
this.updateShownItems();
|
||||
setTimeout(() => this.focus(), 0);
|
||||
}
|
||||
|
||||
public isExpandable(): boolean {
|
||||
return this.children.length > 0;
|
||||
}
|
||||
|
||||
public isExpanded() {
|
||||
return this.expanded;
|
||||
}
|
||||
|
||||
private setExpanded(val: boolean) {
|
||||
this.expanded = val;
|
||||
if (this.expanded) {
|
||||
this.listElement.setAttribute("aria-expanded", "true");
|
||||
return;
|
||||
}
|
||||
this.listElement.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
private updateShownItems() {
|
||||
if (this.expanded) {
|
||||
if (!this.childContainer) {
|
||||
this.childContainer = document.createElement("ul");
|
||||
this.childContainer.setAttribute("role", "group");
|
||||
this.children.forEach((child) => this.childContainer.appendChild(child.render()));
|
||||
this.listElement.appendChild(this.childContainer);
|
||||
} else {
|
||||
this.childContainer.hidden = false;
|
||||
}
|
||||
} else {
|
||||
this.childContainer.hidden = true;
|
||||
// this.listElement.removeChild(this.childContainer);
|
||||
}
|
||||
}
|
||||
|
||||
public focusNext(): boolean {
|
||||
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
|
||||
return this.children[this.focused].focusNext();
|
||||
}
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = Math.min(this.children.length - 1, this.focused + 1);
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
public focusPrevious(): boolean {
|
||||
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
|
||||
return this.children[this.focused].focusPrevious();
|
||||
}
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = Math.max(0, this.focused - 1);
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.children[this.focused].focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
public setParent(item: TreeviewItem) {
|
||||
this.parent = this.parent;
|
||||
}
|
||||
|
||||
public getParent(): TreeviewItem|undefined {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
public setPrevious(node: TreeviewItem) {
|
||||
this.previousItem = node;
|
||||
}
|
||||
|
||||
public setNextItem(node: TreeviewItem) {
|
||||
this.nextItem = node;
|
||||
}
|
||||
|
||||
public getPrevious(): TreeviewItem|undefined {
|
||||
return this.previousItem;
|
||||
}
|
||||
|
||||
public getNext(): TreeviewItem|undefined {
|
||||
return this.nextItem;
|
||||
}
|
||||
|
||||
public _onKeydown(key: string, alt?: boolean, shift?: boolean, ctrl?: boolean): boolean {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
this.focusPrevious();
|
||||
return true;
|
||||
break;
|
||||
case "ArrowDown":
|
||||
this.focusNext();
|
||||
return true;
|
||||
case "ArrowLeft":
|
||||
this.collapse();
|
||||
return true;
|
||||
case "ArrowRight":
|
||||
if (this.children[this.focused].isExpandable() && !this.children[this.focused].isExpanded()) {
|
||||
this.children[this.focused].expand();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public focusOnItem() {
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
}
|
151
frontend/src/ui/treeview.ts
Normal file
151
frontend/src/ui/treeview.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { UINode } from "./node";
|
||||
import { TreeviewItem } from "./treeview-item";
|
||||
|
||||
export class Treeview extends UINode {
|
||||
public children: TreeviewItem[];
|
||||
protected listElement: HTMLUListElement;
|
||||
private focused: number;
|
||||
protected selectCallback?: (id: number) => void;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.children = [];
|
||||
this.listElement = document.createElement("ul");
|
||||
this.listElement.setAttribute("role", "tree");
|
||||
this.listElement.style.listStyle = "none";
|
||||
this.element.appendChild(this.listElement);
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public add(node: TreeviewItem) {
|
||||
this.children.push(node);
|
||||
node._onConnect();
|
||||
this.listElement.appendChild(node.render());
|
||||
if (this.children.length === 1) this.calculateTabIndex();
|
||||
node.onFocus(() => this.calculateFocused(node));
|
||||
}
|
||||
|
||||
public remove(node: TreeviewItem) {
|
||||
const idx = this.children.indexOf(node);
|
||||
this.children.splice(idx, 1);
|
||||
node._onDisconnect();
|
||||
this.listElement.removeChild(node.render());
|
||||
if (idx === this.focused) {
|
||||
if (this.focused > 0) this.focused--;
|
||||
this.calculateTabIndex();
|
||||
}
|
||||
}
|
||||
|
||||
public _onFocus() {
|
||||
super._onFocus();
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
|
||||
public _onClick() {
|
||||
this.children[this.focused]._onClick();
|
||||
}
|
||||
|
||||
public _onSelect(id: number) {
|
||||
if (this.selectCallback) this.selectCallback(id);
|
||||
}
|
||||
|
||||
protected calculateStyle(): void {
|
||||
super.calculateStyle();
|
||||
this.element.style.overflowY = "scroll";
|
||||
this.listElement.style.overflowY = "scroll";
|
||||
}
|
||||
|
||||
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
return this.focusPrevious();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
return this.focusNext();
|
||||
break;
|
||||
case "Enter":
|
||||
this.children[this.focused].click();
|
||||
return true;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
// this.children[this.focused].collapse();
|
||||
return true;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.children[this.focused].expand();
|
||||
return true;
|
||||
break;
|
||||
default:
|
||||
return this.children[this.focused]._onKeydown(key);
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected renderAsListItem(node: UINode) {
|
||||
let li = document.createElement("li");
|
||||
li.appendChild(node.render());
|
||||
return li;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.listElement;
|
||||
}
|
||||
|
||||
public isItemFocused(): boolean {
|
||||
const has = this.children.find((child) => child.isFocused);
|
||||
if (has) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private calculateTabIndex() {
|
||||
this.children[this.focused].setTabbable(true);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.children.forEach((child) => this.remove(child));
|
||||
this.children = [];
|
||||
this.listElement.innerHTML = '';
|
||||
this.focused = 0;
|
||||
}
|
||||
|
||||
public getFocusedChild() {
|
||||
return this.children[this.focused];
|
||||
}
|
||||
|
||||
public getFocus() {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
public onSelect(f: (id: number) => void) {
|
||||
this.selectCallback = f;
|
||||
}
|
||||
|
||||
protected calculateFocused(node: TreeviewItem) {
|
||||
const idx = this.children.indexOf(node);
|
||||
this._onSelect(idx);
|
||||
}
|
||||
|
||||
public focusPrevious() {
|
||||
if (this.children[this.focused].isExpanded()) {
|
||||
// return this.children[this.focused].focusPrevious();
|
||||
} else {
|
||||
this.focused = Math.max(0, this.focused - 1);
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public focusNext() {
|
||||
if (this.children[this.focused].isExpanded()) {
|
||||
// return this.children[this.focused].focusNext();
|
||||
} else {
|
||||
this.focused = Math.min(this.children.length - 1, this.focused + 1);
|
||||
this.children[this.focused].focus();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
54
frontend/src/ui/video.ts
Normal file
54
frontend/src/ui/video.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Video extends UINode {
|
||||
private videoElement: HTMLVideoElement;
|
||||
|
||||
public constructor(title: string, src: string | MediaStream = "") {
|
||||
super(title);
|
||||
this.videoElement = document.createElement("video");
|
||||
if (typeof src === "string") {
|
||||
this.videoElement.src = src; // Set src if it's a string URL
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.videoElement.srcObject = src; // Set srcObject if it's a MediaStream
|
||||
}
|
||||
this.videoElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.videoElement);
|
||||
this.setRole("video");
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.videoElement;
|
||||
}
|
||||
|
||||
public setSource(src: string | MediaStream) {
|
||||
if (typeof src === "string") {
|
||||
this.videoElement.src = src;
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.videoElement.srcObject = src;
|
||||
}
|
||||
}
|
||||
|
||||
public play() {
|
||||
this.videoElement.play();
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this.videoElement.pause();
|
||||
}
|
||||
|
||||
public setControls(show: boolean) {
|
||||
this.videoElement.controls = show;
|
||||
}
|
||||
|
||||
public setLoop(loop: boolean) {
|
||||
this.videoElement.loop = loop;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean) {
|
||||
this.videoElement.muted = muted;
|
||||
}
|
||||
|
||||
public setAutoplay(autoplay: boolean) {
|
||||
this.videoElement.autoplay = autoplay;
|
||||
}
|
||||
}
|
80
frontend/src/ui/window.ts
Normal file
80
frontend/src/ui/window.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Container } from "./container";
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class UIWindow {
|
||||
public title: string;
|
||||
public width!: number;
|
||||
public height!: number;
|
||||
public position!: { x: number; y: number; };
|
||||
public container: Container;
|
||||
public visible: boolean;
|
||||
private element: HTMLDivElement;
|
||||
private rendered!: boolean;
|
||||
private keyDown: (e: KeyboardEvent) => void;
|
||||
|
||||
public constructor(
|
||||
title: string,
|
||||
classname?: string,
|
||||
private setTitle: boolean = true
|
||||
) {
|
||||
this.title = title;
|
||||
this.container = new Container(this.title);
|
||||
this.container._onConnect();
|
||||
this.element = document.createElement("div");
|
||||
if (classname) {
|
||||
this.element.className = classname;
|
||||
}
|
||||
this.keyDown = this.onKeyDown.bind(this);
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
public add(node: UINode) {
|
||||
this.container.add(node);
|
||||
}
|
||||
|
||||
public remove(node: UINode) {
|
||||
if (this.container.children.includes(node)) this.container.remove(node);
|
||||
}
|
||||
|
||||
public show(): HTMLElement|undefined {
|
||||
if (this.visible) return;
|
||||
if (this.setTitle) document.title = this.title;
|
||||
if (this.rendered) return this.element;
|
||||
this.element.appendChild(this.container.render());
|
||||
this.element.addEventListener("keydown", this.keyDown);
|
||||
this.element.focus();
|
||||
this.visible = true;
|
||||
this.rendered = true;
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public hide() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
this.rendered = false;
|
||||
this.element.replaceChildren();
|
||||
this.element.removeEventListener("keydown", this.keyDown);
|
||||
}
|
||||
|
||||
public onKeyDown(e: KeyboardEvent) {
|
||||
if (this.container._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public onConnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
public onDisconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public getContainer(): Container {
|
||||
return this.container;
|
||||
}
|
||||
}
|
56
frontend/src/views/authorize.ts
Normal file
56
frontend/src/views/authorize.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { showToast } from "../speech";
|
||||
import { Button, Text, TextInput } from "../ui";
|
||||
import { View } from "./view";
|
||||
import { state } from "../state";
|
||||
import { API } from "../api";
|
||||
import { MainView } from "./main";
|
||||
import { playSound } from "../sound";
|
||||
|
||||
export class AuthorizeView extends View {
|
||||
private welcomeText!: Text;
|
||||
private apiURLInput!: TextInput;
|
||||
private tokenInput!: TextInput;
|
||||
private loginButton!: Button;
|
||||
|
||||
public onActivate(): void {
|
||||
playSound("intro");
|
||||
}
|
||||
public onDeactivate(): void {
|
||||
}
|
||||
public onCreate(): void {
|
||||
this.welcomeText = new Text("Welcome to Notebrook!");
|
||||
this.welcomeText.setPosition(25, 10, 75, 20);
|
||||
this.apiURLInput = new TextInput("API URL");
|
||||
this.apiURLInput.setPosition(40, 40, 20, 20);
|
||||
this.tokenInput = new TextInput("Token");
|
||||
this.tokenInput.setPosition(40, 60, 20, 10);
|
||||
this.loginButton = new Button("Login");
|
||||
this.loginButton.setPosition(40, 70, 20, 10);
|
||||
this.window.add(this.welcomeText);
|
||||
this.window.add(this.apiURLInput);
|
||||
this.window.add(this.tokenInput);
|
||||
this.window.add(this.loginButton);
|
||||
|
||||
this.loginButton.onClick(async () => {
|
||||
const token = this.tokenInput.getValue();
|
||||
const apiUrl = this.apiURLInput.getValue();
|
||||
API.path = apiUrl;
|
||||
API.token = token;
|
||||
try {
|
||||
await API.checkToken();
|
||||
state.token = token;
|
||||
state.apiUrl = apiUrl;
|
||||
state.save();
|
||||
showToast(`Welcome!`, 2000);
|
||||
playSound("login");
|
||||
this.viewManager.push(new MainView(this.viewManager));
|
||||
} catch (e) {
|
||||
showToast(`Invalid API URL or token provided.`);
|
||||
playSound("uploadFailed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onDestroy(): void {
|
||||
}
|
||||
}
|
505
frontend/src/views/main.ts
Normal file
505
frontend/src/views/main.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { API } from "../api";
|
||||
import { ChunkProcessor } from "../chunk-processor";
|
||||
import { ChannelDialog } from "../dialogs/channel-dialog";
|
||||
import { CreateChannelDialog } from "../dialogs/create-channel";
|
||||
import { MessageDialog } from "../dialogs/message";
|
||||
import { RecordAudioDialog } from "../dialogs/record-audio";
|
||||
import { SearchDialog } from "../dialogs/search";
|
||||
import { SettingsDialog } from "../dialogs/settings";
|
||||
import { TakePhotoDialog } from "../dialogs/take-photo";
|
||||
import { Channel } from "../model/channel";
|
||||
import { IMessage, Message } from "../model/message";
|
||||
import { UnsentMessage } from "../model/unsent-message";
|
||||
import { playSent, playSound, playWater } from "../sound";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, List, ListItem, TextInput, UINode } from "../ui";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { FileInput } from "../ui/file-input";
|
||||
import { MultilineInput } from "../ui/multiline-input";
|
||||
import { connectToWebsocket } from "../websockets";
|
||||
import { AuthorizeView } from "./authorize";
|
||||
import { View } from "./view";
|
||||
|
||||
export class MainView extends View {
|
||||
private settingsButton!: Button;
|
||||
private channelSwitcher!: Dropdown;
|
||||
private channelInfoButton!: Button;
|
||||
private searchButton!: Button;
|
||||
private fileInput!: FileInput;
|
||||
private messageInput!: MultilineInput;
|
||||
private imageInput!: Button;
|
||||
private voiceMessageInput!: Button;
|
||||
private messageList!: List;
|
||||
private updateInterval!: number;
|
||||
|
||||
private messageElementMap: Map<number, UINode> = new Map();
|
||||
|
||||
public onActivate(): void {
|
||||
if (!state.currentChannel) {
|
||||
if (state.defaultChannelId) {
|
||||
this.switchChannel(state.defaultChannelId.toString());
|
||||
} else {
|
||||
if (state.channelList.channels.length > 0) this.switchChannel(state.channelList.channels[0].id.toString());
|
||||
}
|
||||
}
|
||||
this.renderInitialMessageList();
|
||||
this.checkAuthorization();
|
||||
this.syncChannels();
|
||||
this.updateChannelList();
|
||||
this.syncMessages();
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.updateVisibleMessageShownTimestamps();
|
||||
}, 1000);
|
||||
setTimeout(() => this.attemptToSendUnsentMessages(), 2000);
|
||||
}
|
||||
|
||||
|
||||
public onDeactivate(): void {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
public onCreate(): void {
|
||||
this.settingsButton = new Button("Settings");
|
||||
this.settingsButton.setPosition(0, 0, 10, 10);
|
||||
this.settingsButton.onClick(() => {
|
||||
new SettingsDialog().open();
|
||||
});
|
||||
this.channelSwitcher = new Dropdown("Channel", []);
|
||||
this.channelSwitcher.setPosition(30, 10, 30, 10);
|
||||
this.channelInfoButton = new Button("Channel info");
|
||||
this.channelInfoButton.setPosition(60, 10, 30, 10);
|
||||
this.searchButton = new Button("Search");
|
||||
this.searchButton.setPosition(90, 10, 10, 10);
|
||||
this.searchButton.onClick(async () => {
|
||||
const searchDialog = new SearchDialog();
|
||||
const res = await searchDialog.open();
|
||||
if (res) {
|
||||
if (res.channelId && res.messageId) {
|
||||
if (state.currentChannel?.id !== res.channelId) {
|
||||
this.switchChannel(res.channelId.toString());
|
||||
this.renderInitialMessageList();
|
||||
}
|
||||
const message = state.currentChannel!.getMessage(res.messageId);
|
||||
if (message) {
|
||||
this.messageElementMap.get(message.id)?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
this.fileInput = new FileInput("Upload file");
|
||||
this.fileInput.setPosition(0, 90, 15, 10);
|
||||
this.imageInput = new Button("Image");
|
||||
this.imageInput.setPosition(15, 90, 15, 10);
|
||||
this.messageInput = new MultilineInput("New message");
|
||||
this.messageInput.setPosition(30, 90, 60, 10);
|
||||
this.messageInput.getElement().autofocus = true;
|
||||
this.voiceMessageInput = new Button("Voice message");
|
||||
this.voiceMessageInput.setPosition(70, 90, 30, 10);
|
||||
|
||||
this.messageList = new List("Messages");
|
||||
this.messageList.setPosition(30, 30, 60, 50);
|
||||
this.window.add(this.settingsButton);
|
||||
this.window.add(this.channelSwitcher);
|
||||
this.window.add(this.channelInfoButton);
|
||||
this.window.add(this.searchButton);
|
||||
this.window.add(this.messageList);
|
||||
this.window.add(this.messageInput);
|
||||
this.window.add(this.fileInput);
|
||||
this.window.add(this.imageInput);
|
||||
this.window.add(this.voiceMessageInput);
|
||||
this.channelSwitcher.getElement().addEventListener("change", (e) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
if (target.value === "__new__") {
|
||||
this.createNewChannel();
|
||||
} else {
|
||||
this.switchChannel(target.value);
|
||||
this.renderInitialMessageList();
|
||||
this.syncMessages();
|
||||
}
|
||||
});
|
||||
this.voiceMessageInput.onClick(async () => {
|
||||
const blob = await new RecordAudioDialog().open();
|
||||
if (blob) {
|
||||
this.uploadVoiceMessage(blob);
|
||||
}
|
||||
})
|
||||
|
||||
this.messageInput.onKeyDown((key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
|
||||
if (key === "Enter") {
|
||||
if (!shift) {
|
||||
console.log(key, alt, shift, ctrl);
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.channelInfoButton.onClick(() => {
|
||||
if (this.channelSwitcher.getSelectedValue() === "__new__") {
|
||||
this.createNewChannel();
|
||||
return;
|
||||
}
|
||||
const d = new ChannelDialog(state.currentChannel!);
|
||||
d.open().then((chan) => {
|
||||
state.save();
|
||||
this.updateChannelList();
|
||||
});
|
||||
})
|
||||
this.imageInput.onClick(async () => {
|
||||
const photo = await new TakePhotoDialog().open();
|
||||
this.uploadImage(photo);
|
||||
});
|
||||
}
|
||||
|
||||
public onDestroy(): void {
|
||||
|
||||
}
|
||||
|
||||
private async syncChannels() {
|
||||
const channels = await API.getChannels();
|
||||
channels.forEach((chan) => state.addChannel(new Channel(chan)));
|
||||
this.updateChannelList();
|
||||
if (!state.currentChannel) {
|
||||
if (state.defaultChannelId) {
|
||||
this.switchChannel(state.defaultChannelId.toString());
|
||||
} else {
|
||||
this.switchChannel(state.channelList.channels[0].id.toString());
|
||||
}
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
|
||||
private updateChannelList() {
|
||||
this.channelSwitcher.clearOptions();
|
||||
state.getChannels().forEach((chan) => {
|
||||
this.channelSwitcher.addOption(chan.id.toString(), chan.name);
|
||||
});
|
||||
this.channelSwitcher.addOption("__new__", "Add new channel");
|
||||
}
|
||||
|
||||
private checkAuthorization() {
|
||||
if (!state.token || !state.apiUrl) {
|
||||
this.viewManager.push(new AuthorizeView(this.viewManager));
|
||||
} else {
|
||||
API.token = state.token;
|
||||
API.path = state.apiUrl;
|
||||
connectToWebsocket();
|
||||
}
|
||||
|
||||
state.save();
|
||||
}
|
||||
|
||||
private async syncMessages() {
|
||||
if (!state.currentChannel) return;
|
||||
if (!state.currentChannel.messages) state.currentChannel.messages = [];
|
||||
const channelId = state.currentChannel.id;
|
||||
if (channelId) {
|
||||
const messages = await API.getMessages(channelId.toString());
|
||||
// only render new list items, or list items that have changed.
|
||||
const proc = new ChunkProcessor<IMessage>(100);
|
||||
proc.processArray(messages, (chunk: IMessage[]) => {
|
||||
chunk.forEach((message: IMessage) => {
|
||||
// TODO: this could do with a lot of perf improvements. I'll get to it once this is an issue.
|
||||
const existing = state.currentChannel!.getMessage(message.id);
|
||||
if (!existing) {
|
||||
state.currentChannel!.addMessage(new Message(message));
|
||||
this.renderAndAddMessage(message);
|
||||
} else {
|
||||
// TODO: this is awful and needs to be updated, but it works for now.
|
||||
if (existing.content !== message.content || existing.fileId !== message.fileId || existing.filePath !== message.filePath || existing.fileType !== message.fileType || existing.createdAt !== message.createdAt) {
|
||||
existing.content = message.content;
|
||||
existing.fileId = message.fileId;
|
||||
existing.filePath = message.filePath;
|
||||
existing.fileType = message.fileType;
|
||||
existing.createdAt = message.createdAt;
|
||||
existing.fileId = message.fileId;
|
||||
existing.filePath = message.filePath;
|
||||
existing.fileSize = message.fileSize;
|
||||
const renderedMessage = this.messageElementMap.get(message.id);
|
||||
if (renderedMessage) {
|
||||
(renderedMessage as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
|
||||
public switchChannel(channelId: string) {
|
||||
if (this.messageList.children.length > 0) this.messageList.clear();
|
||||
const chan = state.getChannelById(parseInt(channelId));
|
||||
if (!chan) {
|
||||
throw new Error("Could not find channel " + channelId);
|
||||
}
|
||||
state.currentChannel = chan;
|
||||
state.save();
|
||||
}
|
||||
|
||||
private renderMessage(message: IMessage): UINode {
|
||||
const itm = new ListItem(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`);
|
||||
itm.setUserData(message);
|
||||
itm.onClick(() => {
|
||||
new MessageDialog(message).open();
|
||||
})
|
||||
return itm;
|
||||
}
|
||||
|
||||
private renderInitialMessageList(reset: boolean = false) {
|
||||
if (!state.currentChannel) return;
|
||||
if (!state.currentChannel.messages || state.currentChannel.messages.length < 1) return;
|
||||
if (this.messageList.children.length > 0 && !reset) {
|
||||
return;
|
||||
} else {
|
||||
this.messageList.clear();
|
||||
this.messageElementMap.clear();
|
||||
}
|
||||
state.currentChannel.messages.forEach((message) => {
|
||||
this.renderAndAddMessage(message);
|
||||
});
|
||||
this.messageList.scrollToBottom();
|
||||
}
|
||||
|
||||
private async createNewChannel() {
|
||||
const name = await new CreateChannelDialog().open();
|
||||
if (name) {
|
||||
const chan = await API.createChannel(name);
|
||||
state.addChannel(new Channel(chan));
|
||||
this.updateChannelList();
|
||||
if (state.channelList.channels.length < 2) {
|
||||
state.defaultChannelId = chan.id;
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMessage() {
|
||||
if (this.fileInput && this.fileInput.getFiles() && this.fileInput.getFiles()!.length > 0) {
|
||||
return this.uploadFile();
|
||||
}
|
||||
if (this.messageInput.getValue().length > 0) {
|
||||
const messageContent = this.messageInput.getValue();
|
||||
this.messageInput.setValue("");
|
||||
playWater();
|
||||
try {
|
||||
const message: IMessage = await API.createMessage(state.currentChannel!.id.toString(), messageContent);
|
||||
this.messageInput.setValue("");
|
||||
this.renderAndAddMessage(message);
|
||||
this.messageList.scrollToBottom();
|
||||
playSent();
|
||||
state.save();
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
const unsentId = Date.now();
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: messageContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
id: unsentId
|
||||
}));
|
||||
const tmpMessage: IMessage = new Message({
|
||||
id: unsentId,
|
||||
content: messageContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
state.currentChannel!.addMessage(tmpMessage);
|
||||
this.renderAndAddMessage(tmpMessage);
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadVoiceMessage(blob: Blob) {
|
||||
playWater();
|
||||
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "Voice message";
|
||||
this.messageInput.setValue("");
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
|
||||
const id = msg.id;
|
||||
try {
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Something went wrong during message file upload.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
playSound("uploadFailed");
|
||||
showToast("Unable to send message. Will retry later.", 3000);
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: msgContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: blob,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile() {
|
||||
if (!this.fileInput.getFiles()) return;
|
||||
if (this.fileInput!.getFiles()!.length < 1) return;
|
||||
const file = this.fileInput!.getFiles()![0];
|
||||
if (file) {
|
||||
playWater();
|
||||
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "File upload";
|
||||
this.messageInput.setValue("");
|
||||
try {
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
|
||||
const id = msg.id;
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), file);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
this.messageInput.setValue("");
|
||||
// reset the file picker
|
||||
(this.fileInput.getElement() as HTMLInputElement).value = "";
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Error while uploading file.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: this.messageInput.getValue(),
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: file,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private convertIsoTimeStringToRelative(isoTimeString: string): string {
|
||||
const date = new Date(isoTimeString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 1000 * 60) {
|
||||
return `${Math.floor(diff / 1000)} seconds ago`;
|
||||
} else if (diff < 1000 * 60 * 60) {
|
||||
// return both minutes and seconds
|
||||
return `${Math.floor(diff / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
|
||||
} else if (diff < 1000 * 60 * 60 * 24) {
|
||||
// return hours, minutes, seconds ago
|
||||
return `${Math.floor(diff / (1000 * 60 * 60))} hours ${Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
private updateVisibleMessageShownTimestamps() {
|
||||
const lowerIndex = Math.max(this.messageList.getFocus() - 10, 0);
|
||||
const upperIndex = Math.min(this.messageList.getFocus() + 10, this.messageList.children.length);
|
||||
for (let i = lowerIndex; i < upperIndex; i++) {
|
||||
const child = this.messageList.children[i];
|
||||
if (!child) break;
|
||||
const message = child.getUserData() as IMessage;
|
||||
if (message) {
|
||||
(child as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptToSendUnsentMessages() {
|
||||
state.unsentMessages.forEach(async (msg) => {
|
||||
if (msg.blob) {
|
||||
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
|
||||
const id = apiMsg.id;
|
||||
const response: any = await API.uploadFile(msg.channelId.toString(), id.toString(), msg.blob);
|
||||
if (apiMsg) {
|
||||
apiMsg.fileId = response.fileId;
|
||||
apiMsg.filePath = response.filePath;
|
||||
apiMsg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(apiMsg));
|
||||
this.renderAndAddMessage(apiMsg);
|
||||
playSent();
|
||||
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
|
||||
this.removeSpecificMessageFromList(msg.id);
|
||||
state.save();
|
||||
}
|
||||
} else {
|
||||
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
|
||||
state.currentChannel!.addMessage(new Message(apiMsg));
|
||||
this.renderAndAddMessage(apiMsg);
|
||||
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
|
||||
playSent();
|
||||
this.removeSpecificMessageFromList(msg.id);
|
||||
state.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearUnsentMessageDisplay() {
|
||||
this.messageList.children.forEach((msg) => {
|
||||
const data = msg.getUserData() as IMessage;
|
||||
if (data.id === -1) {
|
||||
this.messageList.remove(msg);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private removeSpecificMessageFromList(id: number) {
|
||||
const elem = this.messageElementMap.get(id);
|
||||
if (elem) {
|
||||
this.messageList.remove(elem);
|
||||
this.messageElementMap.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadImage(blob: Blob) {
|
||||
playWater();
|
||||
try {
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), "Image");
|
||||
const id = msg.id;
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Error while uploading file.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: "Image",
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: blob,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private renderAndAddMessage(message: IMessage) {
|
||||
const elem = this.renderMessage(message);
|
||||
this.messageList.add(elem);
|
||||
this.messageElementMap.set(message.id, elem);
|
||||
}
|
||||
}
|
74
frontend/src/views/view-manager.ts
Normal file
74
frontend/src/views/view-manager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { UIWindow } from "../ui/window";
|
||||
import { View } from "./view";
|
||||
|
||||
export class ViewManager {
|
||||
private currentView: View | undefined | null;
|
||||
private views: View[];
|
||||
private window: UIWindow;
|
||||
public constructor() {
|
||||
this.views = [];
|
||||
this.window = new UIWindow("Notebrook");
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
public add(view: View) {
|
||||
this.views.push(view);
|
||||
view.onCreate();
|
||||
}
|
||||
|
||||
public remove(view: View) {
|
||||
this.views.splice(this.views.indexOf(view), 1);
|
||||
view.onDestroy();
|
||||
if (view === this.currentView) this.window.remove(this.currentView.show());
|
||||
if (this.currentView) this.currentView.setActive(false);
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
public switchTo(view: View) {
|
||||
if (!this.views.includes(view)) {
|
||||
throw new Error("View not initialized");
|
||||
}
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
}
|
||||
this.currentView = view;
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
|
||||
public render(): HTMLElement|undefined {
|
||||
return this.window.show();
|
||||
}
|
||||
|
||||
public push(view: View) {
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
}
|
||||
|
||||
this.views.unshift(view);
|
||||
this.currentView = view;
|
||||
this.currentView.onCreate();
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
|
||||
public pop() {
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
this.currentView.onDestroy();
|
||||
}
|
||||
this.views.splice(0, 1);
|
||||
this.currentView = this.views[0];
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
}
|
33
frontend/src/views/view.ts
Normal file
33
frontend/src/views/view.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { UIWindow } from "../ui/window";
|
||||
import { ViewManager } from "./view-manager";
|
||||
|
||||
export abstract class View {
|
||||
protected viewManager: ViewManager;
|
||||
protected window: Container;
|
||||
private active!: boolean;
|
||||
public constructor(viewManager: ViewManager) {
|
||||
this.viewManager = viewManager;
|
||||
this.window = new Container("Base view");
|
||||
}
|
||||
|
||||
public show() {
|
||||
return this.window;
|
||||
}
|
||||
|
||||
public abstract onActivate(): void;
|
||||
|
||||
public abstract onDeactivate(): void;
|
||||
|
||||
public abstract onCreate(): void;
|
||||
|
||||
public abstract onDestroy(): void;
|
||||
|
||||
public isActive() {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public setActive(val: boolean) {
|
||||
this.active = val;
|
||||
}
|
||||
}
|
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
16
frontend/src/websockets.ts
Normal file
16
frontend/src/websockets.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { API } from "./api";
|
||||
|
||||
export const connectToWebsocket = () => {
|
||||
const ws = new WebSocket(`ws://localhost:3000`);
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to websocket server");
|
||||
}
|
||||
ws.onmessage = (data) => {
|
||||
const message = JSON.parse(data.data.toString());
|
||||
console.log(message);
|
||||
}
|
||||
ws.onclose= () => {
|
||||
console.log("Disconnected from websocket server");
|
||||
}
|
||||
return ws;
|
||||
}
|
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
35
frontend/vite.config.ts
Normal file
35
frontend/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['intro.wav', 'login.wav', 'sent1.wav', 'sent2.wav', 'sent3.wav', 'sent4.wav', 'sent5.wav', 'sent6.wav', 'uploadfail.wav', 'water1.wav', 'water2.wav', 'water3.wav', 'water4.wav', 'water5.wav', 'water6.wav', 'water7.wav', 'water8.wav', 'water9.wav', 'water10.wav', 'index.html'],
|
||||
manifest: {
|
||||
name: 'Notebrook',
|
||||
short_name: 'Notebrook',
|
||||
description: 'Notebrook, stream of consciousness accessible note taking',
|
||||
theme_color: '#ffffff',
|
||||
icons: [
|
||||
{
|
||||
src: 'icons/192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'icons/512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
|
||||
// workbox options for the service worker
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
Reference in New Issue
Block a user