diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8bc80fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.env +.vscode +README.md \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..371167a --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/backend/.gitignore @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a7675ae --- /dev/null +++ b/backend/README.md @@ -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. diff --git a/backend/bun.lockb b/backend/bun.lockb new file mode 100644 index 0000000..f66b41c Binary files /dev/null and b/backend/bun.lockb differ diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..9e0cf45 --- /dev/null +++ b/backend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..48077a7 --- /dev/null +++ b/backend/schema.sql @@ -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' +); \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..1b9b56f --- /dev/null +++ b/backend/src/app.ts @@ -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' }); +}); + diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..7ec968a --- /dev/null +++ b/backend/src/config.ts @@ -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/ diff --git a/backend/src/controllers/channel-controller.ts b/backend/src/controllers/channel-controller.ts new file mode 100644 index 0000000..8e70020 --- /dev/null +++ b/backend/src/controllers/channel-controller.ts @@ -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' }); +} \ No newline at end of file diff --git a/backend/src/controllers/file-controller.ts b/backend/src/controllers/file-controller.ts new file mode 100644 index 0000000..3efb838 --- /dev/null +++ b/backend/src/controllers/file-controller.ts @@ -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 }); +} + diff --git a/backend/src/controllers/message-controller.ts b/backend/src/controllers/message-controller.ts new file mode 100644 index 0000000..b97bcd1 --- /dev/null +++ b/backend/src/controllers/message-controller.ts @@ -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 }); +} \ No newline at end of file diff --git a/backend/src/controllers/search-controller.ts b/backend/src/controllers/search-controller.ts new file mode 100644 index 0000000..e970af8 --- /dev/null +++ b/backend/src/controllers/search-controller.ts @@ -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 }); +} \ No newline at end of file diff --git a/backend/src/controllers/websocket-controller.ts b/backend/src/controllers/websocket-controller.ts new file mode 100644 index 0000000..71f1c0f --- /dev/null +++ b/backend/src/controllers/websocket-controller.ts @@ -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 })); + }); +} \ No newline at end of file diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..014bebf --- /dev/null +++ b/backend/src/db.ts @@ -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(); \ No newline at end of file diff --git a/backend/src/globals.ts b/backend/src/globals.ts new file mode 100644 index 0000000..a8eae67 --- /dev/null +++ b/backend/src/globals.ts @@ -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(); +}); \ No newline at end of file diff --git a/backend/src/image-describe-test.ts b/backend/src/image-describe-test.ts new file mode 100644 index 0000000..67efed2 --- /dev/null +++ b/backend/src/image-describe-test.ts @@ -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")); +})(); diff --git a/backend/src/jobs/describe-image.ts b/backend/src/jobs/describe-image.ts new file mode 100644 index 0000000..d67b776 --- /dev/null +++ b/backend/src/jobs/describe-image.ts @@ -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}`); + }); + } + }); +} \ No newline at end of file diff --git a/backend/src/jobs/index.ts b/backend/src/jobs/index.ts new file mode 100644 index 0000000..ff32f4c --- /dev/null +++ b/backend/src/jobs/index.ts @@ -0,0 +1,7 @@ +import { describeImageJob } from "./describe-image"; +import { scheduleVacuum } from "./vacuum"; + +export const jobs = [ + scheduleVacuum, + describeImageJob +] \ No newline at end of file diff --git a/backend/src/jobs/vacuum.ts b/backend/src/jobs/vacuum.ts new file mode 100644 index 0000000..9823c6c --- /dev/null +++ b/backend/src/jobs/vacuum.ts @@ -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); +} diff --git a/backend/src/logging/adapter.ts b/backend/src/logging/adapter.ts new file mode 100644 index 0000000..6a0a088 --- /dev/null +++ b/backend/src/logging/adapter.ts @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/logging/adapters/console-adapter.ts b/backend/src/logging/adapters/console-adapter.ts new file mode 100644 index 0000000..e895e44 --- /dev/null +++ b/backend/src/logging/adapters/console-adapter.ts @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/logging/log-entry.ts b/backend/src/logging/log-entry.ts new file mode 100644 index 0000000..01bcfc5 --- /dev/null +++ b/backend/src/logging/log-entry.ts @@ -0,0 +1,12 @@ +export interface LogEntry { + level: LogLevel; + timestamp: number; + message: string; + additionalInfo?: any; +} + +export enum LogLevel { + info, + warning, + critical +} \ No newline at end of file diff --git a/backend/src/logging/logger.ts b/backend/src/logging/logger.ts new file mode 100644 index 0000000..7df3895 --- /dev/null +++ b/backend/src/logging/logger.ts @@ -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); + } +} \ No newline at end of file diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..cde10fe --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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" }) + } +} diff --git a/backend/src/routes/channel.ts b/backend/src/routes/channel.ts new file mode 100644 index 0000000..3b448f2 --- /dev/null +++ b/backend/src/routes/channel.ts @@ -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); diff --git a/backend/src/routes/file.ts b/backend/src/routes/file.ts new file mode 100644 index 0000000..a0b9ef0 --- /dev/null +++ b/backend/src/routes/file.ts @@ -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); diff --git a/backend/src/routes/message.ts b/backend/src/routes/message.ts new file mode 100644 index 0000000..b3d3f42 --- /dev/null +++ b/backend/src/routes/message.ts @@ -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); + diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts new file mode 100644 index 0000000..c8c3e8c --- /dev/null +++ b/backend/src/routes/search.ts @@ -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); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..203192d --- /dev/null +++ b/backend/src/server.ts @@ -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}`); +}); \ No newline at end of file diff --git a/backend/src/services/channel-service.ts b/backend/src/services/channel-service.ts new file mode 100644 index 0000000..6a6130c --- /dev/null +++ b/backend/src/services/channel-service.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/services/file-service.ts b/backend/src/services/file-service.ts new file mode 100644 index 0000000..85e7475 --- /dev/null +++ b/backend/src/services/file-service.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/services/image-description.ts b/backend/src/services/image-description.ts new file mode 100644 index 0000000..9ef38e1 --- /dev/null +++ b/backend/src/services/image-description.ts @@ -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 { + 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')}`; +} + diff --git a/backend/src/services/message-service.ts b/backend/src/services/message-service.ts new file mode 100644 index 0000000..d42dfb9 --- /dev/null +++ b/backend/src/services/message-service.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/services/search-service.ts b/backend/src/services/search-service.ts new file mode 100644 index 0000000..47dd84f --- /dev/null +++ b/backend/src/services/search-service.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/services/websocket-service.ts b/backend/src/services/websocket-service.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/utils/multer.ts b/backend/src/utils/multer.ts new file mode 100644 index 0000000..893681a --- /dev/null +++ b/backend/src/utils/multer.ts @@ -0,0 +1,4 @@ +import multer from "multer"; +import { UPLOAD_DIR } from "../config"; + +export const upload = multer({ dest: UPLOAD_DIR }); diff --git a/backend/src/utils/scheduler.ts b/backend/src/utils/scheduler.ts new file mode 100644 index 0000000..f421209 --- /dev/null +++ b/backend/src/utils/scheduler.ts @@ -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 = 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 { + return this.tasks; + } +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/backend/tsconfig.json @@ -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 + } +} diff --git a/backend/types.ts b/backend/types.ts new file mode 100644 index 0000000..43ffd46 --- /dev/null +++ b/backend/types.ts @@ -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; +} \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..f620d72 --- /dev/null +++ b/dockerfile @@ -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" ] diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100644 index 0000000..f72fe74 Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..982c619 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,75 @@ + + + + + + + Notebrook + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + \ No newline at end of file diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest new file mode 100644 index 0000000..d19acce --- /dev/null +++ b/frontend/manifest.webmanifest @@ -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" + } + ] +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5f38dd3 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/intro.wav b/frontend/public/intro.wav new file mode 100644 index 0000000..bf53d70 Binary files /dev/null and b/frontend/public/intro.wav differ diff --git a/frontend/public/login.wav b/frontend/public/login.wav new file mode 100644 index 0000000..d6af49d Binary files /dev/null and b/frontend/public/login.wav differ diff --git a/frontend/public/sent1.wav b/frontend/public/sent1.wav new file mode 100644 index 0000000..1b4e164 Binary files /dev/null and b/frontend/public/sent1.wav differ diff --git a/frontend/public/sent2.wav b/frontend/public/sent2.wav new file mode 100644 index 0000000..00ffed1 Binary files /dev/null and b/frontend/public/sent2.wav differ diff --git a/frontend/public/sent3.wav b/frontend/public/sent3.wav new file mode 100644 index 0000000..6cfd030 Binary files /dev/null and b/frontend/public/sent3.wav differ diff --git a/frontend/public/sent4.wav b/frontend/public/sent4.wav new file mode 100644 index 0000000..b94156c Binary files /dev/null and b/frontend/public/sent4.wav differ diff --git a/frontend/public/sent5.wav b/frontend/public/sent5.wav new file mode 100644 index 0000000..4f67554 Binary files /dev/null and b/frontend/public/sent5.wav differ diff --git a/frontend/public/sent6.wav b/frontend/public/sent6.wav new file mode 100644 index 0000000..e86d5ad Binary files /dev/null and b/frontend/public/sent6.wav differ diff --git a/frontend/public/uploadfail.wav b/frontend/public/uploadfail.wav new file mode 100644 index 0000000..98f7828 Binary files /dev/null and b/frontend/public/uploadfail.wav differ diff --git a/frontend/public/water1.wav b/frontend/public/water1.wav new file mode 100644 index 0000000..c00a536 Binary files /dev/null and b/frontend/public/water1.wav differ diff --git a/frontend/public/water10.wav b/frontend/public/water10.wav new file mode 100644 index 0000000..86f67ff Binary files /dev/null and b/frontend/public/water10.wav differ diff --git a/frontend/public/water2.wav b/frontend/public/water2.wav new file mode 100644 index 0000000..3bcee7a Binary files /dev/null and b/frontend/public/water2.wav differ diff --git a/frontend/public/water3.wav b/frontend/public/water3.wav new file mode 100644 index 0000000..1c35eb2 Binary files /dev/null and b/frontend/public/water3.wav differ diff --git a/frontend/public/water4.wav b/frontend/public/water4.wav new file mode 100644 index 0000000..6c769ec Binary files /dev/null and b/frontend/public/water4.wav differ diff --git a/frontend/public/water5.wav b/frontend/public/water5.wav new file mode 100644 index 0000000..91bf464 Binary files /dev/null and b/frontend/public/water5.wav differ diff --git a/frontend/public/water6.wav b/frontend/public/water6.wav new file mode 100644 index 0000000..18bc34a Binary files /dev/null and b/frontend/public/water6.wav differ diff --git a/frontend/public/water7.wav b/frontend/public/water7.wav new file mode 100644 index 0000000..42e16d9 Binary files /dev/null and b/frontend/public/water7.wav differ diff --git a/frontend/public/water8.wav b/frontend/public/water8.wav new file mode 100644 index 0000000..9a4c849 Binary files /dev/null and b/frontend/public/water8.wav differ diff --git a/frontend/public/water9.wav b/frontend/public/water9.wav new file mode 100644 index 0000000..2068e18 Binary files /dev/null and b/frontend/public/water9.wav differ diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..ad22d7e --- /dev/null +++ b/frontend/src/api.ts @@ -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[]; + } +} \ No newline at end of file diff --git a/frontend/src/chunk-processor.ts b/frontend/src/chunk-processor.ts new file mode 100644 index 0000000..5400f4d --- /dev/null +++ b/frontend/src/chunk-processor.ts @@ -0,0 +1,25 @@ +export class ChunkProcessor { + private chunkSize: number; + + constructor(chunkSize: number = 1000) { + this.chunkSize = chunkSize; + } + + async processArray(array: T[], callback: (chunk: T[]) => void): Promise { + 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 { + return new Promise((resolve) => { + setTimeout(() => { + callback(chunk); + resolve(); + }, 0); + }); + } +} diff --git a/frontend/src/dialogs/channel-dialog.ts b/frontend/src/dialogs/channel-dialog.ts new file mode 100644 index 0000000..7cdf1e3 --- /dev/null +++ b/frontend/src/dialogs/channel-dialog.ts @@ -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 { + 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; + }); + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/create-channel.ts b/frontend/src/dialogs/create-channel.ts new file mode 100644 index 0000000..50f4a2e --- /dev/null +++ b/frontend/src/dialogs/create-channel.ts @@ -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 { + 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(); + }); + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/message.ts b/frontend/src/dialogs/message.ts new file mode 100644 index 0000000..de7846d --- /dev/null +++ b/frontend/src/dialogs/message.ts @@ -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 { + 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}`)); + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/record-audio.ts b/frontend/src/dialogs/record-audio.ts new file mode 100644 index 0000000..6086b2e --- /dev/null +++ b/frontend/src/dialogs/record-audio.ts @@ -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 { + 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); + } + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/search.ts b/frontend/src/dialogs/search.ts new file mode 100644 index 0000000..786251e --- /dev/null +++ b/frontend/src/dialogs/search.ts @@ -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); + }); + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/settings.ts b/frontend/src/dialogs/settings.ts new file mode 100644 index 0000000..5095367 --- /dev/null +++ b/frontend/src/dialogs/settings.ts @@ -0,0 +1,23 @@ +import { Button } from "../ui"; +import { Dialog } from "../ui/dialog"; +import { state } from "../state"; + +export class SettingsDialog extends Dialog { + 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(); + }); + } +} \ No newline at end of file diff --git a/frontend/src/dialogs/take-photo.ts b/frontend/src/dialogs/take-photo.ts new file mode 100644 index 0000000..d469ba3 --- /dev/null +++ b/frontend/src/dialogs/take-photo.ts @@ -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 { + 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); + }); + } +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ed3da4d --- /dev/null +++ b/frontend/src/main.ts @@ -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); +}); \ No newline at end of file diff --git a/frontend/src/model/channel-list.ts b/frontend/src/model/channel-list.ts new file mode 100644 index 0000000..1fadc49 --- /dev/null +++ b/frontend/src/model/channel-list.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/model/channel.ts b/frontend/src/model/channel.ts new file mode 100644 index 0000000..0581369 --- /dev/null +++ b/frontend/src/model/channel.ts @@ -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; + + 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; + } +} \ No newline at end of file diff --git a/frontend/src/model/message.ts b/frontend/src/model/message.ts new file mode 100644 index 0000000..9b96ae5 --- /dev/null +++ b/frontend/src/model/message.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/model/state.ts b/frontend/src/model/state.ts new file mode 100644 index 0000000..ded2229 --- /dev/null +++ b/frontend/src/model/state.ts @@ -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[]; +} \ No newline at end of file diff --git a/frontend/src/model/unsent-message.ts b/frontend/src/model/unsent-message.ts new file mode 100644 index 0000000..7145ca4 --- /dev/null +++ b/frontend/src/model/unsent-message.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 0000000..0d6828f --- /dev/null +++ b/frontend/src/service-worker.ts @@ -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); + } + }) + ); + }) + ); +}); diff --git a/frontend/src/sound.ts b/frontend/src/sound.ts new file mode 100644 index 0000000..fbc9b09 --- /dev/null +++ b/frontend/src/sound.ts @@ -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> = {}; + +const waterSounds: AudioBuffer[] = []; +const sentSounds: AudioBuffer[] = []; + +async function loadSound(url: string): Promise { + 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); +}); diff --git a/frontend/src/speech.ts b/frontend/src/speech.ts new file mode 100644 index 0000000..7d42070 --- /dev/null +++ b/frontend/src/speech.ts @@ -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); +} \ No newline at end of file diff --git a/frontend/src/state.ts b/frontend/src/state.ts new file mode 100644 index 0000000..a28d836 --- /dev/null +++ b/frontend/src/state.ts @@ -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 { + // stringify everything here except the currentChannel object. + const { currentChannel, ...state } = this; + await set("notebrook", state); + } + + public async load(): Promise { + 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 { + 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(); \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..f9c7350 --- /dev/null +++ b/frontend/src/style.css @@ -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; + } +} diff --git a/frontend/src/toast.ts b/frontend/src/toast.ts new file mode 100644 index 0000000..e0c5fc6 --- /dev/null +++ b/frontend/src/toast.ts @@ -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(); + }); + } +} diff --git a/frontend/src/typescript.svg b/frontend/src/typescript.svg new file mode 100644 index 0000000..d91c910 --- /dev/null +++ b/frontend/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/ui/audio-recorder.ts b/frontend/src/ui/audio-recorder.ts new file mode 100644 index 0000000..5edd9d5 --- /dev/null +++ b/frontend/src/ui/audio-recorder.ts @@ -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; + } +} diff --git a/frontend/src/ui/audio.ts b/frontend/src/ui/audio.ts new file mode 100644 index 0000000..033ee67 --- /dev/null +++ b/frontend/src/ui/audio.ts @@ -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; + } +} diff --git a/frontend/src/ui/button.ts b/frontend/src/ui/button.ts new file mode 100644 index 0000000..aa88544 --- /dev/null +++ b/frontend/src/ui/button.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/camera.ts b/frontend/src/ui/camera.ts new file mode 100644 index 0000000..8193681 --- /dev/null +++ b/frontend/src/ui/camera.ts @@ -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 { + return new Promise((resolve) => { + const photoCanvas = this.takePhoto(); + if (photoCanvas) { + photoCanvas.toBlob((blob) => { + resolve(blob); + }); + } else { + resolve(null); + } + }); + } + + public getElement(): HTMLElement { + return this.element; + } +} diff --git a/frontend/src/ui/canvas.ts b/frontend/src/ui/canvas.ts new file mode 100644 index 0000000..fdb48f0 --- /dev/null +++ b/frontend/src/ui/canvas.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/checkbox.ts b/frontend/src/ui/checkbox.ts new file mode 100644 index 0000000..468eff5 --- /dev/null +++ b/frontend/src/ui/checkbox.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/collapsable-container.ts b/frontend/src/ui/collapsable-container.ts new file mode 100644 index 0000000..34a5308 --- /dev/null +++ b/frontend/src/ui/collapsable-container.ts @@ -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"); + } + } +} diff --git a/frontend/src/ui/container.ts b/frontend/src/ui/container.ts new file mode 100644 index 0000000..c051d47 --- /dev/null +++ b/frontend/src/ui/container.ts @@ -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); + } +} \ No newline at end of file diff --git a/frontend/src/ui/date-picker.ts b/frontend/src/ui/date-picker.ts new file mode 100644 index 0000000..f91a442 --- /dev/null +++ b/frontend/src/ui/date-picker.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/dialog.ts b/frontend/src/ui/dialog.ts new file mode 100644 index 0000000..eb5909e --- /dev/null +++ b/frontend/src/ui/dialog.ts @@ -0,0 +1,76 @@ +import { UIWindow } from "./window"; +import { Button } from "./button"; + +export class Dialog extends UIWindow { + private resolvePromise!: (value: T | PromiseLike) => void; + private rejectPromise!: (reason?: any) => void; + private promise: Promise; + 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((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 { + 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; + } +} diff --git a/frontend/src/ui/dropdown.ts b/frontend/src/ui/dropdown.ts new file mode 100644 index 0000000..6832f26 --- /dev/null +++ b/frontend/src/ui/dropdown.ts @@ -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); + } + } +} diff --git a/frontend/src/ui/file-input.ts b/frontend/src/ui/file-input.ts new file mode 100644 index 0000000..6862837 --- /dev/null +++ b/frontend/src/ui/file-input.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/image.ts b/frontend/src/ui/image.ts new file mode 100644 index 0000000..53c2403 --- /dev/null +++ b/frontend/src/ui/image.ts @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/ui/index.ts b/frontend/src/ui/index.ts new file mode 100644 index 0000000..1277631 --- /dev/null +++ b/frontend/src/ui/index.ts @@ -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"; \ No newline at end of file diff --git a/frontend/src/ui/list-item.ts b/frontend/src/ui/list-item.ts new file mode 100644 index 0000000..cfbe4d0 --- /dev/null +++ b/frontend/src/ui/list-item.ts @@ -0,0 +1,34 @@ +import { UINode } from "./node"; + +export class ListItem extends UINode { + private listElement: HTMLLIElement; + + public constructor(title: string) { + super(title); + this.listElement = document.createElement("li"); + this.listElement.innerText = this.title; + this.listElement.setAttribute("tabindex", "-1"); + this.element.appendChild(this.listElement); + this.listElement.setAttribute("aria-label", this.title); + this.listElement.setAttribute("role", "option"); + } + + public focus() { + this.listElement.focus(); + } + + public click() { + this.listElement.click(); + } + + public getElement(): HTMLElement { + return this.listElement; + } + + public setText(text: string) { + this.title = text; + this.listElement.innerText = text; + this.element.setAttribute("aria-label", this.title); + this.listElement.setAttribute("aria-label", this.title); + } +} \ No newline at end of file diff --git a/frontend/src/ui/list.ts b/frontend/src/ui/list.ts new file mode 100644 index 0000000..f9943ba --- /dev/null +++ b/frontend/src/ui/list.ts @@ -0,0 +1,164 @@ +import { UINode } from "./node"; + + +export class List extends UINode { + public children: UINode[]; + protected listElement: HTMLUListElement; + private focused: number; + protected selectCallback?: (id: number) => void; + public constructor(title: string) { + super(title); + this.children = []; + this.listElement = document.createElement("ul"); + this.listElement.setAttribute("role", "listbox"); + this.listElement.style.listStyle = "none"; + this.element.appendChild(this.listElement); + this.element.setAttribute("aria-label", this.title); + this.focused = 0; + } + + public add(node: UINode) { + this.children.push(node); + node._onConnect(); + this.listElement.appendChild(node.render()); + if (this.children.length === 1) this.calculateTabIndex(); + node.onFocus(() => this.calculateFocused(node)); + } + + public addNodeAtIndex(node: UINode, index: number) { + index = Math.max(0, Math.min(index, this.children.length)); + this.children.splice(index, 0, node); + node._onConnect(); + this.listElement.insertBefore(node.render(), this.listElement.children[index]); + if (this.children.length === 1) this.calculateTabIndex(); + node.onFocus(() => this.calculateFocused(node)); + } + + public remove(node: UINode) { + const idx = this.children.indexOf(node); + this.children.splice(idx, 1); + node._onDisconnect(); + this.listElement.removeChild(node.render()); + if (idx === this.focused) { + if (this.focused > 0) this.focused--; + this.calculateTabIndex(); + } + } + + public _onFocus() { + super._onFocus(); + this.children[this.focused].focus(); + } + + public _onClick() { + // this.children[this.focused]._onClick(); + } + + public _onSelect(id: number) { + if (this.selectCallback) this.selectCallback(id); + } + + protected calculateStyle(): void { + super.calculateStyle(); + this.element.style.overflowY = "scroll"; + this.listElement.style.overflowY = "scroll"; + } + + public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean { + switch (key) { + case "ArrowUp": + this.children[this.focused].setTabbable(false); + this.focused = Math.max(0, this.focused - 1); + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + break; + case "ArrowDown": + this.children[this.focused].setTabbable(false); + this.focused = Math.min(this.children.length - 1, this.focused + 1); + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + break; + case "Enter": + this.children[this.focused].click(); + return true; + break; + case "Home": + this.children[this.focused].setTabbable(false); + this.focused = 0; + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + break; + case "End": + this.children[this.focused].setTabbable(false); + this.focused = this.children.length - 1; + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + break; + default: + return this.children[this.focused]._onKeydown(key); + break; + } + return false; + } + + protected renderAsListItem(node: UINode) { + let li = document.createElement("li"); + li.appendChild(node.render()); + return li; + } + + public getElement(): HTMLElement { + return this.listElement; + } + + public isItemFocused(): boolean { + const has = this.children.find((child) => child.isFocused); + if (has) { + return true; + } + return false; + } + + private calculateTabIndex() { + if (this.children.length < 1) return; + this.children[this.focused].setTabbable(true); + } + + public clear() { + this.children.forEach((child) => this.remove(child)); + this.children = []; + this.listElement.innerHTML = ''; + this.focused = 0; + } + + public getFocusedChild() { + return this.children[this.focused]; + } + + public getFocus() { + return this.focused; + } + + public onSelect(f: (id: number) => void) { + this.selectCallback = f; + } + + protected calculateFocused(node: UINode) { + const idx = this.children.indexOf(node); + this._onSelect(idx); + this.focused = idx; + } + + public scrollToBottom() { + this.children.forEach((child) => child.setTabbable(false)); + const node = this.children[this.children.length - 1]; + node.getElement().scrollIntoView(); + // set the focused element for tab index without focusing directly. + this.focused = this.children.length - 1; + this.children[this.focused].setTabbable(true); + } +} \ No newline at end of file diff --git a/frontend/src/ui/multiline-input.ts b/frontend/src/ui/multiline-input.ts new file mode 100644 index 0000000..155e851 --- /dev/null +++ b/frontend/src/ui/multiline-input.ts @@ -0,0 +1,43 @@ +import { UINode } from "./node"; + +export class MultilineInput extends UINode { + private id: string; + private titleElement: HTMLLabelElement; + private textareaElement: HTMLTextAreaElement; + public constructor(title: string) { + super(title); + this.id = Math.random().toString(); + this.titleElement = document.createElement("label"); + this.titleElement.innerText = title; + this.titleElement.id = `txtarea_title_${this.id}`; + this.textareaElement = document.createElement("textarea"); + this.textareaElement.id = `txtarea_${this.id}`; + this.titleElement.appendChild(this.textareaElement); + this.element.appendChild(this.titleElement); + } + + public focus() { + this.textareaElement.focus(); + } + + public click() { + this.textareaElement.click(); + } + + public getElement(): HTMLElement { + return this.textareaElement; + } + + public setText(text: string) { + this.title = text; + this.titleElement.innerText = text; + } + + public getValue(): string { + return this.textareaElement.value; + } + + public setValue(value: string) { + this.textareaElement.value = value; + } +} \ No newline at end of file diff --git a/frontend/src/ui/node.ts b/frontend/src/ui/node.ts new file mode 100644 index 0000000..bd0556c --- /dev/null +++ b/frontend/src/ui/node.ts @@ -0,0 +1,155 @@ +import { UITab } from "./tab"; + +export class UINode { + protected title: string; + protected element: HTMLDivElement; + protected position!: {x: number, y: number, width: number, height: number}; + protected positionType: string = "fixed"; + protected calculateOwnStyle: boolean = true; + protected keyDownCallback!: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void | undefined; + protected focusCallback?: () => void; + protected blurCallback?: () => void; + protected clickCallback?: () => void; + protected globalKeydown: boolean = false; + protected visible: boolean; + public isFocused: boolean; + private userdata: any; + + public constructor(title: string) { + this.title = title; + this.element = document.createElement("div"); + this.element.setAttribute("tabindex", "-1"); + this.visible = false; + this.isFocused = false; + } + + public focus() { + this.element.focus(); + } + + public click() { + this.element.click(); + } + + public _onConnect() { + this.calculateStyle(); + this.addListeners(); + return; + } + + public _onDisconnect() { + return; + } + + public _onFocus() { + if (this.focusCallback) this.focusCallback(); + this.isFocused = true; + return; + } + + public _onBlur() { + if (this.blurCallback) this.blurCallback(); + this.isFocused = false; + return; + } + + public _onClick() { + if (this.clickCallback) this.clickCallback(); + return; + } + + public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean { + if (this.keyDownCallback) { + if (this.globalKeydown || (!this.globalKeydown && document.activeElement === this.getElement())) { + this.keyDownCallback(key, alt, shift, ctrl); + return true; + } + } + return false; + } + + public render(): HTMLElement { + this.visible = true; + return this.element; + } + + protected addListeners() { + const elem = this.element; + this.getElement().addEventListener("focus", (e) => this._onFocus()); + elem.addEventListener("blur", (e) => this._onBlur()); + elem.addEventListener("click", (e) => this._onClick()); + elem.addEventListener("keydown", e => this._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey)); + } + + protected calculateStyle() { + if (!this.calculateOwnStyle || !this.position) return; + this.element.style.position = this.positionType; + this.element.style.left = `${this.position.x}%`; + this.element.style.top = `${this.position.y}%`; + this.element.style.width = `${this.position.width}%`; + this.element.style.height = `${this.position.height}%`; + } + + public setPosition(x: number, y: number, width: number, height: number, type: string = "fixed") { + this.position = { + x: x, + y: y, + width: width, + height: height, + }; + this.positionType = type; + this.calculateOwnStyle = true; + this.calculateStyle(); + } + + public onClick(f: () => void) { + this.clickCallback = f; + return this; + } + + public onFocus(f: () => void) { + this.focusCallback = f; + return this; + } + + public onKeyDown(f: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void, global: boolean = false) { + this.keyDownCallback = f; + this.globalKeydown = global; + return this; + } + + public onBlur(f: () => void) { + this.blurCallback = f; + return this; + } + + public getElement(): HTMLElement { + return this.element; + } + + public setTabbable(val: boolean) { + this.getElement().setAttribute("tabindex", + (val === true) ? "0" : + "-1"); + } + + public setAriaLabel(text: string) { + this.element.setAttribute("aria-label", text); + } + + public setRole(role: string) { + this.getElement().setAttribute("role", role); + } + + public getUserData(): any { + return this.userdata; + } + + public setUserData(obj: any) { + this.userdata = obj; + } + + public setAccessKey(key: string) { + this.getElement().accessKey = key; + } +} \ No newline at end of file diff --git a/frontend/src/ui/progress-bar.ts b/frontend/src/ui/progress-bar.ts new file mode 100644 index 0000000..080e1b6 --- /dev/null +++ b/frontend/src/ui/progress-bar.ts @@ -0,0 +1,37 @@ +import { UINode } from "./node"; + +export class ProgressBar extends UINode { + private progressElement: HTMLProgressElement; + public constructor(title: string, max: number) { + super(title); + this.progressElement = document.createElement("progress"); + this.progressElement.max = max; + this.element.appendChild(this.progressElement); + this.element.setAttribute("aria-label", title); + } + + public getElement(): HTMLElement { + return this.progressElement; + } + + public setText(text: string) { + this.title = text; + this.element.setAttribute("aria-label", text); + } + + public getValue(): number { + return this.progressElement.value; + } + + public setValue(value: number) { + this.progressElement.value = value; + } + + public getMax(): number { + return this.progressElement.max; + } + + public setMax(max: number) { + this.progressElement.max = max; + } +} \ No newline at end of file diff --git a/frontend/src/ui/radio-group.ts b/frontend/src/ui/radio-group.ts new file mode 100644 index 0000000..7a872e1 --- /dev/null +++ b/frontend/src/ui/radio-group.ts @@ -0,0 +1,76 @@ +import { UINode } from "./node"; + +export class RadioGroup extends UINode { + private id: string; + private titleElement: HTMLLegendElement; + private containerElement: HTMLFieldSetElement; + private radioElements: Map; + private radioLabels: Map; + + public constructor(title: string, options: { key: string; value: string }[]) { + super(title); + this.id = Math.random().toString(); + this.titleElement = document.createElement("legend"); + this.titleElement.innerText = title; + this.titleElement.id = `rdgrp_title_${this.id}`; + this.containerElement = document.createElement("fieldset"); + this.containerElement.appendChild(this.titleElement); + this.element.appendChild(this.containerElement); + + this.radioElements = new Map(); + this.radioLabels = new Map(); + + options.forEach((option) => { + const radioId = `rd_${this.id}_${option.key}`; + const radioElement = document.createElement("input"); + radioElement.id = radioId; + radioElement.type = "radio"; + radioElement.name = `rdgrp_${this.id}`; + radioElement.value = option.key; + radioElement.setAttribute("aria-labeledby", `${radioId}_label`); + + const radioLabel = document.createElement("label"); + radioLabel.innerText = option.value; + radioLabel.id = `${radioId}_label`; + radioLabel.setAttribute("for", radioId); + + this.radioElements.set(option.key, radioElement); + this.radioLabels.set(option.key, radioLabel); + + this.containerElement.appendChild(radioElement); + this.containerElement.appendChild(radioLabel); + }); + } + + public focus() { + const firstRadioElement = this.radioElements.values().next().value; + if (firstRadioElement) { + firstRadioElement.focus(); + } + } + + public getElement(): HTMLElement { + return this.containerElement; + } + + public setText(text: string) { + this.title = text; + this.titleElement.innerText = text; + } + + public getSelectedValue(): string | null { + for (const [key, radioElement] of this.radioElements.entries()) { + if (radioElement.checked) { + return key; + } + } + return null; + } + + public setSelectedValue(value: string) { + const radioElement = this.radioElements.get(value); + if (radioElement) { + radioElement.checked = true; + } + } +} \ No newline at end of file diff --git a/frontend/src/ui/slider.ts b/frontend/src/ui/slider.ts new file mode 100644 index 0000000..fa08fcf --- /dev/null +++ b/frontend/src/ui/slider.ts @@ -0,0 +1,47 @@ +import { UINode } from "./node"; + +export class Slider extends UINode { + private id: string; + private titleElement: HTMLLabelElement; + private sliderElement: HTMLInputElement; + public constructor(title: string, min: number, max: number, step: number = 1) { + super(title); + this.id = Math.random().toString(); + this.titleElement = document.createElement("label"); + this.titleElement.innerText = title; + this.titleElement.id = `sldr_title_${this.id}`; + this.sliderElement = document.createElement("input"); + this.sliderElement.id = `sldr_${this.id}`; + this.sliderElement.type = "range"; + this.sliderElement.min = min.toString(); + this.sliderElement.max = max.toString(); + this.sliderElement.step = step.toString(); + this.titleElement.appendChild(this.sliderElement); + this.element.appendChild(this.titleElement); + } + + public focus() { + this.sliderElement.focus(); + } + + public click() { + this.sliderElement.click(); + } + + public getElement(): HTMLElement { + return this.sliderElement; + } + + public setText(text: string) { + this.title = text; + this.titleElement.innerText = text; + } + + public getValue(): number { + return parseInt(this.sliderElement.value); + } + + public setValue(value: number) { + this.sliderElement.value = value.toString(); + } +} \ No newline at end of file diff --git a/frontend/src/ui/tab-bar.ts b/frontend/src/ui/tab-bar.ts new file mode 100644 index 0000000..44318de --- /dev/null +++ b/frontend/src/ui/tab-bar.ts @@ -0,0 +1,97 @@ +import { UINode } from "./node"; +import { UITab } from "./tab"; + +export class TabBar extends UINode { + private tabs: UITab[]; + private tabBarContainer: HTMLDivElement; + private onTabChangeCallback?: (index: number) => void; + private focused: number; + + public constructor(title: string = "tab bar") { + super(title); + this.tabs = []; + this.tabBarContainer = document.createElement("div"); + this.tabBarContainer.setAttribute("role", "tablist"); + this.tabBarContainer.style.display = "flex"; + this.tabBarContainer.style.alignItems = "center"; + // this.tabBarContainer.style.justifyContent = "space-between"; + this.tabBarContainer.style.overflow = "hidden"; + + this.element.appendChild(this.tabBarContainer); + this.focused = 0; + } + + public _onFocus() { + this.tabs[this.focused].focus(); + } + + public focus() { + this.tabs[this.focused].focus(); + } + + public add(title: string) { + const idx = this.tabs.length; + const elem = new UITab(title); + elem.onClick(() => { + this.selectTab(idx); + }); + this.tabs.push(elem); + this.tabBarContainer.appendChild(elem.render()); + elem._onConnect(); + if (this.tabs.length === 1) this.calculateTabIndex(); + } + + public onTabChange(f: (index: number) => void) { + this.onTabChangeCallback = f; + } + + private selectTab(idx: number) { + if (idx !== this.focused) { + this.tabs[this.focused].setTabbable(false); + this.focused = idx; + } + if (!this.onTabChangeCallback) return; + this.onTabChangeCallback(idx); + this.tabs[idx].setTabbable(true); + this.tabs[idx].focus(); + this.updateView(); + } + + public _onKeydown(key: string): boolean { + switch (key) { + case "ArrowLeft": + this.tabs[this.focused].setTabbable(false); + this.focused = Math.max(0, this.focused - 1); + this.tabs[this.focused].setTabbable(true); + this.selectTab(this.focused); + return true; + break; + case "ArrowRight": + this.tabs[this.focused].setTabbable(false); + this.focused = Math.min(this.tabs.length - 1, this.focused + 1); + this.tabs[this.focused].setTabbable(true); + this.selectTab(this.focused); + return true; + break; + default: + return false; + break; + } + return false; + } + + private updateView() { + for (let i = 0; i < this.tabs.length; i++) { + this.tabs[i].setSelected(i === this.focused); + } + } + + public getElement(): HTMLElement { + return this.element; + } + + + public calculateTabIndex() { + this.tabs[this.focused].setTabbable(true); + } +} \ No newline at end of file diff --git a/frontend/src/ui/tab.ts b/frontend/src/ui/tab.ts new file mode 100644 index 0000000..c8b7b31 --- /dev/null +++ b/frontend/src/ui/tab.ts @@ -0,0 +1,40 @@ +import { UINode } from "./node"; + +export class UITab extends UINode { + private textElement: HTMLButtonElement; + private selected: boolean; + + public constructor(title: string) { + super(title); + this.title = title; + this.textElement = document.createElement("button"); + this.textElement.innerText = title; + this.textElement.setAttribute("tabindex", "-1"); + this.textElement.setAttribute("role", "tab"); + this.textElement.setAttribute("aria-selected", "false"); + this.element.appendChild(this.textElement); + this.selected = false; + } + + public focus() { + this.textElement.focus(); + } + + public click() { + this.textElement.click(); + } + + public getElement(): HTMLElement { + return this.textElement; + } + + public setText(text: string) { + this.title = text; + this.textElement.innerText = text; + } + + public setSelected(val: boolean) { + this.selected = val; + this.textElement.setAttribute("aria-selected", this.selected.toString()); + } +} \ No newline at end of file diff --git a/frontend/src/ui/tabbed-view.ts b/frontend/src/ui/tabbed-view.ts new file mode 100644 index 0000000..a96a7de --- /dev/null +++ b/frontend/src/ui/tabbed-view.ts @@ -0,0 +1,50 @@ +import { UINode } from "./node"; +import { TabBar } from "./tab-bar"; +import { Container } from "./container"; + + +export class TabbedView extends UINode { + private bar: TabBar; + private containers: Container[]; + private containerElement: HTMLDivElement; + private barAtTop: boolean; + private currentView?: Container; + public constructor(title: string, barAtTop: boolean = true) { + super(title); + this.bar = new TabBar(); + this.bar._onConnect(); + this.bar.onTabChange((index: number) => this.onTabChanged(index)); + this.containers = []; + this.containerElement = document.createElement("div"); + this.element.appendChild(this.bar.render()); + this.element.appendChild(this.containerElement); + this.element.setAttribute("tabindex", "-1"); + this.barAtTop = barAtTop; + } + + public add(name: string, container: Container) { + this.bar.add(name); + container.setRole("tabpanel"); + this.containers.push(container); + } + + private onTabChanged(idx: number) { + if (this.currentView) { + this.containerElement.removeChild(this.currentView.render()); + } + this.currentView = this.containers[idx]; + this.containerElement.appendChild(this.currentView.render()); + } + + public getElement(): HTMLElement { + return this.containerElement; + } + + protected calculateStyle(): void { + if (this.barAtTop) { + this.bar.setPosition(0, 0, 100, 5); + } else { + this.bar.setPosition(0, 90, 100, 5); + } + } +} \ No newline at end of file diff --git a/frontend/src/ui/text-input.ts b/frontend/src/ui/text-input.ts new file mode 100644 index 0000000..3020449 --- /dev/null +++ b/frontend/src/ui/text-input.ts @@ -0,0 +1,44 @@ +import { UINode } from "./node"; + +export class TextInput extends UINode { + private id: string; + private titleElement: HTMLLabelElement; + private inputElement: HTMLInputElement; + public constructor(title: string) { + super(title); + this.id = Math.random().toString(); + this.titleElement = document.createElement("label"); + this.titleElement.innerText = title; + this.titleElement.id = `inpt_title_${this.id}`; + this.inputElement = document.createElement("input"); + this.inputElement.id = `inpt_${this.id}`; + this.inputElement.type = "text"; + this.titleElement.appendChild(this.inputElement); + this.element.appendChild(this.titleElement); + } + + public focus() { + this.inputElement.focus(); + } + + public click() { + this.inputElement.click(); + } + + public getElement(): HTMLElement { + return this.inputElement; + } + + public setText(text: string) { + this.title = text; + this.titleElement.innerText = text; + } + + public getValue(): string { + return this.inputElement.value; + } + + public setValue(value: string) { + this.inputElement.value = value; + } +} \ No newline at end of file diff --git a/frontend/src/ui/text.ts b/frontend/src/ui/text.ts new file mode 100644 index 0000000..e37bb8b --- /dev/null +++ b/frontend/src/ui/text.ts @@ -0,0 +1,29 @@ +import { UINode } from "./node"; + +export class Text extends UINode { + private textElement: HTMLSpanElement; + public constructor(title: string) { + super(title); + this.textElement = document.createElement("span"); + this.textElement.innerText = title; + this.textElement.setAttribute("tabindex", "-1"); + this.element.appendChild(this.textElement); + } + + public focus() { + this.textElement.focus(); + } + + public click() { + this.textElement.click(); + } + + public getElement(): HTMLElement { + return this.textElement; + } + + public setText(text: string) { + this.title = text; + this.textElement.innerText = text; + } +} \ No newline at end of file diff --git a/frontend/src/ui/time-picker.ts b/frontend/src/ui/time-picker.ts new file mode 100644 index 0000000..f39aad9 --- /dev/null +++ b/frontend/src/ui/time-picker.ts @@ -0,0 +1,40 @@ +import { UINode } from "./node"; + +export class TimePicker extends UINode { + private id: string; + private titleElement: HTMLLabelElement; + private inputElement: HTMLInputElement; + public constructor(title: string) { + super(title); + this.id = Math.random().toString(); + this.titleElement = document.createElement("label"); + this.titleElement.innerText = title; + this.titleElement.id = `timepicker_title_${this.id}`; + this.inputElement = document.createElement("input"); + this.inputElement.id = `timepicker_${this.id}`; + this.inputElement.type = "time"; + this.titleElement.appendChild(this.inputElement); + this.element.appendChild(this.titleElement); + } + + public focus() { + this.inputElement.focus(); + } + + public getElement(): HTMLElement { + return this.inputElement; + } + + public setText(text: string) { + this.title = text; + this.titleElement.innerText = text; + } + + public getValue(): string { + return this.inputElement.value; + } + + public setValue(value: string) { + this.inputElement.value = value; + } +} \ No newline at end of file diff --git a/frontend/src/ui/treelist-item.ts b/frontend/src/ui/treelist-item.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/ui/treelist.ts b/frontend/src/ui/treelist.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/ui/treeview-item.ts b/frontend/src/ui/treeview-item.ts new file mode 100644 index 0000000..87a8c52 --- /dev/null +++ b/frontend/src/ui/treeview-item.ts @@ -0,0 +1,198 @@ +import { UINode } from "./node"; +import { Treeview } from "./treeview"; + +export class TreeviewItem extends UINode { + private listElement: HTMLLIElement; + private childContainer!: HTMLUListElement; + + public children: TreeviewItem[]; + + private expanded!: boolean; + + private focused: number; + + private parent?: TreeviewItem; + + private root!: Treeview; + + private previousItem?: TreeviewItem; + private nextItem?: TreeviewItem; + + public constructor(title: string) { + super(title); + this.listElement = document.createElement("li"); + this.listElement.innerText = this.title; + this.listElement.setAttribute("tabindex", "-1"); + this.listElement.setAttribute("role", "treeitem"); + this.element.appendChild(this.listElement); + this.listElement.setAttribute("aria-label", this.title); + this.children = []; + this.focused = 0; + } + + public focus() { + this.listElement.focus(); + } + + public click() { + this.listElement.click(); + } + + public getElement(): HTMLElement { + return this.listElement; + } + + public setText(text: string) { + this.title = text; + this.listElement.innerText = text; + this.element.setAttribute("aria-label", this.title); + this.listElement.setAttribute("aria-label", this.title); + } + + public add(node: TreeviewItem) { + this.children.push(node); + node.setParent(this); + this.setExpanded(false); + if (this.children.length > 0) { + this.previousItem = this.children[this.children.length - 1]; + this.previousItem.setNextItem(node); + } + } + + public remove(node: TreeviewItem) { + const idx = this.children.indexOf(node); + if (idx > -1) { + this.children.splice(idx, 1); + this.updateShownItems(); + } + } + + public expand() { + if (!this.isExpandable()) return; + if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) { + this.children[this.focused].expand(); + return; + } + this.setExpanded(true); + this.updateShownItems(); + this.children[this.focused].focus(); + } + + public collapse(ignoreFocus: boolean = false) { + if (!this.isExpandable()) { + if (this.getElement() !== document.activeElement && ignoreFocus) return; + this.parent?.collapse(true); + return; + } + this.setExpanded(false); + this.updateShownItems(); + setTimeout(() => this.focus(), 0); + } + + public isExpandable(): boolean { + return this.children.length > 0; + } + + public isExpanded() { + return this.expanded; + } + + private setExpanded(val: boolean) { + this.expanded = val; + if (this.expanded) { + this.listElement.setAttribute("aria-expanded", "true"); + return; + } + this.listElement.setAttribute("aria-expanded", "false"); + } + + private updateShownItems() { + if (this.expanded) { + if (!this.childContainer) { + this.childContainer = document.createElement("ul"); + this.childContainer.setAttribute("role", "group"); + this.children.forEach((child) => this.childContainer.appendChild(child.render())); + this.listElement.appendChild(this.childContainer); + } else { + this.childContainer.hidden = false; + } + } else { + this.childContainer.hidden = true; + // this.listElement.removeChild(this.childContainer); + } + } + + public focusNext(): boolean { + if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) { + return this.children[this.focused].focusNext(); + } + this.children[this.focused].setTabbable(false); + this.focused = Math.min(this.children.length - 1, this.focused + 1); + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + } + + public focusPrevious(): boolean { + if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) { + return this.children[this.focused].focusPrevious(); + } + this.children[this.focused].setTabbable(false); + this.focused = Math.max(0, this.focused - 1); + this.children[this.focused].setTabbable(true); + this.children[this.focused].focus(); + return true; + } + + public setParent(item: TreeviewItem) { + this.parent = this.parent; + } + + public getParent(): TreeviewItem|undefined { + return this.parent; + } + + public setPrevious(node: TreeviewItem) { + this.previousItem = node; + } + + public setNextItem(node: TreeviewItem) { + this.nextItem = node; + } + + public getPrevious(): TreeviewItem|undefined { + return this.previousItem; + } + + public getNext(): TreeviewItem|undefined { + return this.nextItem; + } + + public _onKeydown(key: string, alt?: boolean, shift?: boolean, ctrl?: boolean): boolean { + switch (key) { + case "ArrowUp": + this.focusPrevious(); + return true; + break; + case "ArrowDown": + this.focusNext(); + return true; + case "ArrowLeft": + this.collapse(); + return true; + case "ArrowRight": + if (this.children[this.focused].isExpandable() && !this.children[this.focused].isExpanded()) { + this.children[this.focused].expand(); + return true; + } + break; + default: + return false; + } + return false; + } + + public focusOnItem() { + this.children[this.focused].focus(); + } +} \ No newline at end of file diff --git a/frontend/src/ui/treeview.ts b/frontend/src/ui/treeview.ts new file mode 100644 index 0000000..727d716 --- /dev/null +++ b/frontend/src/ui/treeview.ts @@ -0,0 +1,151 @@ +import { UINode } from "./node"; +import { TreeviewItem } from "./treeview-item"; + +export class Treeview extends UINode { + public children: TreeviewItem[]; + protected listElement: HTMLUListElement; + private focused: number; + protected selectCallback?: (id: number) => void; + public constructor(title: string) { + super(title); + this.children = []; + this.listElement = document.createElement("ul"); + this.listElement.setAttribute("role", "tree"); + this.listElement.style.listStyle = "none"; + this.element.appendChild(this.listElement); + this.element.setAttribute("aria-label", this.title); + this.focused = 0; + } + + public add(node: TreeviewItem) { + this.children.push(node); + node._onConnect(); + this.listElement.appendChild(node.render()); + if (this.children.length === 1) this.calculateTabIndex(); + node.onFocus(() => this.calculateFocused(node)); + } + + public remove(node: TreeviewItem) { + const idx = this.children.indexOf(node); + this.children.splice(idx, 1); + node._onDisconnect(); + this.listElement.removeChild(node.render()); + if (idx === this.focused) { + if (this.focused > 0) this.focused--; + this.calculateTabIndex(); + } + } + + public _onFocus() { + super._onFocus(); + this.children[this.focused].focus(); + } + + public _onClick() { + this.children[this.focused]._onClick(); + } + + public _onSelect(id: number) { + if (this.selectCallback) this.selectCallback(id); + } + + protected calculateStyle(): void { + super.calculateStyle(); + this.element.style.overflowY = "scroll"; + this.listElement.style.overflowY = "scroll"; + } + + public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean { + switch (key) { + case "ArrowUp": + return this.focusPrevious(); + break; + case "ArrowDown": + return this.focusNext(); + break; + case "Enter": + this.children[this.focused].click(); + return true; + break; + case "ArrowLeft": + // this.children[this.focused].collapse(); + return true; + break; + case "ArrowRight": + this.children[this.focused].expand(); + return true; + break; + default: + return this.children[this.focused]._onKeydown(key); + break; + } + return false; + } + + protected renderAsListItem(node: UINode) { + let li = document.createElement("li"); + li.appendChild(node.render()); + return li; + } + + public getElement(): HTMLElement { + return this.listElement; + } + + public isItemFocused(): boolean { + const has = this.children.find((child) => child.isFocused); + if (has) { + return true; + } + return false; + } + + private calculateTabIndex() { + this.children[this.focused].setTabbable(true); + } + + public clear() { + this.children.forEach((child) => this.remove(child)); + this.children = []; + this.listElement.innerHTML = ''; + this.focused = 0; + } + + public getFocusedChild() { + return this.children[this.focused]; + } + + public getFocus() { + return this.focused; + } + + public onSelect(f: (id: number) => void) { + this.selectCallback = f; + } + + protected calculateFocused(node: TreeviewItem) { + const idx = this.children.indexOf(node); + this._onSelect(idx); + } + + public focusPrevious() { + if (this.children[this.focused].isExpanded()) { + // return this.children[this.focused].focusPrevious(); + } else { + this.focused = Math.max(0, this.focused - 1); + this.children[this.focused].focus(); + } + return true; + } + + public focusNext() { + if (this.children[this.focused].isExpanded()) { + // return this.children[this.focused].focusNext(); + } else { + this.focused = Math.min(this.children.length - 1, this.focused + 1); + this.children[this.focused].focus(); + } + + return true; + } +} \ No newline at end of file diff --git a/frontend/src/ui/video.ts b/frontend/src/ui/video.ts new file mode 100644 index 0000000..29b61b6 --- /dev/null +++ b/frontend/src/ui/video.ts @@ -0,0 +1,54 @@ +import { UINode } from "./node"; + +export class Video extends UINode { + private videoElement: HTMLVideoElement; + + public constructor(title: string, src: string | MediaStream = "") { + super(title); + this.videoElement = document.createElement("video"); + if (typeof src === "string") { + this.videoElement.src = src; // Set src if it's a string URL + } else if (src instanceof MediaStream) { + this.videoElement.srcObject = src; // Set srcObject if it's a MediaStream + } + this.videoElement.setAttribute("aria-label", title); + this.element.appendChild(this.videoElement); + this.setRole("video"); + } + + public getElement(): HTMLElement { + return this.videoElement; + } + + public setSource(src: string | MediaStream) { + if (typeof src === "string") { + this.videoElement.src = src; + } else if (src instanceof MediaStream) { + this.videoElement.srcObject = src; + } + } + + public play() { + this.videoElement.play(); + } + + public pause() { + this.videoElement.pause(); + } + + public setControls(show: boolean) { + this.videoElement.controls = show; + } + + public setLoop(loop: boolean) { + this.videoElement.loop = loop; + } + + public setMuted(muted: boolean) { + this.videoElement.muted = muted; + } + + public setAutoplay(autoplay: boolean) { + this.videoElement.autoplay = autoplay; + } +} diff --git a/frontend/src/ui/window.ts b/frontend/src/ui/window.ts new file mode 100644 index 0000000..2fcecac --- /dev/null +++ b/frontend/src/ui/window.ts @@ -0,0 +1,80 @@ +import { Container } from "./container"; +import { UINode } from "./node"; + +export class UIWindow { + public title: string; + public width!: number; + public height!: number; + public position!: { x: number; y: number; }; + public container: Container; + public visible: boolean; + private element: HTMLDivElement; + private rendered!: boolean; + private keyDown: (e: KeyboardEvent) => void; + + public constructor( + title: string, + classname?: string, + private setTitle: boolean = true + ) { + this.title = title; + this.container = new Container(this.title); + this.container._onConnect(); + this.element = document.createElement("div"); + if (classname) { + this.element.className = classname; + } + this.keyDown = this.onKeyDown.bind(this); + this.visible = false; + } + + public add(node: UINode) { + this.container.add(node); + } + + public remove(node: UINode) { + if (this.container.children.includes(node)) this.container.remove(node); + } + + public show(): HTMLElement|undefined { + if (this.visible) return; + if (this.setTitle) document.title = this.title; + if (this.rendered) return this.element; + this.element.appendChild(this.container.render()); + this.element.addEventListener("keydown", this.keyDown); + this.element.focus(); + this.visible = true; + this.rendered = true; + return this.element; + } + + public hide() { + if (!this.visible) return; + this.visible = false; + this.rendered = false; + this.element.replaceChildren(); + this.element.removeEventListener("keydown", this.keyDown); + } + + public onKeyDown(e: KeyboardEvent) { + if (this.container._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey)) { + e.preventDefault(); + } + } + + public onConnect() { + return; + } + + public onDisconnect() { + return; + } + + public getElement(): HTMLElement { + return this.element; + } + + public getContainer(): Container { + return this.container; + } +} \ No newline at end of file diff --git a/frontend/src/views/authorize.ts b/frontend/src/views/authorize.ts new file mode 100644 index 0000000..016a128 --- /dev/null +++ b/frontend/src/views/authorize.ts @@ -0,0 +1,56 @@ +import { showToast } from "../speech"; +import { Button, Text, TextInput } from "../ui"; +import { View } from "./view"; +import { state } from "../state"; +import { API } from "../api"; +import { MainView } from "./main"; +import { playSound } from "../sound"; + +export class AuthorizeView extends View { + private welcomeText!: Text; + private apiURLInput!: TextInput; + private tokenInput!: TextInput; + private loginButton!: Button; + + public onActivate(): void { + playSound("intro"); + } + public onDeactivate(): void { + } + public onCreate(): void { + this.welcomeText = new Text("Welcome to Notebrook!"); + this.welcomeText.setPosition(25, 10, 75, 20); + this.apiURLInput = new TextInput("API URL"); + this.apiURLInput.setPosition(40, 40, 20, 20); + this.tokenInput = new TextInput("Token"); + this.tokenInput.setPosition(40, 60, 20, 10); + this.loginButton = new Button("Login"); + this.loginButton.setPosition(40, 70, 20, 10); + this.window.add(this.welcomeText); + this.window.add(this.apiURLInput); + this.window.add(this.tokenInput); + this.window.add(this.loginButton); + + this.loginButton.onClick(async () => { + const token = this.tokenInput.getValue(); + const apiUrl = this.apiURLInput.getValue(); + API.path = apiUrl; + API.token = token; + try { + await API.checkToken(); + state.token = token; + state.apiUrl = apiUrl; + state.save(); + showToast(`Welcome!`, 2000); + playSound("login"); + this.viewManager.push(new MainView(this.viewManager)); + } catch (e) { + showToast(`Invalid API URL or token provided.`); + playSound("uploadFailed"); + } + }); + } + + public onDestroy(): void { + } +} \ No newline at end of file diff --git a/frontend/src/views/main.ts b/frontend/src/views/main.ts new file mode 100644 index 0000000..ada7b86 --- /dev/null +++ b/frontend/src/views/main.ts @@ -0,0 +1,505 @@ +import { API } from "../api"; +import { ChunkProcessor } from "../chunk-processor"; +import { ChannelDialog } from "../dialogs/channel-dialog"; +import { CreateChannelDialog } from "../dialogs/create-channel"; +import { MessageDialog } from "../dialogs/message"; +import { RecordAudioDialog } from "../dialogs/record-audio"; +import { SearchDialog } from "../dialogs/search"; +import { SettingsDialog } from "../dialogs/settings"; +import { TakePhotoDialog } from "../dialogs/take-photo"; +import { Channel } from "../model/channel"; +import { IMessage, Message } from "../model/message"; +import { UnsentMessage } from "../model/unsent-message"; +import { playSent, playSound, playWater } from "../sound"; +import { showToast } from "../speech"; +import { state } from "../state"; +import { Button, List, ListItem, TextInput, UINode } from "../ui"; +import { Dropdown } from "../ui/dropdown"; +import { FileInput } from "../ui/file-input"; +import { MultilineInput } from "../ui/multiline-input"; +import { connectToWebsocket } from "../websockets"; +import { AuthorizeView } from "./authorize"; +import { View } from "./view"; + +export class MainView extends View { + private settingsButton!: Button; + private channelSwitcher!: Dropdown; + private channelInfoButton!: Button; + private searchButton!: Button; + private fileInput!: FileInput; + private messageInput!: MultilineInput; + private imageInput!: Button; + private voiceMessageInput!: Button; + private messageList!: List; + private updateInterval!: number; + + private messageElementMap: Map = new Map(); + + public onActivate(): void { + if (!state.currentChannel) { + if (state.defaultChannelId) { + this.switchChannel(state.defaultChannelId.toString()); + } else { + if (state.channelList.channels.length > 0) this.switchChannel(state.channelList.channels[0].id.toString()); + } + } + this.renderInitialMessageList(); + this.checkAuthorization(); + this.syncChannels(); + this.updateChannelList(); + this.syncMessages(); + this.updateInterval = setInterval(() => { + this.updateVisibleMessageShownTimestamps(); + }, 1000); + setTimeout(() => this.attemptToSendUnsentMessages(), 2000); + } + + + public onDeactivate(): void { + clearInterval(this.updateInterval); + } + + public onCreate(): void { + this.settingsButton = new Button("Settings"); + this.settingsButton.setPosition(0, 0, 10, 10); + this.settingsButton.onClick(() => { + new SettingsDialog().open(); + }); + this.channelSwitcher = new Dropdown("Channel", []); + this.channelSwitcher.setPosition(30, 10, 30, 10); + this.channelInfoButton = new Button("Channel info"); + this.channelInfoButton.setPosition(60, 10, 30, 10); + this.searchButton = new Button("Search"); + this.searchButton.setPosition(90, 10, 10, 10); + this.searchButton.onClick(async () => { + const searchDialog = new SearchDialog(); + const res = await searchDialog.open(); + if (res) { + if (res.channelId && res.messageId) { + if (state.currentChannel?.id !== res.channelId) { + this.switchChannel(res.channelId.toString()); + this.renderInitialMessageList(); + } + const message = state.currentChannel!.getMessage(res.messageId); + if (message) { + this.messageElementMap.get(message.id)?.focus(); + } + } + } + }) + this.fileInput = new FileInput("Upload file"); + this.fileInput.setPosition(0, 90, 15, 10); + this.imageInput = new Button("Image"); + this.imageInput.setPosition(15, 90, 15, 10); + this.messageInput = new MultilineInput("New message"); + this.messageInput.setPosition(30, 90, 60, 10); + this.messageInput.getElement().autofocus = true; + this.voiceMessageInput = new Button("Voice message"); + this.voiceMessageInput.setPosition(70, 90, 30, 10); + + this.messageList = new List("Messages"); + this.messageList.setPosition(30, 30, 60, 50); + this.window.add(this.settingsButton); + this.window.add(this.channelSwitcher); + this.window.add(this.channelInfoButton); + this.window.add(this.searchButton); + this.window.add(this.messageList); + this.window.add(this.messageInput); + this.window.add(this.fileInput); + this.window.add(this.imageInput); + this.window.add(this.voiceMessageInput); + this.channelSwitcher.getElement().addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement; + if (target.value === "__new__") { + this.createNewChannel(); + } else { + this.switchChannel(target.value); + this.renderInitialMessageList(); + this.syncMessages(); + } + }); + this.voiceMessageInput.onClick(async () => { + const blob = await new RecordAudioDialog().open(); + if (blob) { + this.uploadVoiceMessage(blob); + } + }) + + this.messageInput.onKeyDown((key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => { + if (key === "Enter") { + if (!shift) { + console.log(key, alt, shift, ctrl); + this.sendMessage(); + } + } + }); + this.channelInfoButton.onClick(() => { + if (this.channelSwitcher.getSelectedValue() === "__new__") { + this.createNewChannel(); + return; + } + const d = new ChannelDialog(state.currentChannel!); + d.open().then((chan) => { + state.save(); + this.updateChannelList(); + }); + }) + this.imageInput.onClick(async () => { + const photo = await new TakePhotoDialog().open(); + this.uploadImage(photo); + }); + } + + public onDestroy(): void { + + } + + private async syncChannels() { + const channels = await API.getChannels(); + channels.forEach((chan) => state.addChannel(new Channel(chan))); + this.updateChannelList(); + if (!state.currentChannel) { + if (state.defaultChannelId) { + this.switchChannel(state.defaultChannelId.toString()); + } else { + this.switchChannel(state.channelList.channels[0].id.toString()); + } + } + state.save(); + } + + private updateChannelList() { + this.channelSwitcher.clearOptions(); + state.getChannels().forEach((chan) => { + this.channelSwitcher.addOption(chan.id.toString(), chan.name); + }); + this.channelSwitcher.addOption("__new__", "Add new channel"); + } + + private checkAuthorization() { + if (!state.token || !state.apiUrl) { + this.viewManager.push(new AuthorizeView(this.viewManager)); + } else { + API.token = state.token; + API.path = state.apiUrl; + connectToWebsocket(); + } + + state.save(); + } + + private async syncMessages() { + if (!state.currentChannel) return; + if (!state.currentChannel.messages) state.currentChannel.messages = []; + const channelId = state.currentChannel.id; + if (channelId) { + const messages = await API.getMessages(channelId.toString()); + // only render new list items, or list items that have changed. + const proc = new ChunkProcessor(100); + proc.processArray(messages, (chunk: IMessage[]) => { + chunk.forEach((message: IMessage) => { + // TODO: this could do with a lot of perf improvements. I'll get to it once this is an issue. + const existing = state.currentChannel!.getMessage(message.id); + if (!existing) { + state.currentChannel!.addMessage(new Message(message)); + this.renderAndAddMessage(message); + } else { + // TODO: this is awful and needs to be updated, but it works for now. + if (existing.content !== message.content || existing.fileId !== message.fileId || existing.filePath !== message.filePath || existing.fileType !== message.fileType || existing.createdAt !== message.createdAt) { + existing.content = message.content; + existing.fileId = message.fileId; + existing.filePath = message.filePath; + existing.fileType = message.fileType; + existing.createdAt = message.createdAt; + existing.fileId = message.fileId; + existing.filePath = message.filePath; + existing.fileSize = message.fileSize; + const renderedMessage = this.messageElementMap.get(message.id); + if (renderedMessage) { + (renderedMessage as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`); + } + } + } + }); + }); + } + state.save(); + } + + public switchChannel(channelId: string) { + if (this.messageList.children.length > 0) this.messageList.clear(); + const chan = state.getChannelById(parseInt(channelId)); + if (!chan) { + throw new Error("Could not find channel " + channelId); + } + state.currentChannel = chan; + state.save(); + } + + private renderMessage(message: IMessage): UINode { + const itm = new ListItem(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`); + itm.setUserData(message); + itm.onClick(() => { + new MessageDialog(message).open(); + }) + return itm; + } + + private renderInitialMessageList(reset: boolean = false) { + if (!state.currentChannel) return; + if (!state.currentChannel.messages || state.currentChannel.messages.length < 1) return; + if (this.messageList.children.length > 0 && !reset) { + return; + } else { + this.messageList.clear(); + this.messageElementMap.clear(); + } + state.currentChannel.messages.forEach((message) => { + this.renderAndAddMessage(message); + }); + this.messageList.scrollToBottom(); + } + + private async createNewChannel() { + const name = await new CreateChannelDialog().open(); + if (name) { + const chan = await API.createChannel(name); + state.addChannel(new Channel(chan)); + this.updateChannelList(); + if (state.channelList.channels.length < 2) { + state.defaultChannelId = chan.id; + } + state.save(); + } + } + + private async sendMessage() { + if (this.fileInput && this.fileInput.getFiles() && this.fileInput.getFiles()!.length > 0) { + return this.uploadFile(); + } + if (this.messageInput.getValue().length > 0) { + const messageContent = this.messageInput.getValue(); + this.messageInput.setValue(""); + playWater(); + try { + const message: IMessage = await API.createMessage(state.currentChannel!.id.toString(), messageContent); + this.messageInput.setValue(""); + this.renderAndAddMessage(message); + this.messageList.scrollToBottom(); + playSent(); + state.save(); + } catch (e) { + showToast("Could not post message. Will retry later.", 3000); + playSound("uploadFailed"); + const unsentId = Date.now(); + state.unsentMessages.push(new UnsentMessage({ + channelId: state.currentChannel!.id, + content: messageContent, + createdAt: new Date().toISOString(), + id: unsentId + })); + const tmpMessage: IMessage = new Message({ + id: unsentId, + content: messageContent, + createdAt: new Date().toISOString(), + }); + state.currentChannel!.addMessage(tmpMessage); + this.renderAndAddMessage(tmpMessage); + state.save(); + } + } + } + + private async uploadVoiceMessage(blob: Blob) { + playWater(); + const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "Voice message"; + this.messageInput.setValue(""); + const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent); + const id = msg.id; + try { + const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob); + if (msg) { + msg.fileId = response.fileId; + msg.filePath = response.filePath; + msg.fileType = response.fileType; + state.currentChannel!.addMessage(new Message(msg)); + this.renderAndAddMessage(msg); + playSent(); + state.save(); + } else { + showToast("Something went wrong during message file upload."); + playSound("uploadFailed"); + // TODO: Handle the case when no message is found + } + } catch (e) { + playSound("uploadFailed"); + showToast("Unable to send message. Will retry later.", 3000); + state.unsentMessages.push(new UnsentMessage({ + channelId: state.currentChannel!.id, + content: msgContent, + createdAt: new Date().toISOString(), + blob: blob, + id: Date.now() + })); + state.save(); + } + } + + private async uploadFile() { + if (!this.fileInput.getFiles()) return; + if (this.fileInput!.getFiles()!.length < 1) return; + const file = this.fileInput!.getFiles()![0]; + if (file) { + playWater(); + const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "File upload"; + this.messageInput.setValue(""); + try { + const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent); + const id = msg.id; + const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), file); + if (msg) { + msg.fileId = response.fileId; + msg.filePath = response.filePath; + msg.fileType = response.fileType; + state.currentChannel!.addMessage(new Message(msg)); + this.renderAndAddMessage(msg); + playSent(); + this.messageInput.setValue(""); + // reset the file picker + (this.fileInput.getElement() as HTMLInputElement).value = ""; + state.save(); + } else { + showToast("Error while uploading file."); + playSound("uploadFailed"); + // TODO: Handle the case when no message is found + } + } catch (e) { + showToast("Could not post message. Will retry later.", 3000); + playSound("uploadFailed"); + state.unsentMessages.push(new UnsentMessage({ + channelId: state.currentChannel!.id, + content: this.messageInput.getValue(), + createdAt: new Date().toISOString(), + blob: file, + id: Date.now() + })); + state.save(); + } + } + } + + private convertIsoTimeStringToRelative(isoTimeString: string): string { + const date = new Date(isoTimeString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + if (diff < 1000 * 60) { + return `${Math.floor(diff / 1000)} seconds ago`; + } else if (diff < 1000 * 60 * 60) { + // return both minutes and seconds + return `${Math.floor(diff / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`; + } else if (diff < 1000 * 60 * 60 * 24) { + // return hours, minutes, seconds ago + return `${Math.floor(diff / (1000 * 60 * 60))} hours ${Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`; + } else { + return date.toLocaleString(); + } + } + + private updateVisibleMessageShownTimestamps() { + const lowerIndex = Math.max(this.messageList.getFocus() - 10, 0); + const upperIndex = Math.min(this.messageList.getFocus() + 10, this.messageList.children.length); + for (let i = lowerIndex; i < upperIndex; i++) { + const child = this.messageList.children[i]; + if (!child) break; + const message = child.getUserData() as IMessage; + if (message) { + (child as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToRelative(message.createdAt)}`); + } + } + } + + private async attemptToSendUnsentMessages() { + state.unsentMessages.forEach(async (msg) => { + if (msg.blob) { + const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content); + const id = apiMsg.id; + const response: any = await API.uploadFile(msg.channelId.toString(), id.toString(), msg.blob); + if (apiMsg) { + apiMsg.fileId = response.fileId; + apiMsg.filePath = response.filePath; + apiMsg.fileType = response.fileType; + state.currentChannel!.addMessage(new Message(apiMsg)); + this.renderAndAddMessage(apiMsg); + playSent(); + state.unsentMessages = state.unsentMessages.filter((m) => m !== msg); + this.removeSpecificMessageFromList(msg.id); + state.save(); + } + } else { + const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content); + state.currentChannel!.addMessage(new Message(apiMsg)); + this.renderAndAddMessage(apiMsg); + state.unsentMessages = state.unsentMessages.filter((m) => m !== msg); + playSent(); + this.removeSpecificMessageFromList(msg.id); + state.save(); + } + }); + } + + private clearUnsentMessageDisplay() { + this.messageList.children.forEach((msg) => { + const data = msg.getUserData() as IMessage; + if (data.id === -1) { + this.messageList.remove(msg); + } + }) + } + + private removeSpecificMessageFromList(id: number) { + const elem = this.messageElementMap.get(id); + if (elem) { + this.messageList.remove(elem); + this.messageElementMap.delete(id); + } + } + + private async uploadImage(blob: Blob) { + playWater(); + try { + const msg = await API.createMessage(state.currentChannel!.id.toString(), "Image"); + const id = msg.id; + const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob); + if (msg) { + msg.fileId = response.fileId; + msg.filePath = response.filePath; + msg.fileType = response.fileType; + state.currentChannel!.addMessage(new Message(msg)); + this.renderAndAddMessage(msg); + playSent(); + state.save(); + } else { + showToast("Error while uploading file."); + playSound("uploadFailed"); + // TODO: Handle the case when no message is found + } + } catch (e) { + showToast("Could not post message. Will retry later.", 3000); + playSound("uploadFailed"); + state.unsentMessages.push(new UnsentMessage({ + channelId: state.currentChannel!.id, + content: "Image", + createdAt: new Date().toISOString(), + blob: blob, + id: Date.now() + })); + state.save(); + } + } + + private renderAndAddMessage(message: IMessage) { + const elem = this.renderMessage(message); + this.messageList.add(elem); + this.messageElementMap.set(message.id, elem); + } +} \ No newline at end of file diff --git a/frontend/src/views/view-manager.ts b/frontend/src/views/view-manager.ts new file mode 100644 index 0000000..34e933d --- /dev/null +++ b/frontend/src/views/view-manager.ts @@ -0,0 +1,74 @@ +import { UIWindow } from "../ui/window"; +import { View } from "./view"; + +export class ViewManager { + private currentView: View | undefined | null; + private views: View[]; + private window: UIWindow; + public constructor() { + this.views = []; + this.window = new UIWindow("Notebrook"); + this.currentView = null; + } + + public add(view: View) { + this.views.push(view); + view.onCreate(); + } + + public remove(view: View) { + this.views.splice(this.views.indexOf(view), 1); + view.onDestroy(); + if (view === this.currentView) this.window.remove(this.currentView.show()); + if (this.currentView) this.currentView.setActive(false); + this.currentView = null; + } + + public switchTo(view: View) { + if (!this.views.includes(view)) { + throw new Error("View not initialized"); + } + if (this.currentView) { + this.currentView.onDeactivate(); + this.currentView.setActive(false); + this.window.remove(this.currentView.show()); + } + this.currentView = view; + this.currentView.setActive(true); + this.currentView.onActivate(); + this.window.add(this.currentView.show()); + } + + public render(): HTMLElement|undefined { + return this.window.show(); + } + + public push(view: View) { + if (this.currentView) { + this.currentView.onDeactivate(); + this.currentView.setActive(false); + this.window.remove(this.currentView.show()); + } + + this.views.unshift(view); + this.currentView = view; + this.currentView.onCreate(); + this.currentView.setActive(true); + this.currentView.onActivate(); + this.window.add(this.currentView.show()); + } + + public pop() { + if (this.currentView) { + this.currentView.onDeactivate(); + this.currentView.setActive(false); + this.window.remove(this.currentView.show()); + this.currentView.onDestroy(); + } + this.views.splice(0, 1); + this.currentView = this.views[0]; + this.currentView.setActive(true); + this.currentView.onActivate(); + this.window.add(this.currentView.show()); + } +} diff --git a/frontend/src/views/view.ts b/frontend/src/views/view.ts new file mode 100644 index 0000000..ec507a3 --- /dev/null +++ b/frontend/src/views/view.ts @@ -0,0 +1,33 @@ +import { Container } from "../ui/container"; +import { UIWindow } from "../ui/window"; +import { ViewManager } from "./view-manager"; + +export abstract class View { + protected viewManager: ViewManager; + protected window: Container; + private active!: boolean; + public constructor(viewManager: ViewManager) { + this.viewManager = viewManager; + this.window = new Container("Base view"); + } + + public show() { + return this.window; + } + + public abstract onActivate(): void; + + public abstract onDeactivate(): void; + + public abstract onCreate(): void; + + public abstract onDestroy(): void; + + public isActive() { + return this.isActive; + } + + public setActive(val: boolean) { + this.active = val; + } +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/websockets.ts b/frontend/src/websockets.ts new file mode 100644 index 0000000..68f9f0c --- /dev/null +++ b/frontend/src/websockets.ts @@ -0,0 +1,16 @@ +import { API } from "./api"; + +export const connectToWebsocket = () => { + const ws = new WebSocket(`ws://localhost:3000`); + ws.onopen = () => { + console.log("Connected to websocket server"); + } + ws.onmessage = (data) => { + const message = JSON.parse(data.data.toString()); + console.log(message); + } + ws.onclose= () => { + console.log("Disconnected from websocket server"); + } + return ws; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..aab648f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..004c6cd --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; + + + +export default defineConfig({ + plugins: [ + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['intro.wav', 'login.wav', 'sent1.wav', 'sent2.wav', 'sent3.wav', 'sent4.wav', 'sent5.wav', 'sent6.wav', 'uploadfail.wav', 'water1.wav', 'water2.wav', 'water3.wav', 'water4.wav', 'water5.wav', 'water6.wav', 'water7.wav', 'water8.wav', 'water9.wav', 'water10.wav', 'index.html'], + manifest: { + name: 'Notebrook', + short_name: 'Notebrook', + description: 'Notebrook, stream of consciousness accessible note taking', + theme_color: '#ffffff', + icons: [ + { + src: 'icons/192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'icons/512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + }, + workbox: { + + // workbox options for the service worker + }, + }), + ], +}); \ No newline at end of file diff --git a/maybe-dockerfile.txt b/maybe-dockerfile.txt new file mode 100644 index 0000000..240bbfe --- /dev/null +++ b/maybe-dockerfile.txt @@ -0,0 +1,33 @@ +I'll improve the docker build eventually, so I'll keep the planned improvs here. But for now it might be better to use the bigger multistage docker file. + +# Use the official Bun image +FROM oven/bun:1 AS base +WORKDIR /usr/src/app + +# Install dependencies for both backend and frontend +COPY backend/package.json backend/bun.lockb frontend/package.json frontend/bun.lockb /usr/src/app/ +RUN bun install --production --cwd backend && \ + bun install --production --cwd frontend + +# Build the frontend project +COPY frontend/ /usr/src/app/frontend +RUN bun run --cwd frontend build + +# Prepare for final release +FROM oven/bun:1 AS release +WORKDIR /usr/src/app + +# Copy production dependencies +COPY --from=base /usr/src/app/backend/node_modules backend/node_modules +COPY --from=base /usr/src/app/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=base /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" ]