Initial move
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
backend/.env
|
||||
.vscode
|
||||
README.md
|
||||
node_modules
|
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# notebrook-notes
|
||||
|
||||
Stream of consciousness note taking
|
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -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
|
175
backend/.gitignore
vendored
Normal file
175
backend/.gitignore
vendored
Normal file
@@ -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
|
15
backend/README.md
Normal file
15
backend/README.md
Normal file
@@ -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.
|
31
backend/migrations/1_init.sql
Normal file
31
backend/migrations/1_init.sql
Normal file
@@ -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'
|
||||
);
|
52
backend/migrations/2_localtime.sql
Normal file
52
backend/migrations/2_localtime.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- 1. Create a backup of the existing tables
|
||||
CREATE TABLE channels_backup AS SELECT * FROM channels;
|
||||
CREATE TABLE files_backup AS SELECT * FROM files;
|
||||
CREATE TABLE messages_backup AS SELECT * FROM messages;
|
||||
|
||||
-- 2. Drop the existing tables
|
||||
DROP TABLE channels;
|
||||
DROP TABLE files;
|
||||
DROP TABLE messages;
|
||||
|
||||
-- 3. Recreate the tables with the updated schema
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
createdAt DATETIME DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channelId INTEGER,
|
||||
filePath TEXT,
|
||||
fileType TEXT,
|
||||
fileSize INTEGER,
|
||||
originalName TEXT,
|
||||
createdAt DATETIME DEFAULT (datetime('now', 'localtime')),
|
||||
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 (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- 4. Migrate the data back from the backup tables
|
||||
INSERT INTO channels (id, name, createdAt)
|
||||
SELECT id, name, createdAt FROM channels_backup;
|
||||
|
||||
INSERT INTO files (id, channelId, filePath, fileType, fileSize, originalName, createdAt)
|
||||
SELECT id, channelId, filePath, fileType, fileSize, originalName, createdAt FROM files_backup;
|
||||
|
||||
INSERT INTO messages (id, channelId, content, fileId, createdAt)
|
||||
SELECT id, channelId, content, fileId, createdAt FROM messages_backup;
|
||||
|
||||
-- 5. Drop the backup tables
|
||||
DROP TABLE channels_backup;
|
||||
DROP TABLE files_backup;
|
||||
DROP TABLE messages_backup;
|
2435
backend/package-lock.json
generated
Normal file
2435
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
backend/package.json
Normal file
35
backend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "notebrook-backend",
|
||||
"module": "src/server.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/server.ts",
|
||||
"dev": "tsx --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",
|
||||
"better-sqlite3": "^11.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ollama": "^0.5.8",
|
||||
"openai": "^4.56.0",
|
||||
"selfsigned": "^2.4.1",
|
||||
"sharp": "^0.33.5",
|
||||
"tsx": "^4.18.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
31
backend/schema.sql
Normal file
31
backend/schema.sql
Normal file
@@ -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'
|
||||
);
|
27
backend/src/app.ts
Normal file
27
backend/src/app.ts
Normal file
@@ -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' });
|
||||
});
|
||||
|
21
backend/src/config.ts
Normal file
21
backend/src/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
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";
|
||||
export const PORT = parseInt(process.env["PORT"]!) || 3000;
|
||||
export const USE_SSL = process.env["USE_SSL"] === "1" ? true : false;
|
||||
export const SSL_KEY = process.env["SSL_KEY"] || "";
|
||||
export const SSL_CERT = process.env["SSL_CERT"] || "";
|
||||
console.log(process.env);
|
62
backend/src/controllers/channel-controller.ts
Normal file
62
backend/src/controllers/channel-controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response } from "express";
|
||||
import * as ChannelService from "../services/channel-service";
|
||||
import { logger } from "../globals";
|
||||
|
||||
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);
|
||||
logger.info(`Channel ${name} created`);
|
||||
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) {
|
||||
logger.warn(`Channel ${channelId} not found while deleting`);
|
||||
return res.status(404).json({ error: 'Channel not found' });
|
||||
}
|
||||
logger.info(`Channel ${channelId} deleted`);
|
||||
|
||||
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);
|
||||
logger.info(`Channel ${targetChannelId} merged into ${channelId}`);
|
||||
|
||||
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' });
|
||||
}
|
||||
logger.info(`Channel ${channelId} updated as ${name}`);
|
||||
|
||||
res.json({ message: 'Channel updated successfully' });
|
||||
}
|
33
backend/src/controllers/file-controller.ts
Normal file
33
backend/src/controllers/file-controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response } from "express";
|
||||
import * as FileService from "../services/file-service";
|
||||
import { logger } from "../globals";
|
||||
|
||||
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!);
|
||||
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
|
||||
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 });
|
||||
}
|
||||
|
54
backend/src/controllers/message-controller.ts
Normal file
54
backend/src/controllers/message-controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Request, Response } from "express";
|
||||
import * as MessageService from "../services/message-service";
|
||||
import { logger } from "../globals";
|
||||
|
||||
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);
|
||||
logger.info(`Message ${messageId} created in channel ${channelId}`);
|
||||
|
||||
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);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
logger.info(`Message ${messageId} updated`);
|
||||
|
||||
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' });
|
||||
}
|
||||
logger.info(`Message ${messageId} deleted`);
|
||||
|
||||
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 });
|
||||
}
|
13
backend/src/controllers/search-controller.ts
Normal file
13
backend/src/controllers/search-controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Request, Response } from "express";
|
||||
import * as SearchService from "../services/search-service";
|
||||
import { logger } from "../globals";
|
||||
|
||||
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);
|
||||
logger.info(`Searched for ${query}`);
|
||||
res.json({ results });
|
||||
}
|
29
backend/src/controllers/websocket-controller.ts
Normal file
29
backend/src/controllers/websocket-controller.ts
Normal file
@@ -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', data: {id, channelId, messageId, filePath, fileType, fileSize, originalName }}));
|
||||
});
|
||||
events.on('message-created', (id, channelId, content) => {
|
||||
ws.send(JSON.stringify({ type: 'message-created', data: {id, channelId, content }}));
|
||||
});
|
||||
events.on('message-updated', (id, content) => {
|
||||
ws.send(JSON.stringify({ type: 'message-updated', data: {id, content }}));
|
||||
});
|
||||
events.on('message-deleted', (id) => {
|
||||
ws.send(JSON.stringify({ type: 'message-deleted', data: {id }}));
|
||||
});
|
||||
events.on('channel-created', (channel) => {
|
||||
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
|
||||
});
|
||||
events.on('channel-deleted', (id) => {
|
||||
ws.send(JSON.stringify({ type: 'channel-deleted', data: {id} }));
|
||||
});
|
||||
events.on('channel-merged', (channelId, targetChannelId) => {
|
||||
ws.send(JSON.stringify({ type: 'channel-merged', data: {channelId, targetChannelId }}));
|
||||
});
|
||||
events.on('channel-updated', (id, name) => {
|
||||
ws.send(JSON.stringify({ type: 'channel-updated', data: {id, name }}));
|
||||
});
|
||||
}
|
67
backend/src/db.ts
Normal file
67
backend/src/db.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { DB_PATH } from './config';
|
||||
import { logger } from './globals';
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
export let FTS5Enabled = true;
|
||||
|
||||
export const initializeDB = () => {
|
||||
logger.info("Checking fts");
|
||||
const ftstest = db.prepare(`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;
|
||||
}
|
||||
}
|
||||
|
||||
return FTS5Enabled;
|
||||
}
|
||||
|
||||
export const migrate = async () => {
|
||||
logger.info(`Checking for migrations...`);
|
||||
const result = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='meta'`);
|
||||
if (result.all().length === 0) {
|
||||
logger.info(`Creating meta table...`);
|
||||
db.exec(`CREATE TABLE meta (version INTEGER)`);
|
||||
db.exec(`INSERT INTO meta (version) VALUES (-1)`);
|
||||
}
|
||||
|
||||
const version = db.prepare(`SELECT version FROM meta`).get() as { version: number };
|
||||
logger.info(`Migration version: ${version.version}`);
|
||||
// we are in bun.js. use its API's to read the file list.
|
||||
logger.info(`Searching for migrations in ${join("migrations")}`);
|
||||
const files = await readdir(join("migrations"));
|
||||
|
||||
for (const file of files) {
|
||||
const [fileVersion, ...rest] = file.split("_");
|
||||
logger.info(`Found migration ${fileVersion}`);
|
||||
if (fileVersion && Number(fileVersion) > version.version) {
|
||||
logger.info(`Running migration ${file}`);
|
||||
const sql = new TextDecoder().decode(await readFile(join(`migrations/${file}`)));
|
||||
db.exec(sql);
|
||||
const query = db.prepare(`UPDATE meta SET version = ($version)`);
|
||||
const res = query.run({ version: fileVersion })
|
||||
logger.info(`Migration ${file} done`);
|
||||
}
|
||||
}
|
||||
logger.info(`Migrations done`);
|
||||
}
|
||||
|
||||
logger.info(`Loading database at ${DB_PATH}`);
|
||||
|
||||
export const db = new Database(DB_PATH);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
initializeDB();
|
||||
migrate();
|
14
backend/src/globals.ts
Normal file
14
backend/src/globals.ts
Normal file
@@ -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();
|
||||
});
|
6
backend/src/image-describe-test.ts
Normal file
6
backend/src/image-describe-test.ts
Normal file
@@ -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"));
|
||||
})();
|
17
backend/src/jobs/describe-image.ts
Normal file
17
backend/src/jobs/describe-image.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Message } from "../../types";
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
7
backend/src/jobs/index.ts
Normal file
7
backend/src/jobs/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describeImageJob } from "./describe-image";
|
||||
import { scheduleVacuum } from "./vacuum";
|
||||
|
||||
export const jobs = [
|
||||
scheduleVacuum,
|
||||
describeImageJob
|
||||
]
|
9
backend/src/jobs/vacuum.ts
Normal file
9
backend/src/jobs/vacuum.ts
Normal file
@@ -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.exec('VACUUM');
|
||||
}, 1, TimeUnit.DAY);
|
||||
}
|
15
backend/src/logging/adapter.ts
Normal file
15
backend/src/logging/adapter.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
10
backend/src/logging/adapters/console-adapter.ts
Normal file
10
backend/src/logging/adapters/console-adapter.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
12
backend/src/logging/log-entry.ts
Normal file
12
backend/src/logging/log-entry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
additionalInfo?: any;
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
info,
|
||||
warning,
|
||||
critical
|
||||
}
|
49
backend/src/logging/logger.ts
Normal file
49
backend/src/logging/logger.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
15
backend/src/middleware/auth.ts
Normal file
15
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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'];
|
||||
if (!token) {
|
||||
return res.status(403).json({ error: 'No token provided' });
|
||||
}
|
||||
if (token === SECRET_KEY) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: "Unauthenticated" })
|
||||
}
|
||||
}
|
10
backend/src/routes/channel.ts
Normal file
10
backend/src/routes/channel.ts
Normal file
@@ -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);
|
9
backend/src/routes/file.ts
Normal file
9
backend/src/routes/file.ts
Normal file
@@ -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);
|
11
backend/src/routes/message.ts
Normal file
11
backend/src/routes/message.ts
Normal file
@@ -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);
|
||||
|
7
backend/src/routes/search.ts
Normal file
7
backend/src/routes/search.ts
Normal file
@@ -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);
|
55
backend/src/server.ts
Normal file
55
backend/src/server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { app } from "./app";
|
||||
import { createServer } from "http";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { attachEvents } from "./controllers/websocket-controller";
|
||||
import { logger } from "./globals";
|
||||
import selfSigned from "selfsigned";
|
||||
|
||||
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(PORT, () => {
|
||||
logger.info(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
const getOrCreateCertificate = async () => {
|
||||
if (process.env.USE_SSL === '1') {
|
||||
if (!process.env.SSL_KEY || !process.env.SSL_CERT) {
|
||||
return await createSelfSignedSSLCert();
|
||||
}
|
||||
return {
|
||||
key: process.env.SSL_KEY,
|
||||
cert: process.env.SSL_CERT
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const createSelfSignedSSLCert = async () => {
|
||||
const selfsigned = await import('selfsigned');
|
||||
const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], {
|
||||
keySize: 2048,
|
||||
days: 365
|
||||
});
|
||||
return {
|
||||
key: pems.private,
|
||||
cert: pems.cert
|
||||
};
|
||||
}
|
37
backend/src/services/channel-service.ts
Normal file
37
backend/src/services/channel-service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { db } from "../db";
|
||||
import { events } from "../globals";
|
||||
|
||||
export const createChannel = async (name: string) => {
|
||||
const query = db.prepare(`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.prepare(`DELETE FROM channels WHERE id = ($channelId)`);
|
||||
const result = query.run({channelId: 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.prepare(`SELECT * FROM channels`);
|
||||
const rows = query.all();
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const mergeChannel = async (channelId: string, targetChannelId: string) => {
|
||||
const query = db.prepare(`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.prepare(`UPDATE channels SET name = $name WHERE id = $id`);
|
||||
const result = query.run({ id: id, name: name });
|
||||
events.emit('channel-updated', id, name);
|
||||
return result;
|
||||
}
|
21
backend/src/services/file-service.ts
Normal file
21
backend/src/services/file-service.ts
Normal file
@@ -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.prepare(`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 });
|
||||
|
||||
const fileId = result.lastInsertRowid;
|
||||
|
||||
const updateQuery = db.prepare(`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.prepare(`SELECT * FROM files WHERE messageId = $messageId`);
|
||||
const rows = query.all({ messageId: messageId });
|
||||
return rows;
|
||||
}
|
83
backend/src/services/image-description.ts
Normal file
83
backend/src/services/image-description.ts
Normal file
@@ -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')}`;
|
||||
}
|
||||
|
83
backend/src/services/message-service.ts
Normal file
83
backend/src/services/message-service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { db, FTS5Enabled } from "../db";
|
||||
import { events } from "../globals";
|
||||
|
||||
export const createMessage = async (channelId: string, content: string) => {
|
||||
const query = db.prepare(`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.prepare(`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.prepare(`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.prepare(`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.prepare(`DELETE FROM messages WHERE id = $id`);
|
||||
const result = query.run({ id: messageId });
|
||||
|
||||
// Remove from FTS table if enabled
|
||||
if (FTS5Enabled) {
|
||||
const query2 = db.prepare(`DELETE FROM messages_fts WHERE rowid = $rowId`);
|
||||
const result2 = query2.run({ rowId: messageId });
|
||||
}
|
||||
events.emit('message-deleted', messageId);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const getMessages = async (channelId: string) => {
|
||||
const query = db.prepare(`
|
||||
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.prepare(`
|
||||
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;
|
||||
}
|
44
backend/src/services/search-service.ts
Normal file
44
backend/src/services/search-service.ts
Normal file
@@ -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.prepare(sql);
|
||||
const rows = sqlquery.all(params);
|
||||
|
||||
return rows;
|
||||
}
|
0
backend/src/services/websocket-service.ts
Normal file
0
backend/src/services/websocket-service.ts
Normal file
4
backend/src/utils/multer.ts
Normal file
4
backend/src/utils/multer.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import multer from "multer";
|
||||
import { UPLOAD_DIR } from "../config";
|
||||
|
||||
export const upload = multer({ dest: UPLOAD_DIR });
|
54
backend/src/utils/scheduler.ts
Normal file
54
backend/src/utils/scheduler.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
21
backend/types.ts
Normal file
21
backend/types.ts
Normal file
@@ -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;
|
||||
}
|
36
dockerfile
Normal file
36
dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM node:22-slim AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
|
||||
COPY backend/package.json backend/package-lock.json /temp/dev/backend/
|
||||
COPY frontend/package.json frontend/package-lock.json /temp/dev/frontend/
|
||||
|
||||
RUN cd /temp/dev/backend && npm install
|
||||
RUN cd /temp/dev/frontend && npm install
|
||||
|
||||
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
|
||||
COPY backend/package.json backend/package-lock.json /temp/prod/backend/
|
||||
COPY frontend/package.json frontend/package-lock.json /temp/prod/frontend/
|
||||
|
||||
RUN cd /temp/prod/backend && npm install --production
|
||||
RUN cd /temp/prod/frontend && npm install --production
|
||||
|
||||
FROM install AS build-frontend
|
||||
WORKDIR /usr/src/app/frontend
|
||||
COPY frontend/ .
|
||||
COPY --from=install /temp/dev/frontend/node_modules node_modules
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS release
|
||||
WORKDIR /usr/src/app
|
||||
COPY backend/ backend/
|
||||
COPY --from=install /temp/prod/backend/node_modules backend/node_modules
|
||||
COPY --from=install /temp/prod/frontend/node_modules frontend/node_modules
|
||||
|
||||
COPY --from=build-frontend /usr/src/app/frontend/dist backend/public
|
||||
|
||||
USER node
|
||||
WORKDIR /usr/src/app/backend
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT [ "npm", "run", "start"]
|
14
etc/systemd/backend.service
Normal file
14
etc/systemd/backend.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=backend Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=notebrook
|
||||
WorkingDirectory=/home/notebrook/backend
|
||||
ExecStart=/usr/bin/npm start
|
||||
Restart=always
|
||||
Environment=PATH=/usr/bin:/usr/local/bin
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
14
etc/systemd/frontend.service
Normal file
14
etc/systemd/frontend.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=frontend Service notebrook
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=notebrook
|
||||
WorkingDirectory=/home/notebrook/frontend
|
||||
ExecStart=/usr/bin/npm run dev -- --host
|
||||
Restart=always
|
||||
Environment=PATH=/usr/bin:/usr/local/bin
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
75
frontend/index.html
Normal file
75
frontend/index.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notebrook</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Theme Color (For Mobile idk) -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<!-- PWA Metadata -->
|
||||
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
|
||||
<meta name="application-name" content="Notebrook" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Notebrook" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
|
||||
|
||||
<style>
|
||||
/* Basic styles for the toasts */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body role="application">
|
||||
<div id="app"></div>
|
||||
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
31
frontend/manifest.webmanifest
Normal file
31
frontend/manifest.webmanifest
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Notebrook",
|
||||
"short_name": "Notebrook",
|
||||
"description": "Stream of conciousness accessible note taking",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
4949
frontend/package-lock.json
generated
Normal file
4949
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "notebrook-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
BIN
frontend/public/intro.wav
Normal file
BIN
frontend/public/intro.wav
Normal file
Binary file not shown.
BIN
frontend/public/login.wav
Normal file
BIN
frontend/public/login.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent1.wav
Normal file
BIN
frontend/public/sent1.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent2.wav
Normal file
BIN
frontend/public/sent2.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent3.wav
Normal file
BIN
frontend/public/sent3.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent4.wav
Normal file
BIN
frontend/public/sent4.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent5.wav
Normal file
BIN
frontend/public/sent5.wav
Normal file
Binary file not shown.
BIN
frontend/public/sent6.wav
Normal file
BIN
frontend/public/sent6.wav
Normal file
Binary file not shown.
BIN
frontend/public/uploadfail.wav
Normal file
BIN
frontend/public/uploadfail.wav
Normal file
Binary file not shown.
BIN
frontend/public/water1.wav
Normal file
BIN
frontend/public/water1.wav
Normal file
Binary file not shown.
BIN
frontend/public/water10.wav
Normal file
BIN
frontend/public/water10.wav
Normal file
Binary file not shown.
BIN
frontend/public/water2.wav
Normal file
BIN
frontend/public/water2.wav
Normal file
Binary file not shown.
BIN
frontend/public/water3.wav
Normal file
BIN
frontend/public/water3.wav
Normal file
Binary file not shown.
BIN
frontend/public/water4.wav
Normal file
BIN
frontend/public/water4.wav
Normal file
Binary file not shown.
BIN
frontend/public/water5.wav
Normal file
BIN
frontend/public/water5.wav
Normal file
Binary file not shown.
BIN
frontend/public/water6.wav
Normal file
BIN
frontend/public/water6.wav
Normal file
Binary file not shown.
BIN
frontend/public/water7.wav
Normal file
BIN
frontend/public/water7.wav
Normal file
Binary file not shown.
BIN
frontend/public/water8.wav
Normal file
BIN
frontend/public/water8.wav
Normal file
Binary file not shown.
BIN
frontend/public/water9.wav
Normal file
BIN
frontend/public/water9.wav
Normal file
Binary file not shown.
103
frontend/src/api.ts
Normal file
103
frontend/src/api.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { IChannel } from "./model/channel";
|
||||
import { IChannelList } from "./model/channel-list";
|
||||
import { IMessage } from "./model/message";
|
||||
import { IUnsentMessage } from "./model/unsent-message";
|
||||
import { state } from "./state";
|
||||
|
||||
|
||||
export const API = {
|
||||
token: "",
|
||||
path: "http://localhost:3000",
|
||||
|
||||
async request(method: string, path: string, body?: any) {
|
||||
if (!API.token) {
|
||||
throw new Error("API token was not set.");
|
||||
}
|
||||
return fetch(`${API.path}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async checkToken() {
|
||||
const response = await API.request("GET", "check-token");
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Invalid token in request");
|
||||
}
|
||||
},
|
||||
|
||||
async getChannels() {
|
||||
const response = await API.request("GET", "channels");
|
||||
const json = await response.json();
|
||||
return json.channels as IChannel[];
|
||||
},
|
||||
|
||||
async getChannel(id: string) {
|
||||
const response = await API.request("GET", `channels/${id}`);
|
||||
const json = await response.json();
|
||||
return json.channel as IChannel;
|
||||
},
|
||||
|
||||
async createChannel(name: string) {
|
||||
const response = await API.request("POST", "channels", { name });
|
||||
const json = await response.json();
|
||||
return json 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("PUT", `channels/${channelId}/merge`, { targetChannelId });
|
||||
},
|
||||
|
||||
async search(query: string, channelId?: string) {
|
||||
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
|
||||
const response = await API.request("GET", queryPath);
|
||||
const json = await response.json();
|
||||
return json.results as IMessage[];
|
||||
},
|
||||
|
||||
async getFiles(channelId: string, messageId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
|
||||
const json = await response.json();
|
||||
return json.files as string[];
|
||||
}
|
||||
}
|
25
frontend/src/chunk-processor.ts
Normal file
25
frontend/src/chunk-processor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class ChunkProcessor<T> {
|
||||
private chunkSize: number;
|
||||
|
||||
constructor(chunkSize: number = 1000) {
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
const totalChunks = Math.ceil(array.length / this.chunkSize);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
|
||||
await this.processChunk(chunk, callback);
|
||||
}
|
||||
}
|
||||
|
||||
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
callback(chunk);
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
68
frontend/src/dialogs/channel-dialog.ts
Normal file
68
frontend/src/dialogs/channel-dialog.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { IChannel } from "../model/channel";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
import { RemoveDialog } from "./remove-dialog";
|
||||
|
||||
export class ChannelDialog extends Dialog<IChannel | null> {
|
||||
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(() => {
|
||||
this.mergeChannel();
|
||||
});
|
||||
if (state.channelList.channels.length === 1) {
|
||||
this.mergeButton.setDisabled(true);
|
||||
}
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(60, 70, 10, 10);
|
||||
this.deleteButton.onClick(() => {
|
||||
this.deleteChannel();
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
private async mergeChannel() {
|
||||
const res = await new MergeDialog().open();
|
||||
if (res) {
|
||||
this.choose(this.channel);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteChannel() {
|
||||
const res = await new RemoveDialog(this.channel.id.toString()).open();
|
||||
if (res) {
|
||||
this.choose(null);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
22
frontend/src/dialogs/create-channel.ts
Normal file
22
frontend/src/dialogs/create-channel.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
});
|
||||
this.nameField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.choose(this.nameField.getValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
51
frontend/src/dialogs/merge-dialog.ts
Normal file
51
frontend/src/dialogs/merge-dialog.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { API } from "../api";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class MergeDialog extends Dialog<boolean> {
|
||||
private channelList: Dropdown;
|
||||
private mergeButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Merge channels", false);
|
||||
this.channelList = new Dropdown("Target channel", []);
|
||||
this.channelList.setPosition(10, 10, 80, 20);
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(30, 30, 40, 30);
|
||||
this.mergeButton.onClick(() => this.merge());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.channelList);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.cancelButton);
|
||||
this.setupChannelList();
|
||||
}
|
||||
|
||||
private setupChannelList() {
|
||||
this.channelList.clearOptions();
|
||||
state.channelList.getChannels().forEach((channel) => {
|
||||
if (channel.id !== state.currentChannel!.id) this.channelList.addOption(channel.id.toString(), channel.name);
|
||||
})
|
||||
}
|
||||
private async merge() {
|
||||
const currentChannel = state.currentChannel;
|
||||
const target = this.channelList.getSelectedValue();
|
||||
const targetChannel = state.getChannelById(parseInt(target));
|
||||
console.log(currentChannel, targetChannel);
|
||||
if (!targetChannel || !currentChannel) this.cancel();
|
||||
try {
|
||||
const res = await API.mergeChannels(currentChannel!.id.toString(), target);
|
||||
currentChannel!.messages = [];
|
||||
showToast("Channels were merged.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to merge channels: " + e);
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
50
frontend/src/dialogs/message.ts
Normal file
50
frontend/src/dialogs/message.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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";
|
||||
import { state } from "../state";
|
||||
export class MessageDialog extends Dialog<IMessage | null> {
|
||||
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(async () => {
|
||||
await API.deleteMessage(state.currentChannel!.id.toString(), this.message.id.toString());
|
||||
this.choose(null);
|
||||
});
|
||||
this.add(this.messageText);
|
||||
this.add(this.deleteButton);
|
||||
if (this.message.fileId !== null) {
|
||||
this.fileInfoContainer = new Container("File info");
|
||||
this.fileInfoContainer.setPosition(10, 50, 30, 80);
|
||||
this.add(this.fileInfoContainer);
|
||||
this.handleMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage() {
|
||||
if (this.message?.fileType?.toLowerCase().includes("audio")) {
|
||||
const audio = new Audio(`${API.path}/${this.message.filePath}`);
|
||||
audio.autoplay = true;
|
||||
}
|
||||
|
||||
// display info about files, or the image if it is an image. Also display all metadata.
|
||||
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
|
||||
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
|
||||
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
|
||||
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
|
||||
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
|
||||
}
|
||||
}
|
72
frontend/src/dialogs/record-audio.ts
Normal file
72
frontend/src/dialogs/record-audio.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Button } from "../ui";
|
||||
import { Audio } from "../ui/audio";
|
||||
import { AudioRecorder } from "../ui/audio-recorder";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class RecordAudioDialog extends Dialog<Blob> {
|
||||
private audioRecorder: AudioRecorder;
|
||||
private recordButton: Button;
|
||||
private stopButton: Button;
|
||||
private playButton: Button;
|
||||
private saveButton: Button;
|
||||
private discardButton: Button;
|
||||
private audioBlob: Blob | undefined;
|
||||
private audioPlayer?: Audio;
|
||||
|
||||
constructor() {
|
||||
super("Record audio", false);
|
||||
this.audioRecorder = new AudioRecorder("Record from microphone");
|
||||
this.audioRecorder.onRecordingComplete(() => {
|
||||
this.audioBlob = this.audioRecorder.getRecording();
|
||||
this.saveButton.setDisabled(false);
|
||||
});
|
||||
this.recordButton = new Button("Record");
|
||||
this.recordButton.setPosition(30, 30, 40, 30);
|
||||
this.recordButton.onClick(() => this.startRecording());
|
||||
this.stopButton = new Button("Stop");
|
||||
this.stopButton.setPosition(70, 40, 30, 30);
|
||||
this.stopButton.onClick(() => this.stopRecording());
|
||||
this.stopButton.setDisabled(true);
|
||||
this.saveButton = new Button("Save");
|
||||
this.saveButton.setPosition(10, 80, 50, 20);
|
||||
this.saveButton.onClick(() => this.saveRecording());
|
||||
this.saveButton.setDisabled(true);
|
||||
this.playButton = new Button("Play");
|
||||
this.playButton.setPosition(0, 40, 30, 30);
|
||||
this.playButton.onClick(() => {
|
||||
if (this.audioBlob) {
|
||||
this.audioPlayer = new Audio("Recorded audio");
|
||||
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
|
||||
this.audioPlayer.play();
|
||||
}
|
||||
});
|
||||
this.playButton.setDisabled(true);
|
||||
this.discardButton = new Button("Discard");
|
||||
this.discardButton.setPosition(50, 90, 50, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.recordButton);
|
||||
this.add(this.stopButton);
|
||||
this.add(this.playButton);
|
||||
this.add(this.saveButton);
|
||||
this.add(this.discardButton);
|
||||
}
|
||||
|
||||
private startRecording() {
|
||||
this.audioRecorder.startRecording();
|
||||
this.stopButton.setDisabled(false);
|
||||
this.recordButton.setDisabled(true);
|
||||
}
|
||||
|
||||
private stopRecording() {
|
||||
this.audioRecorder.stopRecording();
|
||||
this.recordButton.setDisabled(false);
|
||||
this.stopButton.setDisabled(true);
|
||||
this.playButton.setDisabled(false);
|
||||
}
|
||||
|
||||
private saveRecording() {
|
||||
if (this.audioBlob) {
|
||||
this.choose(this.audioBlob);
|
||||
}
|
||||
}
|
||||
}
|
39
frontend/src/dialogs/remove-dialog.ts
Normal file
39
frontend/src/dialogs/remove-dialog.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class RemoveDialog extends Dialog<boolean> {
|
||||
private content: Text;
|
||||
private confirmButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor(channelId: string) {
|
||||
super("Remove channel", false);
|
||||
this.content = new Text("Are you sure you want to remove this channel?");
|
||||
this.confirmButton = new Button("Remove");
|
||||
this.confirmButton.setPosition(30, 30, 40, 30);
|
||||
this.confirmButton.onClick(() => this.doRemove());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.content);
|
||||
this.add(this.confirmButton);
|
||||
this.add(this.cancelButton);
|
||||
}
|
||||
|
||||
private async doRemove() {
|
||||
try {
|
||||
const res = await API.deleteChannel(state.currentChannel!.id.toString());
|
||||
state.removeChannel(state.currentChannel!);
|
||||
showToast("Channel was removed.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to remove channel: " + e);
|
||||
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
48
frontend/src/dialogs/search.ts
Normal file
48
frontend/src/dialogs/search.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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.searchField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.searchButton.click();
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
this.resultsList.focus();
|
||||
}
|
||||
}
|
23
frontend/src/dialogs/settings.ts
Normal file
23
frontend/src/dialogs/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { state } from "../state";
|
||||
|
||||
export class SettingsDialog extends Dialog<void> {
|
||||
private resetButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Settings");
|
||||
this.resetButton = new Button("Reset frontend");
|
||||
this.resetButton.setPosition(30, 20, 30, 30);
|
||||
this.resetButton.onClick(() => {
|
||||
this.reset();
|
||||
});
|
||||
this.add(this.resetButton);
|
||||
}
|
||||
|
||||
private reset() {
|
||||
state.clear().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
30
frontend/src/dialogs/take-photo.ts
Normal file
30
frontend/src/dialogs/take-photo.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { Button } from "../ui";
|
||||
import { Camera } from "../ui/camera";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class TakePhotoDialog extends Dialog<Blob> {
|
||||
private camera: Camera;
|
||||
private takePhotoButton: Button;
|
||||
private discardButton: Button;
|
||||
|
||||
constructor() {
|
||||
super("Take photo", false);
|
||||
this.camera = new Camera("Photo camera");
|
||||
this.camera.setPosition(10, 15, 80, 75);
|
||||
this.camera.startCamera();
|
||||
this.takePhotoButton = new Button("Take photo");
|
||||
this.takePhotoButton.setPosition(10, 90, 80, 10);
|
||||
this.discardButton = new Button("Cancel");
|
||||
this.discardButton.setPosition(5, 5, 10, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.camera);
|
||||
this.add(this.takePhotoButton);
|
||||
this.add(this.discardButton);
|
||||
this.takePhotoButton.onClick(async () => {
|
||||
const photo = await this.camera.savePhotoToBlob();
|
||||
if (photo) this.choose(photo);
|
||||
});
|
||||
}
|
||||
}
|
28
frontend/src/events/message-events.ts
Normal file
28
frontend/src/events/message-events.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type MessageCreated = {
|
||||
channelId: string,
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type MessageDeleted = {
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
};
|
||||
|
||||
export type MessageUpdated = {
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type ChannelCreated = {
|
||||
name: string,
|
||||
};
|
||||
|
||||
export type ChannelDeleted = {
|
||||
channelId: string,
|
||||
};
|
||||
|
||||
export type ChannelUpdated = {
|
||||
channelId: string,
|
||||
name: string,
|
||||
};
|
60
frontend/src/events/messaging-system.ts
Normal file
60
frontend/src/events/messaging-system.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type Message<T> = {
|
||||
type: string,
|
||||
data?: T,
|
||||
};
|
||||
|
||||
export type MessageHandler<T> = (message: Message<T>) => void;
|
||||
|
||||
export class MessagingSystem {
|
||||
private handlers: Record<string, MessageHandler<any>[]> = {};
|
||||
|
||||
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (!this.handlers[type]) {
|
||||
this.handlers[type] = [];
|
||||
}
|
||||
if (!this.handlers[type].includes(handler)) {
|
||||
this.handlers[type].push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (this.handlers[type]) {
|
||||
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
|
||||
}
|
||||
}
|
||||
|
||||
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
|
||||
const wrappedHandler = (message: Message<T>) => {
|
||||
handler(message);
|
||||
this.unregisterHandler(type, wrappedHandler);
|
||||
};
|
||||
this.registerHandler(type, wrappedHandler);
|
||||
}
|
||||
|
||||
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (message: Message<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(message.data!);
|
||||
this.unregisterHandler(type, handler);
|
||||
};
|
||||
|
||||
this.registerHandler(type, handler);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeout) {
|
||||
timer = setTimeout(() => {
|
||||
this.unregisterHandler(type, handler);
|
||||
reject(new Error(`Timeout waiting for message of type '${type}'`));
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendMessage<T>(message: Message<T>): void {
|
||||
const handlers = this.handlers[message.type];
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(message));
|
||||
}
|
||||
}
|
||||
}
|
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import './style.css'
|
||||
import { MainView } from "./views/main";
|
||||
import { ViewManager } from './views/view-manager';
|
||||
import { AuthorizeView } from './views/authorize';
|
||||
import { state } from './state';
|
||||
import { API } from './api';
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await state.load();
|
||||
const vm = new ViewManager();
|
||||
setInterval(() => {
|
||||
state.save();
|
||||
}, 10000);
|
||||
|
||||
if (state.token === "" || state.apiUrl === "") {
|
||||
vm.push(new AuthorizeView(vm));
|
||||
} else {
|
||||
vm.push(new MainView(vm));
|
||||
}
|
||||
document.body.appendChild(vm.render() as HTMLElement);
|
||||
});
|
46
frontend/src/model/channel-list.ts
Normal file
46
frontend/src/model/channel-list.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Channel, IChannel } from "./channel";
|
||||
|
||||
export interface IChannelList {
|
||||
channels: IChannel[]
|
||||
}
|
||||
|
||||
export class ChannelList implements IChannelList {
|
||||
channels: Channel[] = [];
|
||||
|
||||
constructor(channels?: IChannelList) {
|
||||
this.channels = channels?.channels?.map((chan) => new Channel(chan)) || [];
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel): void {
|
||||
this.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channelId: number): void {
|
||||
this.channels = this.channels.filter(channel => channel.id !== channelId);
|
||||
}
|
||||
|
||||
public getChannel(channelId: number): Channel|undefined {
|
||||
return this.channels.find(channel => channel.id === channelId);
|
||||
}
|
||||
|
||||
public getChannelByName(channelName: string): IChannel|undefined {
|
||||
return this.channels.find(channel => channel.name === channelName);
|
||||
}
|
||||
|
||||
public getChannels(): Channel[] {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public getChannelIds(): number[] {
|
||||
return this.channels.map(channel => channel.id);
|
||||
}
|
||||
|
||||
public getChannelNames(): string[] {
|
||||
return this.channels.map(channel => channel.name);
|
||||
}
|
||||
|
||||
public getChannelId(channelName: string): number|undefined {
|
||||
const channel = this.getChannelByName(channelName);
|
||||
return channel ? channel.id : undefined;
|
||||
}
|
||||
}
|
60
frontend/src/model/channel.ts
Normal file
60
frontend/src/model/channel.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { IMessage, Message } from "./message";
|
||||
|
||||
export interface IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: IMessage[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class Channel implements IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
private messageToIdMap: Map<number, Message>;
|
||||
|
||||
constructor(channel: IChannel) {
|
||||
this.id = channel.id;
|
||||
this.name = channel.name;
|
||||
this.messages = [];
|
||||
this.messageToIdMap = new Map();
|
||||
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
|
||||
this.createdAt = channel.createdAt;
|
||||
}
|
||||
|
||||
public addMessage(message: Message): void {
|
||||
this.messages.push(message);
|
||||
this.messageToIdMap.set(message.id, message);
|
||||
}
|
||||
|
||||
public removeMessage(messageId: number): void {
|
||||
this.messages = this.messages.filter(message => message.id !== messageId);
|
||||
this.messageToIdMap.delete(messageId);
|
||||
}
|
||||
|
||||
public getMessage(messageId: number): Message|undefined {
|
||||
return this.messageToIdMap.get(messageId);
|
||||
}
|
||||
|
||||
public getMessageByContent(content: string): Message|undefined {
|
||||
return this.messages.find(message => message.content === content);
|
||||
}
|
||||
|
||||
public getMessages(): Message[] {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
public getMessageIds(): number[] {
|
||||
return this.messages.map(message => message.id);
|
||||
}
|
||||
|
||||
public getMessageContents(): string[] {
|
||||
return this.messages.map(message => message.content);
|
||||
}
|
||||
|
||||
public getMessageId(content: string): number|undefined {
|
||||
const message = this.getMessageByContent(content);
|
||||
return message ? message.id : undefined;
|
||||
}
|
||||
}
|
33
frontend/src/model/message.ts
Normal file
33
frontend/src/model/message.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface IMessage {
|
||||
id: number;
|
||||
channelId?: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class Message implements IMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(message: IMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.fileId = message.fileId;
|
||||
this.fileType = message.fileType;
|
||||
this.filePath = message.filePath;
|
||||
this.fileSize = message.fileSize;
|
||||
this.originalName = message.originalName;
|
||||
this.createdAt = message.createdAt;
|
||||
}
|
||||
}
|
10
frontend/src/model/state.ts
Normal file
10
frontend/src/model/state.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IChannelList } from "./channel-list";
|
||||
import { IUnsentMessage } from "./unsent-message";
|
||||
|
||||
export interface IState {
|
||||
token: string;
|
||||
apiUrl: string;
|
||||
defaultChannelId: number;
|
||||
channelList: IChannelList;
|
||||
unsentMessages: IUnsentMessage[];
|
||||
}
|
23
frontend/src/model/unsent-message.ts
Normal file
23
frontend/src/model/unsent-message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
}
|
||||
|
||||
export class UnsentMessage implements IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
|
||||
constructor(message: IUnsentMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.blob = message.blob;
|
||||
this.createdAt = message.createdAt;
|
||||
this.channelId = message.channelId;
|
||||
}
|
||||
}
|
62
frontend/src/service-worker.ts
Normal file
62
frontend/src/service-worker.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const CACHE_NAME = 'notebrook-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/intro.wav',
|
||||
'/login.wav',
|
||||
'/uploadfail.wav',
|
||||
'/water1.wav',
|
||||
'/water2.wav',
|
||||
'/water3.wav',
|
||||
'/water4.wav',
|
||||
'/water5.wav',
|
||||
'/water6.wav',
|
||||
'/water7.wav',
|
||||
'/water8.wav',
|
||||
'/water9.wav',
|
||||
'/water10.wav',
|
||||
'/sent1.wav',
|
||||
'/sent2.wav',
|
||||
'/sent3.wav',
|
||||
'/sent4.wav',
|
||||
'/sent5.wav',
|
||||
'/sent6.wav',
|
||||
'/vite.svg',
|
||||
'/src/main.ts'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event: any) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: any) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return the cached response if found, otherwise fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event: any) => {
|
||||
const cacheWhitelist = [CACHE_NAME];
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
80
frontend/src/sound.ts
Normal file
80
frontend/src/sound.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
const soundFiles = {
|
||||
intro: 'intro.wav',
|
||||
login: 'login.wav',
|
||||
uploadFailed: 'uploadfail.wav'
|
||||
} as const;
|
||||
|
||||
type SoundName = keyof typeof soundFiles;
|
||||
|
||||
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
|
||||
|
||||
const waterSounds: AudioBuffer[] = [];
|
||||
const sentSounds: AudioBuffer[] = [];
|
||||
|
||||
async function loadSound(url: string): Promise<AudioBuffer> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer);
|
||||
}
|
||||
|
||||
async function loadAllSounds() {
|
||||
for (const key in soundFiles) {
|
||||
const soundName = key as SoundName;
|
||||
sounds[soundName] = await loadSound(soundFiles[soundName]);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`water${i}.wav`);
|
||||
waterSounds.push(buffer);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`sent${i}.wav`);
|
||||
sentSounds.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function playSoundBuffer(buffer: AudioBuffer) {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName) {
|
||||
const buffer = sounds[name];
|
||||
if (buffer) {
|
||||
playSoundBuffer(buffer);
|
||||
} else {
|
||||
console.error(`Sound ${name} not loaded.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function playWater() {
|
||||
if (waterSounds.length > 0) {
|
||||
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Water sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
export function playSent() {
|
||||
if (sentSounds.length > 0) {
|
||||
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Sent sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
loadAllSounds().then(() => {
|
||||
console.log('All sounds loaded and ready to play');
|
||||
}).catch(error => {
|
||||
console.error('Error loading sounds:', error);
|
||||
});
|
14
frontend/src/speech.ts
Normal file
14
frontend/src/speech.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Toast } from "./toast";
|
||||
|
||||
export function speak(text: string, interrupt: boolean = false) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
if (interrupt) {
|
||||
speechSynthesis.cancel();
|
||||
}
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export function showToast(message: string, timeout: number = 5000) {
|
||||
const toast = new Toast(timeout);
|
||||
toast.show(message);
|
||||
}
|
137
frontend/src/state.ts
Normal file
137
frontend/src/state.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { MessagingSystem } from "./events/messaging-system";
|
||||
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;
|
||||
public events: MessagingSystem;
|
||||
|
||||
constructor() {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.events = new MessagingSystem();
|
||||
}
|
||||
|
||||
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, events, ...state } = this;
|
||||
await set("notebrook", state);
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
const saved = await get("notebrook");
|
||||
if (saved) {
|
||||
this.token = saved.token;
|
||||
this.apiUrl = saved.apiUrl;
|
||||
this.channelList = new ChannelList( saved.channelList);
|
||||
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
|
||||
this.defaultChannelId = saved.defaultChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.currentChannel = null;
|
||||
this.defaultChannelId = -1;
|
||||
|
||||
await clear();
|
||||
}
|
||||
|
||||
public getChannelById(id: number) {
|
||||
return this.channelList.getChannel(id);
|
||||
}
|
||||
|
||||
public getChannelByName(name: string) {
|
||||
return this.channelList.getChannelByName(name);
|
||||
}
|
||||
|
||||
public findChannelByQuery(query: string) {
|
||||
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel) {
|
||||
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channel: IChannel) {
|
||||
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
|
||||
}
|
||||
|
||||
public addUnsentMessage(message: UnsentMessage) {
|
||||
this.unsentMessages.push(message);
|
||||
}
|
||||
|
||||
public removeUnsentMessage(message: IUnsentMessage) {
|
||||
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
|
||||
}
|
||||
|
||||
public getChannels() {
|
||||
return this.channelList.channels;
|
||||
}
|
||||
|
||||
public getCurrentChannel() {
|
||||
return this.currentChannel;
|
||||
}
|
||||
|
||||
public setCurrentChannel(channel: Channel) {
|
||||
this.currentChannel = channel;
|
||||
}
|
||||
|
||||
public getDefaultChannelId() {
|
||||
return this.defaultChannelId;
|
||||
}
|
||||
|
||||
public setDefaultChannelId(id: number) {
|
||||
this.defaultChannelId = id;
|
||||
}
|
||||
|
||||
public getApiUrl() {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
public setApiUrl(url: string) {
|
||||
this.apiUrl = url;
|
||||
}
|
||||
|
||||
public getMessageById(id: number) {
|
||||
return this.currentChannel!.getMessage(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new State();
|
96
frontend/src/style.css
Normal file
96
frontend/src/style.css
Normal file
@@ -0,0 +1,96 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #3178c6aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
32
frontend/src/toast.ts
Normal file
32
frontend/src/toast.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export class Toast {
|
||||
private container: HTMLElement;
|
||||
private timeout: number;
|
||||
|
||||
constructor(timeout: number = 3000) {
|
||||
this.container = document.querySelector('.toast-container') as HTMLElement;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public show(message: string): void {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.hide(toast);
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
private hide(toast: HTMLElement): void {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
}
|
1
frontend/src/typescript.svg
Normal file
1
frontend/src/typescript.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
After Width: | Height: | Size: 1.4 KiB |
76
frontend/src/ui/audio-recorder.ts
Normal file
76
frontend/src/ui/audio-recorder.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
protected triggerRecordingComplete(audioUrl: string) {
|
||||
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
|
||||
this.element.dispatchEvent(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
public getRecording() {
|
||||
return this.recording;
|
||||
}
|
||||
}
|
66
frontend/src/ui/audio.ts
Normal file
66
frontend/src/ui/audio.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public play() {
|
||||
this.audioElement.play();
|
||||
return this;
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this.audioElement.pause();
|
||||
return this;
|
||||
}
|
||||
|
||||
public setControls(show: boolean) {
|
||||
this.audioElement.controls = show;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setLoop(loop: boolean) {
|
||||
this.audioElement.loop = loop;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean) {
|
||||
this.audioElement.muted = muted;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAutoplay(autoplay: boolean) {
|
||||
this.audioElement.autoplay = autoplay;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setVolume(volume: number) {
|
||||
this.audioElement.volume = volume;
|
||||
return this;
|
||||
}
|
||||
}
|
39
frontend/src/ui/button.ts
Normal file
39
frontend/src/ui/button.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
return this;
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.buttonElement.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.buttonElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.buttonElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
return this;
|
||||
}
|
||||
|
||||
public setDisabled(val: boolean) {
|
||||
this.buttonElement.disabled = val;
|
||||
return this;
|
||||
}
|
||||
}
|
77
frontend/src/ui/camera.ts
Normal file
77
frontend/src/ui/camera.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Camera extends UINode {
|
||||
private videoElement: HTMLVideoElement;
|
||||
private canvasElement: HTMLCanvasElement;
|
||||
private stream: MediaStream | null;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.videoElement = document.createElement("video");
|
||||
this.canvasElement = document.createElement("canvas");
|
||||
this.stream = null;
|
||||
|
||||
this.videoElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.videoElement);
|
||||
this.element.appendChild(this.canvasElement);
|
||||
|
||||
this.setRole("camera");
|
||||
}
|
||||
|
||||
public async startCamera() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
this.videoElement.srcObject = this.stream;
|
||||
this.videoElement.play();
|
||||
} catch (error) {
|
||||
console.error("Error accessing camera:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.videoElement.pause();
|
||||
this.videoElement.srcObject = null;
|
||||
}
|
||||
|
||||
public takePhoto(): HTMLCanvasElement | null {
|
||||
if (this.stream) {
|
||||
const context = this.canvasElement.getContext("2d");
|
||||
if (context) {
|
||||
this.canvasElement.width = this.videoElement.videoWidth;
|
||||
this.canvasElement.height = this.videoElement.videoHeight;
|
||||
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhoto(): string | null {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
return photoCanvas.toDataURL("image/png");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhotoToBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
photoCanvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
}
|
26
frontend/src/ui/canvas.ts
Normal file
26
frontend/src/ui/canvas.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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();
|
||||
return this;
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.canvasElement.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user