Add files
parent
2b8a6e1428
commit
448e25de50
|
@ -0,0 +1,3 @@
|
||||||
|
.env
|
||||||
|
.vscode
|
||||||
|
README.md
|
|
@ -0,0 +1,12 @@
|
||||||
|
DB_PATH=/nb/database.db
|
||||||
|
API_TOKEN=test
|
||||||
|
UPLOAD_DIR=/nb/uploads/
|
||||||
|
DESCRIBE_IMAGES=1
|
||||||
|
DESCRIBE_IMAGES_API=ollama
|
||||||
|
DESCRIBE_IMAGES_PROMPT="Your task is to describe images to your friend in a friendly, detailed but concise manner.\n"
|
||||||
|
DESCRIBE_IMAGES_TEMPERATURE=0.5
|
||||||
|
DESCRIBE_IMAGES_MAX_TOKENS=8192
|
||||||
|
OPENAI_API_KEY=sk-blahblahblahblahblahImAnAPIKeyWoopDeeDoo
|
||||||
|
OPENAI_MODEL=gpt-4o
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=moondream
|
|
@ -0,0 +1,175 @@
|
||||||
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
|
@ -0,0 +1,15 @@
|
||||||
|
# notebrook-backend
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.1.21. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
Binary file not shown.
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "notebrook-backend",
|
||||||
|
"module": "src/server.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/server.ts",
|
||||||
|
"dev": "bun run --watch src/server.ts",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"ollama": "^0.5.8",
|
||||||
|
"openai": "^4.56.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channelId INTEGER,
|
||||||
|
filePath TEXT,
|
||||||
|
fileType TEXT,
|
||||||
|
fileSize INTEGER,
|
||||||
|
originalName TEXT,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channelId INTEGER,
|
||||||
|
content TEXT,
|
||||||
|
fileId INTEGER NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
|
||||||
|
SET
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
|
content,
|
||||||
|
content = 'messages',
|
||||||
|
content_rowid = 'id'
|
||||||
|
);
|
|
@ -0,0 +1,27 @@
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import * as ChannelRoutes from "./routes/channel";
|
||||||
|
import * as FileRoutes from "./routes/file";
|
||||||
|
import * as MessageRoutes from "./routes/message";
|
||||||
|
import * as SearchRoutes from "./routes/search";
|
||||||
|
import { authenticate } from "./middleware/auth";
|
||||||
|
import { initializeDB } from "./db";
|
||||||
|
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
|
||||||
|
|
||||||
|
|
||||||
|
export const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cors());
|
||||||
|
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||||
|
app.use(express.static(FRONTEND_DIR));
|
||||||
|
|
||||||
|
app.use("/channels", ChannelRoutes.router);
|
||||||
|
app.use("/channels/:channelId/messages", MessageRoutes.router);
|
||||||
|
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
|
||||||
|
app.use("/search", SearchRoutes.router);
|
||||||
|
|
||||||
|
app.get('/check-token', authenticate, (req, res) => {
|
||||||
|
res.json({ message: 'Token is valid' });
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
export const DB_PATH = process.env["DB_PATH"] || "/usr/src/app/data/db.sqlite";
|
||||||
|
export const SECRET_KEY = process.env["API_TOKEN"] || "";
|
||||||
|
export const UPLOAD_DIR = process.env["UPLOAD_DIR"] || "/usr/src/app/data/uploads/";
|
||||||
|
export const FRONTEND_DIR = process.env["FRONTEND_DIR"] || "/usr/src/app/backend/public";
|
||||||
|
export const DESCRIBE_IMAGES: boolean = process.env["DESCRIBE_IMAGES"] === "1" ? true : false;
|
||||||
|
export const DESCRIBE_IMAGES_API = process.env["DESCRIBE_IMAGES_API"] || "ollama";
|
||||||
|
export const DESCRIBE_IMAGES_PROMPT= process.env["DESCRIBE_IMAGES_PROMPT"] || "Describe this image.";
|
||||||
|
export const DESCRIBE_IMAGES_TEMPERATURE= parseFloat(process.env["DESCRIBE_IMAGES_TEMPERATURE"]!) || 0.5;
|
||||||
|
export const DESCRIBE_IMAGES_MAX_TOKENS= parseInt(process.env["DESCRIBE_IMAGES_MAX_TOKENS"]!) || 1024;
|
||||||
|
export const OPENAI_API_KEY= process.env["OPENAI_API_KEY"] || "";
|
||||||
|
export const OPENAI_MODEL = process.env["OPENAI_MODEL"] || "gpt-4o";
|
||||||
|
export const OLLAMA_URL= process.env["OLLAMA_URL"] || "http://localhost:11434";
|
||||||
|
export const OLLAMA_MODEL= process.env["OLLAMA_MODEL"] || "moondream";
|
||||||
|
|
||||||
|
// list all files in /usr/src/app/data/
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import * as ChannelService from "../services/channel-service";
|
||||||
|
|
||||||
|
export const createChannel = async (req: Request, res: Response) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
|
}
|
||||||
|
const chan = await ChannelService.createChannel(name);
|
||||||
|
|
||||||
|
res.json(chan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteChannel = async (req: Request, res: Response) => {
|
||||||
|
const { channelId } = req.params;
|
||||||
|
if (!channelId) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID is required' });
|
||||||
|
}
|
||||||
|
const result = await ChannelService.deleteChannel(channelId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Channel deleted successfully' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getChannels = async (req: Request, res: Response) => {
|
||||||
|
const channels = await ChannelService.getChannels();
|
||||||
|
res.json({ channels });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeChannel = async (req: Request, res: Response) => {
|
||||||
|
const { channelId } = req.params;
|
||||||
|
const { targetChannelId } = req.body;
|
||||||
|
if (!channelId || !targetChannelId) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID and targetChannelId are required' });
|
||||||
|
}
|
||||||
|
const result = await ChannelService.mergeChannel(channelId, targetChannelId);
|
||||||
|
|
||||||
|
res.json({ message: 'Channels merged successfully' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateChannel = async (req: Request, res: Response) => {
|
||||||
|
const { channelId } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!channelId || !name) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID and name are required' });
|
||||||
|
}
|
||||||
|
const result = await ChannelService.updateChannel(channelId, name);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Channel not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Channel updated successfully' });
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import * as FileService from "../services/file-service";
|
||||||
|
|
||||||
|
export const uploadFile = async (req: Request, res: Response) => {
|
||||||
|
const { channelId, messageId } = req.params;
|
||||||
|
const filePath = (req.file as Express.Multer.File).path;
|
||||||
|
const fileType = req.file?.mimetype;
|
||||||
|
const fileSize = req.file?.size;
|
||||||
|
const originalName = req.file?.originalname;
|
||||||
|
|
||||||
|
if (!channelId || !messageId) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID and message ID are required' });
|
||||||
|
}
|
||||||
|
if (!filePath || !fileType || !fileSize || !originalName) {
|
||||||
|
return res.status(400).json({ error: 'File is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
|
||||||
|
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getFiles = async (req: Request, res: Response) => {
|
||||||
|
const { messageId } = req.params;
|
||||||
|
if (!messageId) {
|
||||||
|
return res.status(400).json({ error: 'Message ID is required' });
|
||||||
|
}
|
||||||
|
const files = await FileService.getFiles(messageId);
|
||||||
|
res.json({ files });
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import * as MessageService from "../services/message-service";
|
||||||
|
|
||||||
|
export const createMessage = async (req: Request, res: Response) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
const { channelId } = req.params;
|
||||||
|
if (!content || !channelId) {
|
||||||
|
return res.status(400).json({ error: 'Content and channel ID are required' });
|
||||||
|
}
|
||||||
|
const messageId = await MessageService.createMessage(channelId, content);
|
||||||
|
|
||||||
|
res.json({ id: messageId, channelId, content, createdAt: new Date().toISOString() });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateMessage = async (req: Request, res: Response) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
const { messageId } = req.params;
|
||||||
|
if (!content || !messageId) {
|
||||||
|
return res.status(400).json({ error: 'Content and message ID are required ' });
|
||||||
|
}
|
||||||
|
const result = await MessageService.updateMessage(messageId, content);
|
||||||
|
|
||||||
|
res.json({ id: messageId, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteMessage = async (req: Request, res: Response) => {
|
||||||
|
const { messageId } = req.params;
|
||||||
|
if (!messageId) {
|
||||||
|
return res.status(400).json({ error: 'Message ID is required' });
|
||||||
|
}
|
||||||
|
const result = await MessageService.deleteMessage(messageId);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Message not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Message deleted successfully' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMessages = async (req: Request, res: Response) => {
|
||||||
|
const { channelId } = req.params;
|
||||||
|
if (!channelId) {
|
||||||
|
return res.status(400).json({ error: 'Channel ID is required' });
|
||||||
|
}
|
||||||
|
const messages = await MessageService.getMessages(channelId);
|
||||||
|
|
||||||
|
res.json({ messages });
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import * as SearchService from "../services/search-service";
|
||||||
|
|
||||||
|
export const search = async (req: Request, res: Response) => {
|
||||||
|
const { query, channelId } = req.query;
|
||||||
|
if (!query) {
|
||||||
|
return res.status(400).json({ error: 'Query is required' });
|
||||||
|
}
|
||||||
|
const results = await SearchService.search(query as string, channelId as string);
|
||||||
|
res.json({ results });
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { events } from "../globals";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
export const attachEvents = (ws: WebSocket) => {
|
||||||
|
events.on('file-uploaded', (id, channelId, messageId, filePath, fileType, fileSize, originalName) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'file-uploaded', id, channelId, messageId, filePath, fileType, fileSize, originalName }));
|
||||||
|
});
|
||||||
|
events.on('message-created', (id, channelId, content) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'message-created', id, channelId, content }));
|
||||||
|
});
|
||||||
|
events.on('message-updated', (id, content) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'message-updated', id, content }));
|
||||||
|
});
|
||||||
|
events.on('message-deleted', (id) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'message-deleted', id }));
|
||||||
|
});
|
||||||
|
events.on('channel-created', (channel) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'channel-created', channel }));
|
||||||
|
});
|
||||||
|
events.on('channel-deleted', (id) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'channel-deleted', id }));
|
||||||
|
});
|
||||||
|
events.on('channel-merged', (channelId, targetChannelId) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'channel-merged', channelId, targetChannelId }));
|
||||||
|
});
|
||||||
|
events.on('channel-updated', (id, name) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'channel-updated', id, name }));
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { DB_PATH } from './config';
|
||||||
|
import { logger } from './globals';
|
||||||
|
|
||||||
|
export let FTS5Enabled = true;
|
||||||
|
|
||||||
|
export const initializeDB = () => {
|
||||||
|
logger.info("Checking fts");
|
||||||
|
const ftstest = db.query(`pragma compile_options;`);
|
||||||
|
const result = ftstest.all() as { compile_options: string }[];
|
||||||
|
if (result.find((o) => o["compile_options"].includes("ENABLE_FTS5"))) {
|
||||||
|
logger.info("FTS5 is enabled");
|
||||||
|
} else {
|
||||||
|
logger.info("FTS5 is not enabled. Attempting to load...");
|
||||||
|
try {
|
||||||
|
db.loadExtension('./fts5');
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Failed to load FTS5 extension. Disabling FTS5");
|
||||||
|
FTS5Enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channelId INTEGER,
|
||||||
|
filePath TEXT,
|
||||||
|
fileType TEXT,
|
||||||
|
fileSize INTEGER,
|
||||||
|
originalName TEXT,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channelId INTEGER,
|
||||||
|
content TEXT,
|
||||||
|
fileId INTEGER NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
|
content,
|
||||||
|
content='messages',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return FTS5Enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Loading database at ${DB_PATH}`);
|
||||||
|
export const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
|
||||||
|
initializeDB();
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { Scheduler } from "./utils/scheduler";
|
||||||
|
import { jobs } from "./jobs";
|
||||||
|
import { Logger } from "./logging/logger";
|
||||||
|
import { ConsoleAdapter } from "./logging/adapters/console-adapter";
|
||||||
|
|
||||||
|
export const events = new EventEmitter();
|
||||||
|
export const scheduler = new Scheduler();
|
||||||
|
export const logger = new Logger();
|
||||||
|
logger.addAdapter(new ConsoleAdapter());
|
||||||
|
|
||||||
|
jobs.forEach((job) => {
|
||||||
|
job();
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { loadImage, describeWithOpenAI, describeImage } from "./services/image-description";
|
||||||
|
import { DESCRIBE_IMAGES_PROMPT, OPENAI_API_KEY } from "./config";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log(await describeImage("d:/avatar.jpg"));
|
||||||
|
})();
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { events, logger } from "../globals"
|
||||||
|
import { describeImage } from "../services/image-description";
|
||||||
|
import { getMessage, updateMessage } from "../services/message-service";
|
||||||
|
|
||||||
|
export const describeImageJob = () => {
|
||||||
|
events.on("file-uploaded", (id, channelId, messageId, filePath, fileType, fileSize, originalName) => {
|
||||||
|
if (fileType.includes("image")) {
|
||||||
|
describeImage(filePath).then((description) => {
|
||||||
|
const msg = getMessage(messageId) as any;
|
||||||
|
updateMessage(messageId, `${msg.content ? msg.content : ''}\n\n${description}`);
|
||||||
|
}).catch((e) => {
|
||||||
|
logger.warn(`Failed to describe image: ${e.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { describeImageJob } from "./describe-image";
|
||||||
|
import { scheduleVacuum } from "./vacuum";
|
||||||
|
|
||||||
|
export const jobs = [
|
||||||
|
scheduleVacuum,
|
||||||
|
describeImageJob
|
||||||
|
]
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Scheduler, TimeUnit } from "../utils/scheduler";
|
||||||
|
import { scheduler } from "../globals";
|
||||||
|
import { db } from "../db";
|
||||||
|
|
||||||
|
export const scheduleVacuum = () => {
|
||||||
|
scheduler.register('vacuum', () => {
|
||||||
|
db.query('VACUUM');
|
||||||
|
}, 1, TimeUnit.DAY);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { type LogEntry } from "./log-entry";
|
||||||
|
|
||||||
|
export abstract class LogAdapter {
|
||||||
|
public log(message: LogEntry) {
|
||||||
|
if (this.shouldLog(message)) {
|
||||||
|
this.logImpl(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract logImpl(message: LogEntry): boolean;
|
||||||
|
|
||||||
|
public shouldLog(message: LogEntry): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { LogAdapter } from "../adapter";
|
||||||
|
import { type LogEntry, LogLevel } from "../log-entry";
|
||||||
|
|
||||||
|
export class ConsoleAdapter extends LogAdapter {
|
||||||
|
public logImpl(message: LogEntry): boolean {
|
||||||
|
console.log(`${LogLevel[message.level]}: ${message.message}; ${new Date(message.timestamp).toLocaleString()}:`);
|
||||||
|
if (message.additionalInfo) console.log(message.additionalInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface LogEntry {
|
||||||
|
level: LogLevel;
|
||||||
|
timestamp: number;
|
||||||
|
message: string;
|
||||||
|
additionalInfo?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LogLevel {
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
critical
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { LogAdapter } from "./adapter";
|
||||||
|
import { type LogEntry, LogLevel } from "./log-entry";
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
private adapters: LogAdapter[];
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.adapters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public log(message: LogEntry) {
|
||||||
|
this.adapters.forEach((adapter) => adapter.log(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, additionalInfo?: any) {
|
||||||
|
this.log({
|
||||||
|
level: LogLevel.info,
|
||||||
|
message,
|
||||||
|
additionalInfo,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, additionalInfo?: any) {
|
||||||
|
this.log({
|
||||||
|
level: LogLevel.warning,
|
||||||
|
message,
|
||||||
|
additionalInfo,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public critical(message: string, additionalInfo?: any) {
|
||||||
|
this.log({
|
||||||
|
level: LogLevel.critical,
|
||||||
|
message,
|
||||||
|
additionalInfo,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public addAdapter(adapter: LogAdapter) {
|
||||||
|
this.adapters.push(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeAdapter(adapter: LogAdapter) {
|
||||||
|
this.adapters.slice(this.adapters.indexOf(adapter), 1);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { SECRET_KEY } from "../config";
|
||||||
|
import { logger } from "../globals";
|
||||||
|
|
||||||
|
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const token = req.headers['authorization'];
|
||||||
|
logger.info(`Checking ${SECRET_KEY} against ${token}`);
|
||||||
|
if (!token) {
|
||||||
|
return res.status(403).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
if (token === SECRET_KEY) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Unauthenticated" })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import * as ChannelController from '../controllers/channel-controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const router = Router({mergeParams: true});
|
||||||
|
|
||||||
|
router.post('/', authenticate, ChannelController.createChannel);
|
||||||
|
router.get('/', authenticate, ChannelController.getChannels);
|
||||||
|
router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
|
||||||
|
router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { upload } from "../utils/multer";
|
||||||
|
import * as FileController from "../controllers/file-controller";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
export const router = Router({mergeParams: true});
|
||||||
|
|
||||||
|
router.post("/", authenticate, upload.single("file"), FileController.uploadFile);
|
||||||
|
router.get("/", authenticate, FileController.getFiles);
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import * as MessageController from '../controllers/message-controller';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const router = Router({mergeParams: true});
|
||||||
|
|
||||||
|
router.post('/', authenticate, MessageController.createMessage);
|
||||||
|
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
||||||
|
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
||||||
|
router.get('/', authenticate, MessageController.getMessages);
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import * as SearchController from "../controllers/search-controller";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
export const router = Router({mergeParams: true});
|
||||||
|
|
||||||
|
router.get("/", authenticate, SearchController.search);
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { app } from "./app";
|
||||||
|
import { createServer } from "http";
|
||||||
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { attachEvents } from "./controllers/websocket-controller";
|
||||||
|
import { logger } from "./globals";
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
|
logger.info('Websocket client connected');
|
||||||
|
|
||||||
|
attachEvents(ws);
|
||||||
|
|
||||||
|
ws.on('message', (message: string) => {
|
||||||
|
logger.info(`Received message: ${message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.info('Websocket client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(3000, () => {
|
||||||
|
logger.info(`Server is running on http://localhost:${3000}`);
|
||||||
|
});
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { db } from "../db";
|
||||||
|
import { events } from "../globals";
|
||||||
|
|
||||||
|
export const createChannel = async (name: string) => {
|
||||||
|
const query = db.query(`INSERT INTO channels (name) VALUES ($name)`);
|
||||||
|
const result = query.run({ $name: name });
|
||||||
|
events.emit('channel-created', { id: result.lastInsertRowid, name });
|
||||||
|
return { id: result.lastInsertRowid, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteChannel = async (id: string) => {
|
||||||
|
const query = db.query(`DELETE FROM channels WHERE id = ($channelId)`);
|
||||||
|
const result = db.run(id);
|
||||||
|
// No need to manually delete messages and files as they are set to cascade on delete in the schema
|
||||||
|
events .emit('channel-deleted', id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getChannels = async () => {
|
||||||
|
const query = db.query(`SELECT * FROM channels`);
|
||||||
|
const rows = query.all();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeChannel = async (channelId: string, targetChannelId: string) => {
|
||||||
|
const query = db.query(`UPDATE messages SET channelId = $targetChannelId WHERE channelId = $channelId`);
|
||||||
|
const result = query.run({ $channelId: channelId, $targetChannelId: targetChannelId });
|
||||||
|
events.emit('channel-merged', channelId, targetChannelId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateChannel = async (id: string, name: string) => {
|
||||||
|
const query = db.query(`UPDATE channels SET name = $name WHERE id = $id`);
|
||||||
|
const result = query.run({ $id: id, $name: name });
|
||||||
|
events.emit('channel-updated', id, name);
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { db } from "../db";
|
||||||
|
import { events } from "../globals";
|
||||||
|
|
||||||
|
export const uploadFile = async (channelId: string, messageId: string, filePath: string, fileType: string, fileSize: number, originalName: string) => {
|
||||||
|
const query = db.query(`INSERT INTO files (channelId, filePath, fileType, fileSize, originalName) VALUES ($channelId, $filePath, $fileType, $fileSize, $originalName)`);
|
||||||
|
const result = query.run({ $channelId: channelId, $filePath: filePath, $fileType: fileType, $fileSize: fileSize, $originalName: originalName } as any);
|
||||||
|
|
||||||
|
const fileId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
const updateQuery = db.query(`UPDATE messages SET fileId = $fileId WHERE id = $messageId`);
|
||||||
|
const result2 = updateQuery.run({ $fileId: fileId, $messageId: messageId });
|
||||||
|
|
||||||
|
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
|
||||||
|
return result2; ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFiles = async (messageId: string) => {
|
||||||
|
const query = db.query(`SELECT * FROM files WHERE messageId = $messageId`);
|
||||||
|
const rows = query.all({ $messageId: messageId });
|
||||||
|
return rows;
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Ollama } from "ollama";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { DESCRIBE_IMAGES_API, DESCRIBE_IMAGES_MAX_TOKENS, DESCRIBE_IMAGES_PROMPT, DESCRIBE_IMAGES_TEMPERATURE, OLLAMA_MODEL, OLLAMA_URL, OPENAI_API_KEY, OPENAI_MODEL } from "../config";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export const describeWithOllama = async (image: Buffer) => {
|
||||||
|
const client = new Ollama({ host: OLLAMA_URL });
|
||||||
|
|
||||||
|
const response = await client.chat({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
options: {
|
||||||
|
temperature: DESCRIBE_IMAGES_TEMPERATURE,
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: DESCRIBE_IMAGES_PROMPT },
|
||||||
|
{ role: "user", images: [image], content: "Describe this image." },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return response.message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const describeWithOpenAI = async (image: Buffer) => {
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: OPENAI_API_KEY,
|
||||||
|
});
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: OPENAI_MODEL,
|
||||||
|
max_tokens: DESCRIBE_IMAGES_MAX_TOKENS,
|
||||||
|
temperature: DESCRIBE_IMAGES_TEMPERATURE,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: DESCRIBE_IMAGES_PROMPT },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "Describe the following image in a detailed but concise manner." }, { type: "image_url", image_url: { url: imageToBase64URL(image) } }] },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const describeImage = async (filePath: string) => {
|
||||||
|
const image = await loadImage(filePath);
|
||||||
|
if (DESCRIBE_IMAGES_API === "ollama") {
|
||||||
|
return describeWithOllama(image);
|
||||||
|
} else {
|
||||||
|
return describeWithOpenAI(image);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadImage = async (filePath: string) => {
|
||||||
|
return processImage(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processImage(imagePath: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const image = sharp(imagePath);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const maxDimension = 1024;
|
||||||
|
|
||||||
|
// Check if the image needs to be resized
|
||||||
|
let resizedImage = image;
|
||||||
|
if (metadata.width && metadata.height && (metadata.width > maxDimension || metadata.height > maxDimension)) {
|
||||||
|
resizedImage = image.resize({
|
||||||
|
width: Math.min(metadata.width, maxDimension),
|
||||||
|
height: Math.min(metadata.height, maxDimension),
|
||||||
|
fit: sharp.fit.inside,
|
||||||
|
withoutEnlargement: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the image to JPG
|
||||||
|
const jpgBuffer = await resizedImage.jpeg().toBuffer();
|
||||||
|
|
||||||
|
return jpgBuffer;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing the image:', error);
|
||||||
|
throw new Error('Failed to process the image.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageToBase64URL = (input: Buffer) => {
|
||||||
|
return `data:image/jpeg;base64,${input.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { db, FTS5Enabled } from "../db";
|
||||||
|
import { events } from "../globals";
|
||||||
|
|
||||||
|
export const createMessage = async (channelId: string, content: string) => {
|
||||||
|
const query = db.query(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
|
||||||
|
const result = query.run({ $channelId: channelId, $content: content });
|
||||||
|
|
||||||
|
const messageId = result.lastInsertRowid;
|
||||||
|
console.log(`Adding message for search with id ${messageId}`);
|
||||||
|
// Insert into FTS table if FTS is enabled.
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
const query2 = db.query(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content)`);
|
||||||
|
const result2 = query2.run({ $rowId: messageId, $content: content });
|
||||||
|
}
|
||||||
|
|
||||||
|
events.emit('message-created', messageId, channelId, content);
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
|
||||||
|
const query = db.query(`UPDATE messages SET content = $content WHERE id = $id`);
|
||||||
|
const result = query.run({ $content: content, $id: messageId });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update FTS table if enabled
|
||||||
|
if (!FTS5Enabled) {
|
||||||
|
const query2 = db.query(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content) ON CONFLICT(rowid) DO UPDATE SET content = excluded.content`);
|
||||||
|
const result2 = query.run({ $rowId: messageId, $content: content });
|
||||||
|
}
|
||||||
|
events.emit('message-updated', messageId, content);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteMessage = async (messageId: string) => {
|
||||||
|
const query = db.query(`DELETE FROM messages WHERE id = $id`);
|
||||||
|
const result = query.run({ $id: messageId });
|
||||||
|
|
||||||
|
// Remove from FTS table if enabled
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
const query2 = db.query(`DELETE FROM messages_fts WHERE rowid = $rowId`);
|
||||||
|
const result2 = query.run({ $rowId: messageId });
|
||||||
|
}
|
||||||
|
events.emit('message-deleted', messageId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMessages = async (channelId: string) => {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT
|
||||||
|
messages.id, messages.channelId, messages.content, messages.createdAt,
|
||||||
|
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||||
|
FROM
|
||||||
|
messages
|
||||||
|
LEFT JOIN
|
||||||
|
files
|
||||||
|
ON
|
||||||
|
messages.fileId = files.id
|
||||||
|
WHERE
|
||||||
|
messages.channelId = $channelId
|
||||||
|
`);
|
||||||
|
const rows = query.all({ $channelId: channelId });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMessage = async (id: string) => {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT
|
||||||
|
messages.id, messages.channelId, messages.content, messages.createdAt,
|
||||||
|
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||||
|
FROM
|
||||||
|
messages
|
||||||
|
LEFT JOIN
|
||||||
|
files
|
||||||
|
ON
|
||||||
|
messages.fileId = files.id
|
||||||
|
WHERE
|
||||||
|
messages.id = $id
|
||||||
|
`);
|
||||||
|
const row = query.get({ $id: id });
|
||||||
|
return row;
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { db, FTS5Enabled } from "../db";
|
||||||
|
|
||||||
|
export const search = async (query: string, channelId?: string) => {
|
||||||
|
let sql: string;
|
||||||
|
let params: any;
|
||||||
|
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
if (channelId) {
|
||||||
|
sql = `
|
||||||
|
SELECT messages.id, messages.channelId, messages.content, messages.createdAt
|
||||||
|
FROM messages_fts
|
||||||
|
JOIN messages ON messages_fts.rowid = messages.id
|
||||||
|
WHERE messages_fts MATCH lower($query) AND messages.channelId = $channelId
|
||||||
|
`;
|
||||||
|
params = { $channelId: channelId, $query: (query || '').toString().toLowerCase() };
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
SELECT messages.id, messages.channelId, messages.content, messages.createdAt
|
||||||
|
FROM messages_fts
|
||||||
|
JOIN messages ON messages_fts.rowid = messages.id
|
||||||
|
WHERE messages_fts MATCH lower($query)
|
||||||
|
`;
|
||||||
|
params = { $query: (query || '').toString().toLowerCase() };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Performing search without FTS5. This might be very slow.");
|
||||||
|
if (channelId) {
|
||||||
|
sql = `
|
||||||
|
SELECT * FROM messages WHERE LOWER(content) LIKE '%' || LOWER($query) || '%' AND channelId = $channelId
|
||||||
|
`;
|
||||||
|
params = { $channelId: channelId, $query: query };
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
SELECT * FROM messages WHERE LOWER(content) LIKE '%' || LOWER($query) || '%'
|
||||||
|
`;
|
||||||
|
params = { $query: query };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlquery = db.query(sql);
|
||||||
|
const rows = sqlquery.all(params);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
import multer from "multer";
|
||||||
|
import { UPLOAD_DIR } from "../config";
|
||||||
|
|
||||||
|
export const upload = multer({ dest: UPLOAD_DIR });
|
|
@ -0,0 +1,54 @@
|
||||||
|
export enum TimeUnit {
|
||||||
|
SECOND = 1000,
|
||||||
|
MINUTE = 60 * 1000,
|
||||||
|
HOUR = 60 * 60 * 1000,
|
||||||
|
DAY = 24 * 60 * 60 * 1000,
|
||||||
|
WEEK = 7 * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Task = () => void;
|
||||||
|
|
||||||
|
export interface TaskEntry {
|
||||||
|
id: Timer;
|
||||||
|
task: Task;
|
||||||
|
remainingRuns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Scheduler {
|
||||||
|
private tasks: Map<string, TaskEntry> = new Map();
|
||||||
|
|
||||||
|
static toMilliseconds(time: number, unit: TimeUnit): number {
|
||||||
|
return time * unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(taskName: string, task: Task, delay: number, unit: TimeUnit, runs: number = Infinity): void {
|
||||||
|
if (this.tasks.has(taskName)) {
|
||||||
|
throw new Error(`Task ${taskName} is already registered.`);
|
||||||
|
}
|
||||||
|
const performTask = () => {
|
||||||
|
task();
|
||||||
|
const taskEntry = this.tasks.get(taskName);
|
||||||
|
if (taskEntry) {
|
||||||
|
taskEntry.remainingRuns--;
|
||||||
|
if (taskEntry.remainingRuns > 0) {
|
||||||
|
taskEntry.id = setTimeout(performTask, Scheduler.toMilliseconds(delay, unit));
|
||||||
|
} else {
|
||||||
|
this.tasks.delete(taskName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.tasks.set(taskName, { id: setTimeout(performTask, Scheduler.toMilliseconds(delay, unit)), task, remainingRuns: runs });
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(taskName: string): void {
|
||||||
|
const taskEntry = this.tasks.get(taskName);
|
||||||
|
if (taskEntry) {
|
||||||
|
clearTimeout(taskEntry.id);
|
||||||
|
this.tasks.delete(taskName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTasks(): Map<string, TaskEntry> {
|
||||||
|
return this.tasks;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
export interface Channel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
channel_id: number;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
id: number;
|
||||||
|
channel_id: number;
|
||||||
|
message_id: number;
|
||||||
|
file_path: string;
|
||||||
|
file_type: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Use the official Bun image
|
||||||
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install dependencies into temp directories
|
||||||
|
# This will cache them and speed up future builds
|
||||||
|
FROM base AS install
|
||||||
|
|
||||||
|
# Install dependencies for both backend and frontend
|
||||||
|
COPY backend/package.json backend/bun.lockb /temp/dev/backend/
|
||||||
|
COPY frontend/package.json frontend/bun.lockb /temp/dev/frontend/
|
||||||
|
|
||||||
|
RUN cd /temp/dev/backend && bun install
|
||||||
|
RUN cd /temp/dev/frontend && bun install
|
||||||
|
|
||||||
|
# Install with --production (exclude devDependencies)
|
||||||
|
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
|
||||||
|
COPY backend/package.json backend/bun.lockb /temp/prod/backend/
|
||||||
|
COPY frontend/package.json frontend/bun.lockb /temp/prod/frontend/
|
||||||
|
|
||||||
|
RUN cd /temp/prod/backend && bun install
|
||||||
|
RUN cd /temp/prod/frontend && bun install
|
||||||
|
|
||||||
|
# Build the frontend project
|
||||||
|
FROM install AS build-frontend
|
||||||
|
WORKDIR /usr/src/app/frontend
|
||||||
|
COPY --from=install /temp/dev/frontend/node_modules node_modules
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Prepare for final release
|
||||||
|
FROM base AS release
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Copy production dependencies
|
||||||
|
COPY --from=install /temp/prod/backend/node_modules backend/node_modules
|
||||||
|
COPY --from=install /temp/prod/frontend/node_modules frontend/node_modules
|
||||||
|
|
||||||
|
# Copy backend source code
|
||||||
|
COPY backend/ backend/
|
||||||
|
|
||||||
|
# Copy the built frontend assets into the backend public directory
|
||||||
|
COPY --from=build-frontend /usr/src/app/frontend/dist backend/public
|
||||||
|
|
||||||
|
# Set the entrypoint to run the backend server
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000/tcp
|
||||||
|
ENTRYPOINT [ "bun", "run", "backend/src/server.ts" ]
|
Binary file not shown.
|
@ -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>
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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[];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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[];
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
|
@ -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();
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 |
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue