Add files

main
Talon 2024-08-23 16:45:28 +02:00
parent 2b8a6e1428
commit 448e25de50
128 changed files with 5134 additions and 0 deletions

3
.dockerignore 100644
View File

@ -0,0 +1,3 @@
.env
.vscode
README.md

View 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 100644
View 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 100644
View 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.

BIN
backend/bun.lockb 100644

Binary file not shown.

View File

@ -0,0 +1,31 @@
{
"name": "notebrook-backend",
"module": "src/server.ts",
"type": "module",
"scripts": {
"start": "bun run src/server.ts",
"dev": "bun run --watch src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.5.4"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/ws": "^8.5.12",
"cors": "^2.8.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"ollama": "^0.5.8",
"openai": "^4.56.0",
"sharp": "^0.33.5",
"ws": "^8.18.0"
}
}

31
backend/schema.sql 100644
View 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 100644
View 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' });
});

View File

@ -0,0 +1,15 @@
export const DB_PATH = process.env["DB_PATH"] || "/usr/src/app/data/db.sqlite";
export const SECRET_KEY = process.env["API_TOKEN"] || "";
export const UPLOAD_DIR = process.env["UPLOAD_DIR"] || "/usr/src/app/data/uploads/";
export const FRONTEND_DIR = process.env["FRONTEND_DIR"] || "/usr/src/app/backend/public";
export const DESCRIBE_IMAGES: boolean = process.env["DESCRIBE_IMAGES"] === "1" ? true : false;
export const DESCRIBE_IMAGES_API = process.env["DESCRIBE_IMAGES_API"] || "ollama";
export const DESCRIBE_IMAGES_PROMPT= process.env["DESCRIBE_IMAGES_PROMPT"] || "Describe this image.";
export const DESCRIBE_IMAGES_TEMPERATURE= parseFloat(process.env["DESCRIBE_IMAGES_TEMPERATURE"]!) || 0.5;
export const DESCRIBE_IMAGES_MAX_TOKENS= parseInt(process.env["DESCRIBE_IMAGES_MAX_TOKENS"]!) || 1024;
export const OPENAI_API_KEY= process.env["OPENAI_API_KEY"] || "";
export const OPENAI_MODEL = process.env["OPENAI_MODEL"] || "gpt-4o";
export const OLLAMA_URL= process.env["OLLAMA_URL"] || "http://localhost:11434";
export const OLLAMA_MODEL= process.env["OLLAMA_MODEL"] || "moondream";
// list all files in /usr/src/app/data/

View File

@ -0,0 +1,57 @@
import type { Request, Response } from "express";
import * as ChannelService from "../services/channel-service";
export const createChannel = async (req: Request, res: Response) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const chan = await ChannelService.createChannel(name);
res.json(chan);
}
export const deleteChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
if (!channelId) {
return res.status(400).json({ error: 'Channel ID is required' });
}
const result = await ChannelService.deleteChannel(channelId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Channel not found' });
}
res.json({ message: 'Channel deleted successfully' });
}
export const getChannels = async (req: Request, res: Response) => {
const channels = await ChannelService.getChannels();
res.json({ channels });
}
export const mergeChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
const { targetChannelId } = req.body;
if (!channelId || !targetChannelId) {
return res.status(400).json({ error: 'Channel ID and targetChannelId are required' });
}
const result = await ChannelService.mergeChannel(channelId, targetChannelId);
res.json({ message: 'Channels merged successfully' });
}
export const updateChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
const { name } = req.body;
if (!channelId || !name) {
return res.status(400).json({ error: 'Channel ID and name are required' });
}
const result = await ChannelService.updateChannel(channelId, name);
if (result.changes === 0) {
return res.status(404).json({ error: 'Channel not found' });
}
res.json({ message: 'Channel updated successfully' });
}

View File

@ -0,0 +1,31 @@
import type { Request, Response } from "express";
import * as FileService from "../services/file-service";
export const uploadFile = async (req: Request, res: Response) => {
const { channelId, messageId } = req.params;
const filePath = (req.file as Express.Multer.File).path;
const fileType = req.file?.mimetype;
const fileSize = req.file?.size;
const originalName = req.file?.originalname;
if (!channelId || !messageId) {
return res.status(400).json({ error: 'Channel ID and message ID are required' });
}
if (!filePath || !fileType || !fileSize || !originalName) {
return res.status(400).json({ error: 'File is required' });
}
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType });
}
export const getFiles = async (req: Request, res: Response) => {
const { messageId } = req.params;
if (!messageId) {
return res.status(400).json({ error: 'Message ID is required' });
}
const files = await FileService.getFiles(messageId);
res.json({ files });
}

View File

@ -0,0 +1,47 @@
import type { Request, Response } from "express";
import * as MessageService from "../services/message-service";
export const createMessage = async (req: Request, res: Response) => {
const { content } = req.body;
const { channelId } = req.params;
if (!content || !channelId) {
return res.status(400).json({ error: 'Content and channel ID are required' });
}
const messageId = await MessageService.createMessage(channelId, content);
res.json({ id: messageId, channelId, content, createdAt: new Date().toISOString() });
};
export const updateMessage = async (req: Request, res: Response) => {
const { content } = req.body;
const { messageId } = req.params;
if (!content || !messageId) {
return res.status(400).json({ error: 'Content and message ID are required ' });
}
const result = await MessageService.updateMessage(messageId, content);
res.json({ id: messageId, content });
}
export const deleteMessage = async (req: Request, res: Response) => {
const { messageId } = req.params;
if (!messageId) {
return res.status(400).json({ error: 'Message ID is required' });
}
const result = await MessageService.deleteMessage(messageId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Message not found' });
}
res.json({ message: 'Message deleted successfully' });
}
export const getMessages = async (req: Request, res: Response) => {
const { channelId } = req.params;
if (!channelId) {
return res.status(400).json({ error: 'Channel ID is required' });
}
const messages = await MessageService.getMessages(channelId);
res.json({ messages });
}

View File

@ -0,0 +1,11 @@
import type { Request, Response } from "express";
import * as SearchService from "../services/search-service";
export const search = async (req: Request, res: Response) => {
const { query, channelId } = req.query;
if (!query) {
return res.status(400).json({ error: 'Query is required' });
}
const results = await SearchService.search(query as string, channelId as string);
res.json({ results });
}

View 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', id, channelId, messageId, filePath, fileType, fileSize, originalName }));
});
events.on('message-created', (id, channelId, content) => {
ws.send(JSON.stringify({ type: 'message-created', id, channelId, content }));
});
events.on('message-updated', (id, content) => {
ws.send(JSON.stringify({ type: 'message-updated', id, content }));
});
events.on('message-deleted', (id) => {
ws.send(JSON.stringify({ type: 'message-deleted', id }));
});
events.on('channel-created', (channel) => {
ws.send(JSON.stringify({ type: 'channel-created', channel }));
});
events.on('channel-deleted', (id) => {
ws.send(JSON.stringify({ type: 'channel-deleted', id }));
});
events.on('channel-merged', (channelId, targetChannelId) => {
ws.send(JSON.stringify({ type: 'channel-merged', channelId, targetChannelId }));
});
events.on('channel-updated', (id, name) => {
ws.send(JSON.stringify({ type: 'channel-updated', id, name }));
});
}

73
backend/src/db.ts 100644
View File

@ -0,0 +1,73 @@
import { Database } from 'bun:sqlite';
import { DB_PATH } from './config';
import { logger } from './globals';
export let FTS5Enabled = true;
export const initializeDB = () => {
logger.info("Checking fts");
const ftstest = db.query(`pragma compile_options;`);
const result = ftstest.all() as { compile_options: string }[];
if (result.find((o) => o["compile_options"].includes("ENABLE_FTS5"))) {
logger.info("FTS5 is enabled");
} else {
logger.info("FTS5 is not enabled. Attempting to load...");
try {
db.loadExtension('./fts5');
} catch (e) {
logger.warn("Failed to load FTS5 extension. Disabling FTS5");
FTS5Enabled = false;
}
}
db.run(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
filePath TEXT,
fileType TEXT,
fileSize INTEGER,
originalName TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE SET NULL
)
`);
db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content='messages',
content_rowid='id'
);
`)
return FTS5Enabled;
}
logger.info(`Loading database at ${DB_PATH}`);
export const db = new Database(DB_PATH);
initializeDB();

View 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();
});

View 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"));
})();

View File

@ -0,0 +1,16 @@
import { events, logger } from "../globals"
import { describeImage } from "../services/image-description";
import { getMessage, updateMessage } from "../services/message-service";
export const describeImageJob = () => {
events.on("file-uploaded", (id, channelId, messageId, filePath, fileType, fileSize, originalName) => {
if (fileType.includes("image")) {
describeImage(filePath).then((description) => {
const msg = getMessage(messageId) as any;
updateMessage(messageId, `${msg.content ? msg.content : ''}\n\n${description}`);
}).catch((e) => {
logger.warn(`Failed to describe image: ${e.message}`);
});
}
});
}

View File

@ -0,0 +1,7 @@
import { describeImageJob } from "./describe-image";
import { scheduleVacuum } from "./vacuum";
export const jobs = [
scheduleVacuum,
describeImageJob
]

View 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.query('VACUUM');
}, 1, TimeUnit.DAY);
}

View 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;
}
}

View 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;
}
}

View File

@ -0,0 +1,12 @@
export interface LogEntry {
level: LogLevel;
timestamp: number;
message: string;
additionalInfo?: any;
}
export enum LogLevel {
info,
warning,
critical
}

View 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);
}
}

View File

@ -0,0 +1,16 @@
import type { NextFunction, Request, Response } from "express";
import { SECRET_KEY } from "../config";
import { logger } from "../globals";
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const token = req.headers['authorization'];
logger.info(`Checking ${SECRET_KEY} against ${token}`);
if (!token) {
return res.status(403).json({ error: 'No token provided' });
}
if (token === SECRET_KEY) {
next();
} else {
res.status(401).json({ error: "Unauthenticated" })
}
}

View 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);

View 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);

View 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);

View 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);

View File

@ -0,0 +1,29 @@
import { app } from "./app";
import { createServer } from "http";
import { WebSocket, WebSocketServer } from "ws";
import { attachEvents } from "./controllers/websocket-controller";
import { logger } from "./globals";
const PORT = process.env.PORT || 3000;
const server = createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
logger.info('Websocket client connected');
attachEvents(ws);
ws.on('message', (message: string) => {
logger.info(`Received message: ${message}`);
});
ws.on('close', () => {
logger.info('Websocket client disconnected');
});
});
server.listen(3000, () => {
logger.info(`Server is running on http://localhost:${3000}`);
});

View File

@ -0,0 +1,37 @@
import { db } from "../db";
import { events } from "../globals";
export const createChannel = async (name: string) => {
const query = db.query(`INSERT INTO channels (name) VALUES ($name)`);
const result = query.run({ $name: name });
events.emit('channel-created', { id: result.lastInsertRowid, name });
return { id: result.lastInsertRowid, name };
}
export const deleteChannel = async (id: string) => {
const query = db.query(`DELETE FROM channels WHERE id = ($channelId)`);
const result = db.run(id);
// No need to manually delete messages and files as they are set to cascade on delete in the schema
events .emit('channel-deleted', id);
return result;
}
export const getChannels = async () => {
const query = db.query(`SELECT * FROM channels`);
const rows = query.all();
return rows;
}
export const mergeChannel = async (channelId: string, targetChannelId: string) => {
const query = db.query(`UPDATE messages SET channelId = $targetChannelId WHERE channelId = $channelId`);
const result = query.run({ $channelId: channelId, $targetChannelId: targetChannelId });
events.emit('channel-merged', channelId, targetChannelId);
return result;
}
export const updateChannel = async (id: string, name: string) => {
const query = db.query(`UPDATE channels SET name = $name WHERE id = $id`);
const result = query.run({ $id: id, $name: name });
events.emit('channel-updated', id, name);
return result;
}

View 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.query(`INSERT INTO files (channelId, filePath, fileType, fileSize, originalName) VALUES ($channelId, $filePath, $fileType, $fileSize, $originalName)`);
const result = query.run({ $channelId: channelId, $filePath: filePath, $fileType: fileType, $fileSize: fileSize, $originalName: originalName } as any);
const fileId = result.lastInsertRowid;
const updateQuery = db.query(`UPDATE messages SET fileId = $fileId WHERE id = $messageId`);
const result2 = updateQuery.run({ $fileId: fileId, $messageId: messageId });
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
return result2; ''
}
export const getFiles = async (messageId: string) => {
const query = db.query(`SELECT * FROM files WHERE messageId = $messageId`);
const rows = query.all({ $messageId: messageId });
return rows;
}

View 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')}`;
}

View 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.query(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
const result = query.run({ $channelId: channelId, $content: content });
const messageId = result.lastInsertRowid;
console.log(`Adding message for search with id ${messageId}`);
// Insert into FTS table if FTS is enabled.
if (FTS5Enabled) {
const query2 = db.query(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content)`);
const result2 = query2.run({ $rowId: messageId, $content: content });
}
events.emit('message-created', messageId, channelId, content);
return messageId;
}
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
const query = db.query(`UPDATE messages SET content = $content WHERE id = $id`);
const result = query.run({ $content: content, $id: messageId });
// Update FTS table if enabled
if (!FTS5Enabled) {
const query2 = db.query(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content) ON CONFLICT(rowid) DO UPDATE SET content = excluded.content`);
const result2 = query.run({ $rowId: messageId, $content: content });
}
events.emit('message-updated', messageId, content);
return result;
}
export const deleteMessage = async (messageId: string) => {
const query = db.query(`DELETE FROM messages WHERE id = $id`);
const result = query.run({ $id: messageId });
// Remove from FTS table if enabled
if (FTS5Enabled) {
const query2 = db.query(`DELETE FROM messages_fts WHERE rowid = $rowId`);
const result2 = query.run({ $rowId: messageId });
}
events.emit('message-deleted', messageId);
return result;
}
export const getMessages = async (channelId: string) => {
const query = db.query(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
LEFT JOIN
files
ON
messages.fileId = files.id
WHERE
messages.channelId = $channelId
`);
const rows = query.all({ $channelId: channelId });
return rows;
}
export const getMessage = async (id: string) => {
const query = db.query(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
LEFT JOIN
files
ON
messages.fileId = files.id
WHERE
messages.id = $id
`);
const row = query.get({ $id: id });
return row;
}

View 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.query(sql);
const rows = sqlquery.all(params);
return rows;
}

View File

@ -0,0 +1,4 @@
import multer from "multer";
import { UPLOAD_DIR } from "../config";
export const upload = multer({ dest: UPLOAD_DIR });

View 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;
}
}

View 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 100644
View 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;
}

48
dockerfile 100644
View File

@ -0,0 +1,48 @@
# Use the official Bun image
FROM oven/bun:1 AS base
WORKDIR /usr/src/app
# Install dependencies into temp directories
# This will cache them and speed up future builds
FROM base AS install
# Install dependencies for both backend and frontend
COPY backend/package.json backend/bun.lockb /temp/dev/backend/
COPY frontend/package.json frontend/bun.lockb /temp/dev/frontend/
RUN cd /temp/dev/backend && bun install
RUN cd /temp/dev/frontend && bun install
# Install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
COPY backend/package.json backend/bun.lockb /temp/prod/backend/
COPY frontend/package.json frontend/bun.lockb /temp/prod/frontend/
RUN cd /temp/prod/backend && bun install
RUN cd /temp/prod/frontend && bun install
# Build the frontend project
FROM install AS build-frontend
WORKDIR /usr/src/app/frontend
COPY --from=install /temp/dev/frontend/node_modules node_modules
COPY frontend/ .
RUN bun run build
# Prepare for final release
FROM base AS release
WORKDIR /usr/src/app
# Copy production dependencies
COPY --from=install /temp/prod/backend/node_modules backend/node_modules
COPY --from=install /temp/prod/frontend/node_modules frontend/node_modules
# Copy backend source code
COPY backend/ backend/
# Copy the built frontend assets into the backend public directory
COPY --from=build-frontend /usr/src/app/frontend/dist backend/public
# Set the entrypoint to run the backend server
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "run", "backend/src/server.ts" ]

BIN
frontend/bun.lockb 100644

Binary file not shown.

View File

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notebrook</title>
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Theme Color (For Mobile idk) -->
<meta name="theme-color" content="#ffffff" />
<!-- PWA Metadata -->
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
<meta name="application-name" content="Notebrook" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Notebrook" />
<meta name="msapplication-starturl" content="/" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
<style>
/* Basic styles for the toasts */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background-color: #333;
color: #fff;
padding: 15px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s, transform 0.3s;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div id="app"></div>
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
<script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function (err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
</script>
</body>
</html>

View 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"
}
]
}

View 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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

103
frontend/src/api.ts 100644
View File

@ -0,0 +1,103 @@
import { IChannel } from "./model/channel";
import { IChannelList } from "./model/channel-list";
import { IMessage } from "./model/message";
import { IUnsentMessage } from "./model/unsent-message";
import { state } from "./state";
export const API = {
token: "",
path: "http://localhost:3000",
async request(method: string, path: string, body?: any) {
if (!API.token) {
throw new Error("API token was not set.");
}
return fetch(`${API.path}/${path}`, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": API.token
},
body: JSON.stringify(body),
});
},
async checkToken() {
const response = await API.request("GET", "check-token");
if (response.status !== 200) {
throw new Error("Invalid token in request");
}
},
async getChannels() {
const response = await API.request("GET", "channels");
const json = await response.json();
return json.channels as IChannel[];
},
async getChannel(id: string) {
const response = await API.request("GET", `channels/${id}`);
const json = await response.json();
return json.channel as IChannel;
},
async createChannel(name: string) {
const response = await API.request("POST", "channels", { name });
const json = await response.json();
return json.channel as IChannel;
},
async deleteChannel(id: string) {
await API.request("DELETE", `channels/${id}`);
},
async getMessages(channelId: string) {
const response = await API.request("GET", `channels/${channelId}/messages`);
const json = await response.json();
return json.messages as IMessage[];
},
async createMessage(channelId: string, content: string) {
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
const json = await response.json();
return json as IMessage;
},
async deleteMessage(channelId: string, messageId: string) {
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
},
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
method: "POST",
headers: {
"Authorization": API.token
},
body: formData,
});
const json = await response.json();
return json;
},
async mergeChannels(channelId: string, targetChannelId: string) {
await API.request("POST", "merge-channels", { channelId, targetChannelId });
},
async search(query: string, channelId?: string) {
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
const response = await API.request("GET", queryPath);
const json = await response.json();
return json.results as IMessage[];
},
async getFiles(channelId: string, messageId: string) {
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
const json = await response.json();
return json.files as string[];
}
}

View 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);
});
}
}

View File

@ -0,0 +1,45 @@
import { IChannel } from "../model/channel";
import { showToast } from "../speech";
import { state } from "../state";
import { Button, TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
export class ChannelDialog extends Dialog<IChannel> {
private channel: IChannel;
private nameField: TextInput;
private makeDefault: Button;
private mergeButton: Button;
private deleteButton: Button;
public constructor(channel: IChannel) {
super("Channel info for " + channel.name);
this.channel = channel;
this.nameField = new TextInput("Channel name");
this.nameField.setPosition(25, 10, 50, 10);
this.nameField.setValue(channel.name);
this.makeDefault = new Button("Make default");
this.makeDefault.setPosition(20, 70, 10, 10);
this.makeDefault.onClick(() => {
state.defaultChannelId = this.channel.id;
showToast(`${channel.name} is now the default channel.`);
});
this.mergeButton = new Button("Merge");
this.mergeButton.setPosition(40, 70, 10, 10);
this.mergeButton.onClick(() => {
showToast("Merge not implemented.");
});
this.deleteButton = new Button("Delete");
this.deleteButton.setPosition(60, 70, 10, 10);
this.deleteButton.onClick(() => {
showToast("Delete not implemented.");
});
this.add(this.nameField);
this.add(this.makeDefault);
this.add(this.mergeButton);
this.add(this.deleteButton);
this.setOkAction(() => {
this.channel.name = this.nameField.getValue();
return this.channel;
});
}
}

View File

@ -0,0 +1,17 @@
import { API } from "../api";
import { showToast } from "../speech";
import { TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
export class CreateChannelDialog extends Dialog<string> {
private nameField: TextInput;
public constructor() {
super("Create new channel");
this.nameField = new TextInput("Name of new channel");
this.add(this.nameField);
this.setOkAction(() => {
return this.nameField.getValue();
});
}
}

View File

@ -0,0 +1,47 @@
import { API } from "../api";
import { IMessage } from "../model/message";
import { Button, Container, TextInput} from "../ui";
import { Dialog } from "../ui/dialog";
import { Text } from "../ui";
import { MultilineInput } from "../ui/multiline-input";
export class MessageDialog extends Dialog<void> {
private message: IMessage;
private messageText: MultilineInput;
private deleteButton: Button;
private fileInfoContainer?: Container;
public constructor(message: IMessage) {
super("Message");
this.message = message;
this.messageText = new MultilineInput("Message");
this.messageText.setValue(message.content);
this.messageText.setPosition(10, 10, 80, 20);
this.deleteButton = new Button("Delete");
this.deleteButton.setPosition(10, 90, 80, 10);
this.deleteButton.onClick(() => {
return;
});
this.add(this.messageText);
if (this.message.fileId !== null) {
this.fileInfoContainer = new Container("File info");
this.fileInfoContainer.setPosition(10, 50, 30, 80);
this.add(this.fileInfoContainer);
this.handleMessage();
}
}
private handleMessage() {
if (this.message?.fileType?.toLowerCase().includes("audio")) {
const audio = new Audio(`${API.path}/${this.message.filePath}`);
audio.autoplay = true;
}
// display info about files, or the image if it is an image. Also display all metadata.
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
}
}

View 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);
}
}
}

View File

@ -0,0 +1,42 @@
import { API } from "../api";
import { IMessage } from "../model/message";
import { Button, List, ListItem, TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
private searchField: TextInput;
private searchButton: Button;
private resultsList: List;
private closeButton: Button;
public constructor() {
super("Search for message", false);
this.searchField = new TextInput("Search query");
this.searchField.setPosition(5, 5, 80, 20);
this.searchButton = new Button("Search");
this.searchButton.setPosition(85, 5, 10, 20);
this.searchButton.onClick(async () => {
const messages = await API.search(this.searchField.getValue());
console.log(messages);
this.renderResults(messages);
})
this.resultsList = new List("Results");
this.resultsList.setPosition(5, 20, 90, 70);
this.closeButton = new Button("Close");
this.closeButton.setPosition(5, 90, 90, 5);
this.closeButton.onClick(() => this.cancel());
this.add(this.searchField);
this.add(this.searchButton);
this.add(this.resultsList);
this.add(this.closeButton);
}
private renderResults(messages: IMessage[]) {
this.resultsList.clear();
messages.forEach((message) => {
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
this.resultsList.add(itm);
});
}
}

View 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();
});
}
}

View 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);
});
}
}

View 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);
});

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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[];
}

View 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;
}
}

View 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);
}
})
);
})
);
});

View 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);
});

View 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);
}

View File

@ -0,0 +1,134 @@
import { IChannel, Channel } from "./model/channel";
import { IChannelList, ChannelList } from "./model/channel-list";
import { IState } from "./model/state";
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
import { get, set, clear } from "idb-keyval";
export class State implements IState {
token!: string;
apiUrl!: string;
channelList!: ChannelList;
unsentMessages!: IUnsentMessage[];
currentChannel!: Channel | null;
defaultChannelId!: number;
constructor() {
this.token = "";
this.channelList = new ChannelList();
this.unsentMessages = [];
}
public getToken(): string {
return this.token;
}
public setToken(token: string): void {
this.token = token;
}
public getChannelList(): IChannelList {
return this.channelList;
}
public setChannelList(channelList: ChannelList): void {
this.channelList = channelList;
}
public getUnsentMessages(): IUnsentMessage[] {
return this.unsentMessages;
}
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
this.unsentMessages = unsentMessages;
}
public async save(): Promise<void> {
// stringify everything here except the currentChannel object.
const { currentChannel, ...state } = this;
await set("notebrook", state);
}
public async load(): Promise<void> {
const saved = await get("notebrook");
if (saved) {
this.token = saved.token;
this.apiUrl = saved.apiUrl;
this.channelList = new ChannelList( saved.channelList);
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
this.defaultChannelId = saved.defaultChannelId;
}
}
public async clear(): Promise<void> {
this.token = "";
this.channelList = new ChannelList();
this.unsentMessages = [];
this.currentChannel = null;
this.defaultChannelId = -1;
await clear();
}
public getChannelById(id: number) {
return this.channelList.getChannel(id);
}
public getChannelByName(name: string) {
return this.channelList.getChannelByName(name);
}
public findChannelByQuery(query: string) {
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
}
public addChannel(channel: Channel) {
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
}
public removeChannel(channel: IChannel) {
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
}
public addUnsentMessage(message: UnsentMessage) {
this.unsentMessages.push(message);
}
public removeUnsentMessage(message: IUnsentMessage) {
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
}
public getChannels() {
return this.channelList.channels;
}
public getCurrentChannel() {
return this.currentChannel;
}
public setCurrentChannel(channel: Channel) {
this.currentChannel = channel;
}
public getDefaultChannelId() {
return this.defaultChannelId;
}
public setDefaultChannelId(id: number) {
this.defaultChannelId = id;
}
public getApiUrl() {
return this.apiUrl;
}
public setApiUrl(url: string) {
this.apiUrl = url;
}
public getMessageById(id: number) {
return this.currentChannel!.getMessage(id);
}
}
export const state = new State();

View 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;
}
}

View 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();
});
}
}

View 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

View File

@ -0,0 +1,74 @@
import { UINode } from "./node";
export class AudioRecorder extends UINode {
private audioElement: HTMLAudioElement;
private mediaRecorder: MediaRecorder | null;
private audioChunks: Blob[];
private stream: MediaStream | null;
private recording?: Blob;
public constructor(title: string) {
super(title);
this.audioElement = document.createElement("audio");
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.audioElement.setAttribute("controls", "true");
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio-recorder");
}
public async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
this.recording = audioBlob;
this.audioChunks = [];
const audioUrl = URL.createObjectURL(audioBlob);
this.audioElement.src = audioUrl;
this.triggerRecordingComplete(audioUrl);
};
this.mediaRecorder.start();
} catch (error) {
console.error("Error accessing microphone:", error);
}
}
public stopRecording() {
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
this.mediaRecorder.stop();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
}
public getElement(): HTMLElement {
return this.element;
}
public onRecordingComplete(callback: (audioUrl: string) => void) {
this.element.addEventListener("recording-complete", (event: Event) => {
const customEvent = event as CustomEvent;
callback(customEvent.detail.audioUrl);
});
}
protected triggerRecordingComplete(audioUrl: string) {
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
this.element.dispatchEvent(event);
}
public getRecording() {
return this.recording;
}
}

View File

@ -0,0 +1,58 @@
import { UINode } from "./node";
export class Audio extends UINode {
private audioElement: HTMLAudioElement;
public constructor(title: string, src: string | MediaStream = "") {
super(title);
this.audioElement = document.createElement("audio");
if (typeof src === "string") {
this.audioElement.src = src; // Set src if it's a string URL
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
}
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio");
}
public getElement(): HTMLElement {
return this.audioElement;
}
public setSource(src: string | MediaStream) {
if (typeof src === "string") {
this.audioElement.src = src;
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src;
}
}
public play() {
this.audioElement.play();
}
public pause() {
this.audioElement.pause();
}
public setControls(show: boolean) {
this.audioElement.controls = show;
}
public setLoop(loop: boolean) {
this.audioElement.loop = loop;
}
public setMuted(muted: boolean) {
this.audioElement.muted = muted;
}
public setAutoplay(autoplay: boolean) {
this.audioElement.autoplay = autoplay;
}
public setVolume(volume: number) {
this.audioElement.volume = volume;
}
}

View File

@ -0,0 +1,35 @@
import { UINode } from "./node";
export class Button extends UINode {
private buttonElement: HTMLButtonElement;
public constructor(title: string, hasPopup: boolean = false) {
super(title);
this.buttonElement = document.createElement("button");
this.buttonElement.innerText = title;
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
this.element.appendChild(this.buttonElement);
this.element.setAttribute("aria-label", this.title);
}
public focus() {
this.buttonElement.focus();
}
public click() {
this.buttonElement.click();
}
public getElement(): HTMLElement {
return this.buttonElement;
}
public setText(text: string) {
this.title = text;
this.buttonElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
}
public setDisabled(val: boolean) {
this.buttonElement.disabled = val;
}
}

View 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;
}
}

View File

@ -0,0 +1,24 @@
import { UINode } from "./node";
export class Canvas extends UINode {
private canvasElement: HTMLCanvasElement;
public constructor(title: string) {
super(title);
this.canvasElement = document.createElement("canvas");
this.canvasElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.canvasElement);
}
public focus() {
this.canvasElement.focus();
}
public click() {
this.canvasElement.click();
}
public getElement(): HTMLElement {
return this.canvasElement;
}
}

View File

@ -0,0 +1,46 @@
import { UINode } from "./node";
export class Checkbox extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private checkboxElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.id = `chkbx_title_${this.id}`;
this.checkboxElement = document.createElement("input");
this.checkboxElement.id = `chkbx_${this.id}`;
this.checkboxElement.type = "checkbox";
this.titleElement.appendChild(this.checkboxElement);
this.titleElement.appendChild(document.createTextNode(this.title));
this.element.appendChild(this.titleElement);
}
public focus() {
this.checkboxElement.focus();
}
public click() {
this.checkboxElement.click();
}
public getElement(): HTMLElement {
return this.checkboxElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.element.setAttribute("aria-roledescription", "checkbox");
}
public isChecked(): boolean {
return this.checkboxElement.checked;
}
public setChecked(value: boolean) {
this.checkboxElement.checked = value;
}
}

View File

@ -0,0 +1,40 @@
import { Container } from "./container";
export class CollapsableContainer extends Container {
private detailsElement: HTMLDetailsElement;
private summaryElement: HTMLElement;
private wrapperElement: HTMLDivElement;
public constructor(title: string) {
super(title);
this.wrapperElement = document.createElement("div");
this.detailsElement = document.createElement("details");
this.summaryElement = document.createElement("summary");
this.summaryElement.innerText = title;
this.detailsElement.appendChild(this.summaryElement);
this.detailsElement.appendChild(this.containerElement);
this.wrapperElement.appendChild(this.detailsElement);
}
public render() {
return this.wrapperElement;
}
public setTitle(text: string): void {
this.title = text;
this.summaryElement.innerText = text;
}
public isCollapsed(): boolean {
return this.detailsElement.hasAttribute("open");
}
public expand(val: boolean) {
if (val) {
this.detailsElement.setAttribute("open", "true");
} else {
this.detailsElement.removeAttribute("open");
}
}
}

View File

@ -0,0 +1,51 @@
import { UINode } from "./node";
export class Container extends UINode {
public children: UINode[];
protected containerElement: HTMLDivElement;
private focused: number = 0;
public constructor(title: string) {
super(title);
this.children = [];
this.containerElement = document.createElement("div");
this.containerElement.setAttribute("tabindex", "-1");
this.focused = 0;
}
public focus() {
this.containerElement.focus();
}
public _onFocus() {
this.children[this.focused].focus();
}
public add(node: UINode) {
this.children.push(node);
node._onConnect();
this.containerElement.appendChild(node.render());
}
public remove(node: UINode) {
this.children.splice(this.children.indexOf(node), 1);
node._onDisconnect();
this.containerElement.removeChild(node.render());
}
public render() {
return this.containerElement;
}
public getChildren(): UINode[] {
return this.children;
}
public getElement() {
return this.containerElement;
}
public setAriaLabel(text: string): void {
this.containerElement.setAttribute("aria-label", text);
}
}

View File

@ -0,0 +1,41 @@
import { UINode } from "./node";
export class DatePicker extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `datepicker_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `datepicker_${this.id}`;
this.inputElement.type = "date";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
}
}

View File

@ -0,0 +1,76 @@
import { UIWindow } from "./window";
import { Button } from "./button";
export class Dialog<T> extends UIWindow {
private resolvePromise!: (value: T | PromiseLike<T>) => void;
private rejectPromise!: (reason?: any) => void;
private promise: Promise<T>;
private dialogElement!: HTMLDialogElement;
private okButton?: Button;
private cancelButton?: Button;
private previouslyFocusedElement!: HTMLElement;
public constructor(title: string, addButtons: boolean = true) {
super(title, "dialog", false);
this.dialogElement = document.createElement("dialog");
this.promise = new Promise<T>((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
// Automatically add OK and Cancel buttons
if (addButtons) {
this.okButton = new Button("OK");
this.okButton.setPosition(70, 90, 10, 5);
this.okButton.onClick(() => this.choose(undefined));
this.cancelButton = new Button("Cancel");
this.cancelButton.setPosition(20, 90, 10, 5);
this.cancelButton.onClick(() => this.cancel());
}
}
public setOkAction(action: () => T): void {
if (!this.okButton) return;
this.okButton.onClick(() => {
const result = action();
this.choose(result);
});
}
public setCancelAction(action: () => void): void {
if (!this.cancelButton) return;
this.cancelButton.onClick(() => {
action();
this.cancel();
});
}
public choose(item: T | undefined) {
this.resolvePromise(item as T);
document.body.removeChild(this.dialogElement);
this.hide();
this.previouslyFocusedElement.focus();
}
public cancel(reason?: any) {
this.rejectPromise(reason);
document.body.removeChild(this.dialogElement);
this.hide();
this.previouslyFocusedElement.focus();
}
public open(): Promise<T> {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
this.dialogElement.appendChild(this.show()!);
if (this.okButton) this.add(this.okButton);
if (this.cancelButton) this.add(this.cancelButton);
document.body.appendChild(this.dialogElement);
this.dialogElement.showModal();
this.container.focus();
return this.promise;
}
}

View File

@ -0,0 +1,70 @@
import { UINode } from "./node";
export class Dropdown extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private selectElement: HTMLSelectElement;
public constructor(title: string, options: { key: string; value: string }[]) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `dd_title_${this.id}`;
this.selectElement = document.createElement("select");
this.selectElement.id = `dd_${this.id}`;
this.titleElement.appendChild(this.selectElement);
this.element.appendChild(this.titleElement);
this.setOptions(options);
}
public focus() {
this.selectElement.focus();
}
public getElement(): HTMLElement {
return this.selectElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getSelectedValue(): string {
return this.selectElement.value;
}
public setSelectedValue(value: string) {
this.selectElement.value = value;
}
public setOptions(options: { key: string; value: string }[]) {
this.clearOptions();
options.forEach((option) => {
this.addOption(option.key, option.value);
});
}
public addOption(key: string, value: string) {
const optionElement = document.createElement("option");
optionElement.value = key;
optionElement.innerText = value;
this.selectElement.appendChild(optionElement);
}
public removeOption(key: string) {
const options = Array.from(this.selectElement.options);
const optionToRemove = options.find(option => option.value === key);
if (optionToRemove) {
this.selectElement.removeChild(optionToRemove);
}
}
public clearOptions() {
while (this.selectElement.firstChild) {
this.selectElement.removeChild(this.selectElement.firstChild);
}
}
}

View File

@ -0,0 +1,44 @@
import { UINode } from "./node";
export class FileInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string, multiple: boolean = false) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `fileinpt_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `fileinpt_${this.id}`;
this.inputElement.type = "file";
if (multiple) {
this.inputElement.multiple = true;
}
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getFiles(): FileList | null {
return this.inputElement.files;
}
public setAccept(accept: string) {
this.inputElement.accept = accept;
}
}

View File

@ -0,0 +1,30 @@
import { UINode } from "./node";
export class Image extends UINode {
private imgElement: HTMLImageElement;
public constructor(title: string, src: string, altText: string = "") {
super(title);
this.imgElement = document.createElement("img");
this.imgElement.src = src;
this.imgElement.alt = altText;
this.element.appendChild(this.imgElement);
this.element.setAttribute("aria-label", title);
}
public getElement(): HTMLElement {
return this.imgElement;
}
public setText(text: string) {
this.title = text;
this.element.setAttribute("aria-label", text);
}
public setSource(src: string) {
this.imgElement.src = src;
}
public setAltText(altText: string) {
this.imgElement.alt = altText;
}
}

View File

@ -0,0 +1,12 @@
export { UIWindow } from "./window";
export { Button } from "./button";
export { Container } from "./container";
export { UINode } from "./node";
export { List } from "./list";
export { Text } from "./text";
export { ListItem } from "./list-item";
export { Checkbox } from "./checkbox";
export { TextInput } from "./text-input";
export { TabBar } from "./tab-bar";
export { TabbedView } from "./tabbed-view";
export { Canvas } from "./canvas";

Some files were not shown because too many files have changed in this diff Show More