Compare commits
16 Commits
60c2a18dbe
...
feat/new-f
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d4a6ab76 | |||
| 9ea94e0e31 | |||
| 81022c4e21 | |||
| ebe8389cb3 | |||
| 051c032f1d | |||
| 7e49e14901 | |||
| 74fbd7dc4a | |||
| 619fcdb9ae | |||
| d786a7463b | |||
| fca1046047 | |||
| 221aa1c2af | |||
| bfe77ae86a | |||
| 181ae28548 | |||
| fab05f32ec | |||
| ec1a2ba7f0 | |||
| 64f0f55d10 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ backend/uploads/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend-vue/dist
|
frontend-vue/dist
|
||||||
|
.npm/**
|
||||||
|
|||||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `frontend-vue/` – Vue 3 + Vite app. Key folders: `src/components`, `src/views`, `src/stores`, `src/services`, `src/composables`, `src/router`, and static assets in `public/`.
|
||||||
|
- `backend/` – TypeScript API + WebSocket server. Key folders: `src/controllers`, `src/routes`, `src/services`, `src/utils`, `src/logging`, `src/jobs`. Database files in `schema.sql` and `migrations/`.
|
||||||
|
- `etc/systemd/` – example unit files for deployment. `dockerfile` – container build (optional).
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- Frontend
|
||||||
|
- `cd frontend-vue && npm install`
|
||||||
|
- `npm run dev` – start Vite dev server.
|
||||||
|
- `npm run build` – type-check + production build.
|
||||||
|
- `npm run preview` – preview production build.
|
||||||
|
- `npm run lint` / `npm run format` – ESLint / Prettier.
|
||||||
|
- Backend
|
||||||
|
- `cd backend && npm install`
|
||||||
|
- `npm run dev` – start server with `tsx --watch`.
|
||||||
|
- `npm run start` – start server once (prod-like).
|
||||||
|
- Note: backend was initialized with Bun; Node/npm + tsx is the primary flow.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- General: TypeScript, 2-space indent, small focused modules. File names: lowercase-kebab for modules (e.g., `message-service.ts`), `PascalCase.vue` for Vue SFCs.
|
||||||
|
- Frontend: Prettier + ESLint enforced; prefer single quotes; composition API; component names in `PascalCase`.
|
||||||
|
- Backend: Keep semicolons and consistent import quoting (matches current files). Use `PascalCase` for types/classes, `camelCase` for variables/functions.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- No formal test runner is configured yet. If adding tests:
|
||||||
|
- Place unit tests alongside code as `*.spec.ts` or in a `__tests__/` directory.
|
||||||
|
- Keep tests fast and deterministic; document manual verification steps in PRs until a runner is introduced.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Commits: imperative mood, concise subject (<=72 chars), include scope prefix when helpful, e.g. `frontend: fix message input blur` or `backend: add channel search`.
|
||||||
|
- PRs must include: clear description, linked issue (if any), screenshots/GIFs for UI changes, manual test steps, and notes on migrations if touching `backend/migrations/`.
|
||||||
|
- Ensure `npm run lint` (frontend) passes and the app runs locally for both services before requesting review.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Backend reads environment from `.env` (see `backend/src/config.ts`): important keys include `DB_PATH`, `API_TOKEN`, `UPLOAD_DIR`, `PORT`, `USE_SSL`, `OPENAI_API_KEY`, `OLLAMA_URL`, and related model settings.
|
||||||
|
- Do not commit secrets. Provide `.env` examples in PRs when adding new variables.
|
||||||
|
- For local SSL, set `USE_SSL=1` and supply `SSL_KEY/SSL_CERT`, or let the server generate a self-signed pair for development.
|
||||||
|
|
||||||
3
backend/migrations/3_checked.sql
Normal file
3
backend/migrations/3_checked.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add tri-state checked column to messages (NULL | 0 | 1)
|
||||||
|
ALTER TABLE messages ADD COLUMN checked INTEGER NULL;
|
||||||
|
|
||||||
2018
backend/package-lock.json
generated
2018
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,22 +14,22 @@
|
|||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.18.1",
|
||||||
"better-sqlite3": "^11.2.1",
|
"better-sqlite3": "^12.5.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^5.2.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.2",
|
||||||
"ollama": "^0.5.8",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^4.56.0",
|
"openai": "^6.9.1",
|
||||||
"selfsigned": "^2.4.1",
|
"selfsigned": "^5.2.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.18.0",
|
"tsx": "^4.21.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
channelId INTEGER,
|
channelId INTEGER,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
fileId INTEGER NULL,
|
fileId INTEGER NULL,
|
||||||
|
checked INTEGER NULL,
|
||||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
|
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as ChannelRoutes from "./routes/channel";
|
|||||||
import * as FileRoutes from "./routes/file";
|
import * as FileRoutes from "./routes/file";
|
||||||
import * as MessageRoutes from "./routes/message";
|
import * as MessageRoutes from "./routes/message";
|
||||||
import * as SearchRoutes from "./routes/search";
|
import * as SearchRoutes from "./routes/search";
|
||||||
|
import * as BackupRoutes from "./routes/backup";
|
||||||
import { authenticate } from "./middleware/auth";
|
import { authenticate } from "./middleware/auth";
|
||||||
import { initializeDB } from "./db";
|
import { initializeDB } from "./db";
|
||||||
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
|
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
|
||||||
@@ -20,6 +21,7 @@ app.use("/channels", ChannelRoutes.router);
|
|||||||
app.use("/channels/:channelId/messages", MessageRoutes.router);
|
app.use("/channels/:channelId/messages", MessageRoutes.router);
|
||||||
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
|
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
|
||||||
app.use("/search", SearchRoutes.router);
|
app.use("/search", SearchRoutes.router);
|
||||||
|
app.use("/backup", BackupRoutes.router);
|
||||||
|
|
||||||
app.get('/check-token', authenticate, (req, res) => {
|
app.get('/check-token', authenticate, (req, res) => {
|
||||||
res.json({ message: 'Token is valid' });
|
res.json({ message: 'Token is valid' });
|
||||||
|
|||||||
@@ -78,3 +78,20 @@ export const moveMessage = async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ error: 'Failed to move message' });
|
res.status(500).json({ error: 'Failed to move message' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setChecked = async (req: Request, res: Response) => {
|
||||||
|
const { messageId } = req.params;
|
||||||
|
const { checked } = req.body as { checked: boolean | null | undefined };
|
||||||
|
if (!messageId) {
|
||||||
|
return res.status(400).json({ error: 'Message ID is required' });
|
||||||
|
}
|
||||||
|
const value = (checked === undefined) ? null : checked;
|
||||||
|
// Ensure message exists; treat no-change updates as success
|
||||||
|
const existing = await MessageService.getMessage(messageId);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Message not found' });
|
||||||
|
}
|
||||||
|
await MessageService.setMessageChecked(messageId, value);
|
||||||
|
logger.info(`Message ${messageId} checked set to ${value}`);
|
||||||
|
res.json({ id: parseInt(messageId), checked: value });
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { DB_PATH } from './config';
|
import { DB_PATH } from './config';
|
||||||
import { logger } from './globals';
|
import { logger } from './globals';
|
||||||
import { readdir, readFile } from "fs/promises";
|
import { readdir, readFile } from "fs/promises";
|
||||||
|
import { existsSync, mkdirSync } from "fs";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
|
|
||||||
export let FTS5Enabled = true;
|
export let FTS5Enabled = true;
|
||||||
@@ -57,6 +58,18 @@ export const migrate = async () => {
|
|||||||
|
|
||||||
logger.info(`Loading database at ${DB_PATH}`);
|
logger.info(`Loading database at ${DB_PATH}`);
|
||||||
|
|
||||||
|
// Ensure parent directory exists (avoid better-sqlite3 directory error)
|
||||||
|
try {
|
||||||
|
const dir = dirname(DB_PATH);
|
||||||
|
// Skip if dir is current directory or drive root-like (e.g., "C:")
|
||||||
|
const isTrivialDir = dir === '.' || dir === '' || /^[A-Za-z]:\\?$/.test(dir);
|
||||||
|
if (!isTrivialDir && !existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to ensure DB directory exists: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
export const db = new Database(DB_PATH);
|
export const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
162
backend/src/routes/backup.ts
Normal file
162
backend/src/routes/backup.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { db, FTS5Enabled } from '../db';
|
||||||
|
import { DB_PATH } from '../config';
|
||||||
|
import { logger } from '../globals';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { unlink } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
const upload = multer({ dest: tmpdir() });
|
||||||
|
|
||||||
|
// GET /backup - Download the entire database as a .db file
|
||||||
|
router.get('/', authenticate, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
logger.info('Creating database backup...');
|
||||||
|
|
||||||
|
// Use better-sqlite3's backup API to create a safe copy
|
||||||
|
const backupPath = join(tmpdir(), `notebrook-backup-${Date.now()}.db`);
|
||||||
|
await db.backup(backupPath);
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `notebrook-backup-${timestamp}.db`;
|
||||||
|
|
||||||
|
res.download(backupPath, filename, async (err) => {
|
||||||
|
// Clean up temp file after download
|
||||||
|
try {
|
||||||
|
await unlink(backupPath);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to clean up backup temp file: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
logger.critical(`Backup download error: ${err}`);
|
||||||
|
} else {
|
||||||
|
logger.info('Backup download completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.critical(`Backup failed: ${error}`);
|
||||||
|
res.status(500).json({ error: 'Failed to create backup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /restore - Upload a .db file and restore the database
|
||||||
|
router.post('/', authenticate, upload.single('database'), async (req: Request, res: Response) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No database file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedPath = req.file.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Restoring database from uploaded file: ${uploadedPath}`);
|
||||||
|
|
||||||
|
// Open the uploaded database to validate and read data
|
||||||
|
const uploadedDb = new Database(uploadedPath, { readonly: true });
|
||||||
|
|
||||||
|
// Validate that it has the expected tables
|
||||||
|
const tables = uploadedDb.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[];
|
||||||
|
const tableNames = tables.map(t => t.name);
|
||||||
|
|
||||||
|
if (!tableNames.includes('channels') || !tableNames.includes('messages')) {
|
||||||
|
uploadedDb.close();
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
return res.status(400).json({ error: 'Invalid backup file: missing required tables' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all data from uploaded database
|
||||||
|
const channels = uploadedDb.prepare('SELECT * FROM channels').all();
|
||||||
|
const messages = uploadedDb.prepare('SELECT * FROM messages').all();
|
||||||
|
const files = tableNames.includes('files')
|
||||||
|
? uploadedDb.prepare('SELECT * FROM files').all()
|
||||||
|
: [];
|
||||||
|
const meta = tableNames.includes('meta')
|
||||||
|
? uploadedDb.prepare('SELECT * FROM meta').all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
uploadedDb.close();
|
||||||
|
|
||||||
|
// Begin transaction to restore data
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
// Clear existing data (order matters due to foreign keys)
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
db.exec('DELETE FROM messages_fts');
|
||||||
|
}
|
||||||
|
db.exec('DELETE FROM messages');
|
||||||
|
db.exec('DELETE FROM files');
|
||||||
|
db.exec('DELETE FROM channels');
|
||||||
|
|
||||||
|
// Reset auto-increment counters
|
||||||
|
db.exec(`DELETE FROM sqlite_sequence WHERE name IN ('channels', 'messages', 'files')`);
|
||||||
|
|
||||||
|
// Insert channels
|
||||||
|
if (channels.length > 0) {
|
||||||
|
const insertChannel = db.prepare(`
|
||||||
|
INSERT INTO channels (id, name, created_at) VALUES (@id, @name, @created_at)
|
||||||
|
`);
|
||||||
|
for (const channel of channels) {
|
||||||
|
insertChannel.run(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert files first (messages reference files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
const insertFile = db.prepare(`
|
||||||
|
INSERT INTO files (id, channel_id, file_path, file_type, file_size, original_name, created_at)
|
||||||
|
VALUES (@id, @channel_id, @file_path, @file_type, @file_size, @original_name, @created_at)
|
||||||
|
`);
|
||||||
|
for (const file of files) {
|
||||||
|
insertFile.run(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert messages
|
||||||
|
if (messages.length > 0) {
|
||||||
|
const insertMessage = db.prepare(`
|
||||||
|
INSERT INTO messages (id, channel_id, content, file_id, checked, created_at)
|
||||||
|
VALUES (@id, @channel_id, @content, @file_id, @checked, @created_at)
|
||||||
|
`);
|
||||||
|
for (const message of messages) {
|
||||||
|
insertMessage.run(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild FTS index
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
// Clean up uploaded file
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
|
||||||
|
logger.info('Database restore completed successfully');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Database restored successfully',
|
||||||
|
stats: {
|
||||||
|
channels: channels.length,
|
||||||
|
messages: messages.length,
|
||||||
|
files: files.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.critical(`Restore failed: ${error}`);
|
||||||
|
|
||||||
|
// Clean up uploaded file on error
|
||||||
|
try {
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to restore database: ' + (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ export const router = Router({mergeParams: true});
|
|||||||
router.post('/', authenticate, MessageController.createMessage);
|
router.post('/', authenticate, MessageController.createMessage);
|
||||||
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
||||||
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
|
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
|
||||||
|
router.put('/:messageId/checked', authenticate, MessageController.setChecked);
|
||||||
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
||||||
router.get('/', authenticate, MessageController.getMessages);
|
router.get('/', authenticate, MessageController.getMessages);
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ const getOrCreateCertificate = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSelfSignedSSLCert = async () => {
|
const createSelfSignedSSLCert = async () => {
|
||||||
const selfsigned = await import('selfsigned');
|
const pems = await selfSigned.generate([{ name: 'commonName', value: 'localhost' }], {
|
||||||
const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], {
|
|
||||||
keySize: 2048,
|
keySize: 2048,
|
||||||
days: 365
|
algorithm: 'sha256'
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
key: pems.private,
|
key: pems.private,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db, FTS5Enabled } from "../db";
|
|||||||
import { events } from "../globals";
|
import { events } from "../globals";
|
||||||
|
|
||||||
export const createMessage = async (channelId: string, content: string) => {
|
export const createMessage = async (channelId: string, content: string) => {
|
||||||
const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
|
const query = db.prepare(`INSERT INTO messages (channelId, content, checked) VALUES ($channelId, $content, NULL)`);
|
||||||
const result = query.run({ channelId: channelId, content: content });
|
const result = query.run({ channelId: channelId, content: content });
|
||||||
|
|
||||||
const messageId = result.lastInsertRowid;
|
const messageId = result.lastInsertRowid;
|
||||||
@@ -49,7 +49,7 @@ export const deleteMessage = async (messageId: string) => {
|
|||||||
export const getMessages = async (channelId: string) => {
|
export const getMessages = async (channelId: string) => {
|
||||||
const query = db.prepare(`
|
const query = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
messages.id, messages.channelId, messages.content, messages.createdAt,
|
messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked,
|
||||||
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||||
FROM
|
FROM
|
||||||
messages
|
messages
|
||||||
@@ -67,7 +67,7 @@ export const getMessages = async (channelId: string) => {
|
|||||||
export const getMessage = async (id: string) => {
|
export const getMessage = async (id: string) => {
|
||||||
const query = db.prepare(`
|
const query = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
messages.id, messages.channelId, messages.content, messages.createdAt,
|
messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked,
|
||||||
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||||
FROM
|
FROM
|
||||||
messages
|
messages
|
||||||
@@ -82,6 +82,15 @@ export const getMessage = async (id: string) => {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setMessageChecked = async (messageId: string, checked: boolean | null) => {
|
||||||
|
const query = db.prepare(`UPDATE messages SET checked = $checked WHERE id = $id`);
|
||||||
|
// SQLite stores booleans as integers; NULL for unknown
|
||||||
|
const value = checked === null ? null : (checked ? 1 : 0);
|
||||||
|
const result = query.run({ id: messageId, checked: value });
|
||||||
|
events.emit('message-updated', messageId, { checked: value });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const moveMessage = async (messageId: string, targetChannelId: string) => {
|
export const moveMessage = async (messageId: string, targetChannelId: string) => {
|
||||||
// Get current message to emit proper events
|
// Get current message to emit proper events
|
||||||
const currentMessage = await getMessage(messageId);
|
const currentMessage = await getMessage(messageId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Message {
|
|||||||
channel_id: number;
|
channel_id: number;
|
||||||
content: string;
|
content: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
checked?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface File {
|
export interface File {
|
||||||
|
|||||||
2518
frontend-vue/package-lock.json
generated
2518
frontend-vue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,27 +12,29 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13",
|
"@types/jszip": "^3.4.0",
|
||||||
"vue-router": "^4.4.5",
|
"@vueuse/core": "^14.1.0",
|
||||||
"pinia": "^2.3.0",
|
"@vueuse/sound": "^2.1.3",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.2",
|
||||||
"@vueuse/core": "^11.3.0",
|
"jszip": "^3.10.1",
|
||||||
"@vueuse/sound": "^2.0.1"
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
"typescript": "^5.7.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"vue-tsc": "^2.1.10",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"vite-plugin-pwa": "^0.21.2",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
"eslint": "^9.39.1",
|
||||||
"@typescript-eslint/parser": "^8.18.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"@vue/eslint-config-prettier": "^10.1.0",
|
"prettier": "^3.7.3",
|
||||||
"@vue/eslint-config-typescript": "^14.1.3",
|
"typescript": "^5.9.3",
|
||||||
"eslint": "^9.17.0",
|
"vite": "^7.2.6",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"prettier": "^3.4.2"
|
"vue-tsc": "^3.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,9 @@ const emit = defineEmits<{
|
|||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
emit('click', event as any)
|
const btn = event.currentTarget as HTMLButtonElement | null
|
||||||
|
// Trigger native click so type="submit" works and parent @click receives it
|
||||||
|
btn?.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
'dialog',
|
'dialog',
|
||||||
`dialog--${size}`
|
`dialog--${size}`
|
||||||
]"
|
]"
|
||||||
|
tabindex="-1"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="dialog__header" v-if="$slots.header || title">
|
<div class="dialog__header" v-if="$slots.header || title">
|
||||||
@@ -87,6 +88,12 @@ const handleOverlayClick = () => {
|
|||||||
let lastFocusedElement: HTMLElement | null = null
|
let lastFocusedElement: HTMLElement | null = null
|
||||||
|
|
||||||
const trapFocus = (event: KeyboardEvent) => {
|
const trapFocus = (event: KeyboardEvent) => {
|
||||||
|
// Close on Escape regardless of focused element when dialog is open
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.key !== 'Tab') return
|
if (event.key !== 'Tab') return
|
||||||
|
|
||||||
const focusableElements = dialogRef.value?.querySelectorAll(
|
const focusableElements = dialogRef.value?.querySelectorAll(
|
||||||
@@ -101,12 +108,12 @@ const trapFocus = (event: KeyboardEvent) => {
|
|||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
if (document.activeElement === firstElement) {
|
if (document.activeElement === firstElement) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
lastElement.focus()
|
lastElement?.focus()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (document.activeElement === lastElement) {
|
if (document.activeElement === lastElement) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
firstElement.focus()
|
firstElement?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,16 +126,24 @@ watch(() => props.show, async (isVisible) => {
|
|||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Focus first focusable element or the dialog itself
|
// Focus [autofocus] first, then first focusable, else the dialog itself
|
||||||
const firstFocusable = dialogRef.value?.querySelector(
|
const root = dialogRef.value as HTMLElement | undefined
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
const selector = '[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
) as HTMLElement
|
const firstFocusable = root?.querySelector(selector) as HTMLElement | null
|
||||||
|
|
||||||
if (firstFocusable) {
|
if (firstFocusable) {
|
||||||
firstFocusable.focus()
|
firstFocusable.focus()
|
||||||
} else {
|
} else {
|
||||||
dialogRef.value?.focus()
|
root?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry shortly after in case slotted children mount slightly later
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!root) return
|
||||||
|
if (!root.contains(document.activeElement)) {
|
||||||
|
const retryTarget = (root.querySelector(selector) as HTMLElement) || root
|
||||||
|
retryTarget?.focus()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
document.removeEventListener('keydown', trapFocus)
|
document.removeEventListener('keydown', trapFocus)
|
||||||
|
|||||||
@@ -30,6 +30,27 @@
|
|||||||
🎤
|
🎤
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="$emit('toggle-check')"
|
||||||
|
aria-label="Toggle check on focused message"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="open-url-button"
|
||||||
|
@click="$emit('open-url')"
|
||||||
|
aria-label="Open URL in focused message"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -59,6 +80,8 @@ defineEmits<{
|
|||||||
'file-upload': []
|
'file-upload': []
|
||||||
'camera': []
|
'camera': []
|
||||||
'voice': []
|
'voice': []
|
||||||
|
'toggle-check': []
|
||||||
|
'open-url': []
|
||||||
'send': []
|
'send': []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -70,4 +93,12 @@ defineEmits<{
|
|||||||
gap: 0.25rem; /* Reduced gap to save space */
|
gap: 0.25rem; /* Reduced gap to save space */
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-only for the checked toggle button and open URL button */
|
||||||
|
.input-actions [aria-label="Toggle check on focused message"],
|
||||||
|
.input-actions .open-url-button { display: none; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.input-actions [aria-label="Toggle check on focused message"],
|
||||||
|
.input-actions .open-url-button { display: inline-flex; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
@file-upload="$emit('file-upload')"
|
@file-upload="$emit('file-upload')"
|
||||||
@camera="$emit('camera')"
|
@camera="$emit('camera')"
|
||||||
@voice="$emit('voice')"
|
@voice="$emit('voice')"
|
||||||
|
@toggle-check="$emit('toggle-check')"
|
||||||
|
@open-url="$emit('open-url')"
|
||||||
@send="handleSubmit"
|
@send="handleSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,6 +37,8 @@ const emit = defineEmits<{
|
|||||||
'file-upload': []
|
'file-upload': []
|
||||||
'camera': []
|
'camera': []
|
||||||
'voice': []
|
'voice': []
|
||||||
|
'toggle-check': []
|
||||||
|
'open-url': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
]"
|
]"
|
||||||
ref="rootEl"
|
ref="rootEl"
|
||||||
:data-message-id="message.id"
|
:data-message-id="message.id"
|
||||||
:tabindex="tabindex || -1"
|
:tabindex="tabindex ?? -1"
|
||||||
:aria-label="messageAriaLabel"
|
:aria-label="messageAriaLabel"
|
||||||
role="option"
|
role="option"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
|
@focus="handleFocus"
|
||||||
>
|
>
|
||||||
<div class="message__content">
|
<div class="message__content">
|
||||||
|
<span v-if="isChecked === true" class="message__check" aria-hidden="true">✔</span>
|
||||||
|
<span v-else-if="isChecked === false" class="message__check message__check--unchecked" aria-hidden="true">☐</span>
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,6 +25,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message__meta">
|
<div class="message__meta">
|
||||||
|
<button
|
||||||
|
class="message__toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="toggleAriaLabel"
|
||||||
|
@click.stop="toggleChecked()"
|
||||||
|
>
|
||||||
|
<span v-if="isChecked === true">Uncheck</span>
|
||||||
|
<span v-else-if="isChecked === false">Check</span>
|
||||||
|
<span v-else>Check</span>
|
||||||
|
</button>
|
||||||
<time
|
<time
|
||||||
v-if="!isUnsent && 'created_at' in message"
|
v-if="!isUnsent && 'created_at' in message"
|
||||||
class="message__time"
|
class="message__time"
|
||||||
@@ -53,6 +66,9 @@ interface Props {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||||
|
'focus': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -81,6 +97,11 @@ const hasFileAttachment = computed(() => {
|
|||||||
return 'fileId' in props.message && !!props.message.fileId
|
return 'fileId' in props.message && !!props.message.fileId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tri-state checked
|
||||||
|
const isChecked = computed<boolean | null>(() => {
|
||||||
|
return (props as any).message?.checked ?? null
|
||||||
|
})
|
||||||
|
|
||||||
// Create FileAttachment object from flattened message data
|
// Create FileAttachment object from flattened message data
|
||||||
const fileAttachment = computed((): FileAttachmentType | null => {
|
const fileAttachment = computed((): FileAttachmentType | null => {
|
||||||
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
|
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
|
||||||
@@ -112,8 +133,16 @@ const fileAttachment = computed((): FileAttachmentType | null => {
|
|||||||
|
|
||||||
// Create comprehensive aria-label for screen readers
|
// Create comprehensive aria-label for screen readers
|
||||||
const messageAriaLabel = computed(() => {
|
const messageAriaLabel = computed(() => {
|
||||||
|
let prefix = ''
|
||||||
let label = ''
|
let label = ''
|
||||||
|
|
||||||
|
// Checked state first
|
||||||
|
if ((props as any).message?.checked === true) {
|
||||||
|
prefix = 'checked, '
|
||||||
|
} else if ((props as any).message?.checked === false) {
|
||||||
|
prefix = 'unchecked, '
|
||||||
|
}
|
||||||
|
|
||||||
// Add message content
|
// Add message content
|
||||||
if (props.message.content) {
|
if (props.message.content) {
|
||||||
label += props.message.content
|
label += props.message.content
|
||||||
@@ -137,7 +166,7 @@ const messageAriaLabel = computed(() => {
|
|||||||
label += '. Message is sending'
|
label += '. Message is sending'
|
||||||
}
|
}
|
||||||
|
|
||||||
return label
|
return `${prefix}${label}`.trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper to determine file type for better description
|
// Helper to determine file type for better description
|
||||||
@@ -167,17 +196,64 @@ const handleClick = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract URLs from text content
|
||||||
|
const extractUrls = (text: string): string[] => {
|
||||||
|
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
|
||||||
|
const matches = text.match(urlRegex) || []
|
||||||
|
// Remove duplicates
|
||||||
|
return [...new Set(matches)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Shift+Enter: open URL(s) or fall back to edit
|
||||||
|
const handleOpenUrl = () => {
|
||||||
|
if (props.isUnsent) return
|
||||||
|
|
||||||
|
const urls = extractUrls(props.message.content)
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
// No links found, fall back to edit
|
||||||
|
emit('open-dialog-edit', props.message)
|
||||||
|
} else if (urls.length === 1) {
|
||||||
|
// Single link, open directly
|
||||||
|
window.open(urls[0], '_blank', 'noopener,noreferrer')
|
||||||
|
toastStore.success('Opening link')
|
||||||
|
} else {
|
||||||
|
// Multiple links, emit event for selection dialog
|
||||||
|
emit('open-links', urls, props.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
// Handle Shift+Enter for opening URLs
|
||||||
|
if (event.shiftKey && event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
handleOpenUrl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.key === ' ' || event.code === 'Space') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleChecked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'c') {
|
if (event.key === 'c') {
|
||||||
// Copy message content (only when no modifiers are pressed)
|
// Copy message content (only when no modifiers are pressed)
|
||||||
navigator.clipboard.writeText(props.message.content)
|
navigator.clipboard.writeText(props.message.content)
|
||||||
playSound('copy')
|
playSound('copy')
|
||||||
toastStore.success('Message copied to clipboard')
|
toastStore.success('Message copied to clipboard')
|
||||||
|
} else if (event.key === 'e') {
|
||||||
|
// Edit message - open the message dialog in edit mode
|
||||||
|
if (!props.isUnsent) {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('open-dialog-edit', props.message)
|
||||||
|
}
|
||||||
} else if (event.key === 'r') {
|
} else if (event.key === 'r') {
|
||||||
// Read message aloud (only when no modifiers are pressed)
|
// Read message aloud (only when no modifiers are pressed)
|
||||||
if (appStore.settings.ttsEnabled) {
|
if (appStore.settings.ttsEnabled) {
|
||||||
@@ -260,20 +336,52 @@ const handleDelete = async () => {
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
// focus the closest message
|
|
||||||
await nextTick()
|
|
||||||
if (targetToFocus && document.contains(targetToFocus)) {
|
|
||||||
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
|
|
||||||
targetToFocus.focus()
|
|
||||||
} else {
|
|
||||||
focusFallbackToInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete message:', error)
|
console.error('Failed to delete message:', error)
|
||||||
toastStore.error('Failed to delete message')
|
toastStore.error('Failed to delete message')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const handleFocus = () => {
|
||||||
|
// Keep parent selection index in sync
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAriaLabel = computed(() => {
|
||||||
|
if (isChecked.value === true) return 'Mark as unchecked'
|
||||||
|
if (isChecked.value === false) return 'Remove check'
|
||||||
|
return 'Mark as checked'
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleChecked = async () => {
|
||||||
|
if (props.isUnsent) return
|
||||||
|
const msg = props.message as ExtendedMessage
|
||||||
|
// Cycle: null → true → false → null
|
||||||
|
let next: boolean | null
|
||||||
|
if (isChecked.value === null) {
|
||||||
|
next = true
|
||||||
|
} else if (isChecked.value === true) {
|
||||||
|
next = false
|
||||||
|
} else {
|
||||||
|
next = null
|
||||||
|
}
|
||||||
|
const prev = isChecked.value
|
||||||
|
try {
|
||||||
|
// optimistic
|
||||||
|
appStore.setMessageChecked(msg.id, next)
|
||||||
|
await apiService.setMessageChecked(msg.channel_id, msg.id, next)
|
||||||
|
} catch (e) {
|
||||||
|
// rollback
|
||||||
|
appStore.setMessageChecked(msg.id, prev as any)
|
||||||
|
console.error('Failed to set checked state', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose methods for external use (e.g., mobile button)
|
||||||
|
defineExpose({
|
||||||
|
handleOpenUrl,
|
||||||
|
extractUrls
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -336,6 +444,31 @@ const handleDelete = async () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message__check {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__check--unchecked {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__toggle {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* Hide the per-message toggle on desktop; show only on mobile */
|
||||||
|
.message__toggle { display: none; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.message__toggle { display: inline-flex; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.message {
|
.message {
|
||||||
background: #2d3748;
|
background: #2d3748;
|
||||||
@@ -358,3 +491,4 @@ const handleDelete = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" tabindex="0" role="listbox"
|
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" @focusin="handleFocusIn" tabindex="-1" role="listbox"
|
||||||
:aria-label="messagesAriaLabel">
|
:aria-label="messagesAriaLabel">
|
||||||
<div class="messages" role="presentation">
|
<div class="messages" role="presentation">
|
||||||
<!-- Regular Messages -->
|
<!-- Regular Messages -->
|
||||||
@@ -7,13 +7,18 @@
|
|||||||
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
|
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
|
||||||
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
||||||
@focus="focusedMessageIndex = index"
|
@focus="focusedMessageIndex = index"
|
||||||
@open-dialog="emit('open-message-dialog', $event)" />
|
@open-dialog="emit('open-message-dialog', $event)"
|
||||||
|
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||||
|
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||||
|
|
||||||
<!-- Unsent Messages -->
|
<!-- Unsent Messages -->
|
||||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||||
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||||
|
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
||||||
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
||||||
@open-dialog="emit('open-message-dialog', $event)" />
|
@open-dialog="emit('open-message-dialog', $event)"
|
||||||
|
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||||
|
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,6 +36,8 @@ interface Props {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||||
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -62,27 +69,30 @@ const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 mess
|
|||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (totalMessages.value === 0) return
|
if (totalMessages.value === 0) return
|
||||||
|
|
||||||
let newIndex = focusedMessageIndex.value
|
// Derive current index from actual focused DOM if possible
|
||||||
|
const activeIdx = getActiveMessageIndex()
|
||||||
|
let currentIndex = activeIdx != null ? activeIdx : focusedMessageIndex.value
|
||||||
|
let newIndex = currentIndex
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
newIndex = Math.max(0, currentIndex - 1)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
newIndex = Math.min(totalMessages.value - 1, currentIndex + 1)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
newIndex = Math.max(0, currentIndex - 10)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
|
newIndex = Math.min(totalMessages.value - 1, currentIndex + 10)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'Home':
|
case 'Home':
|
||||||
@@ -110,6 +120,19 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
const el = target.closest('[data-message-index]') as HTMLElement | null
|
||||||
|
if (!el) return
|
||||||
|
const idxAttr = el.getAttribute('data-message-index')
|
||||||
|
if (idxAttr == null) return
|
||||||
|
const idx = parseInt(idxAttr, 10)
|
||||||
|
if (!Number.isNaN(idx) && idx !== focusedMessageIndex.value) {
|
||||||
|
focusedMessageIndex.value = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const focusMessage = (index: number) => {
|
const focusMessage = (index: number) => {
|
||||||
focusedMessageIndex.value = index
|
focusedMessageIndex.value = index
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -136,6 +159,29 @@ const focusMessageById = (messageId: string | number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNearBottom = (threshold = 48) => {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return true
|
||||||
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||||
|
return distance <= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInputActive = () => {
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active) return false
|
||||||
|
// Keep focus on the message composer when typing/sending
|
||||||
|
return !!active.closest('.message-input') && active.classList.contains('base-textarea__field')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActiveMessageIndex = (): number | null => {
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active) return null
|
||||||
|
const el = active.closest('[data-message-index]') as HTMLElement | null
|
||||||
|
if (!el) return null
|
||||||
|
const idx = el.getAttribute('data-message-index')
|
||||||
|
return idx != null ? parseInt(idx, 10) : null
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
@@ -144,19 +190,46 @@ const scrollToBottom = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for new messages and auto-scroll
|
// Watch for list length changes
|
||||||
watch(() => [props.messages.length, props.unsentMessages.length], () => {
|
// - If items were added, move focus to the newest and scroll to bottom.
|
||||||
// When new messages arrive, focus the last message and scroll to bottom
|
// - If items were removed, keep current index when possible; otherwise clamp.
|
||||||
if (totalMessages.value > 0) {
|
watch(
|
||||||
focusedMessageIndex.value = totalMessages.value - 1
|
() => [props.messages.length, props.unsentMessages.length],
|
||||||
}
|
([newM, newU], [oldM = 0, oldU = 0]) => {
|
||||||
|
const oldTotal = (oldM ?? 0) + (oldU ?? 0)
|
||||||
|
const newTotal = (newM ?? 0) + (newU ?? 0)
|
||||||
|
|
||||||
|
if (newTotal > oldTotal) {
|
||||||
|
// New message(s) appended: only jump if user is near bottom and not typing
|
||||||
|
const shouldStickToBottom = isNearBottom() || focusedMessageIndex.value === oldTotal - 1
|
||||||
|
if (shouldStickToBottom && newTotal > 0) {
|
||||||
|
if (isInputActive()) {
|
||||||
|
// Preserve input focus; optionally keep scroll at bottom
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
} else {
|
||||||
|
focusMessage(newTotal - 1)
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For deletions, defer to the totalMessages watcher below to clamp and focus
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Reset focus when messages change significantly
|
// Reset focus when messages change significantly
|
||||||
watch(() => totalMessages.value, (newTotal) => {
|
watch(() => totalMessages.value, (newTotal, oldTotal) => {
|
||||||
if (focusedMessageIndex.value >= newTotal) {
|
if (newTotal === 0) return
|
||||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
if (isInputActive()) return
|
||||||
|
const current = focusedMessageIndex.value
|
||||||
|
let nextIndex = current
|
||||||
|
if (current >= newTotal) {
|
||||||
|
// If we deleted the last item, move to the new last
|
||||||
|
nextIndex = Math.max(0, newTotal - 1)
|
||||||
|
}
|
||||||
|
// Avoid double focusing if the correct item is already focused
|
||||||
|
const activeIdx = getActiveMessageIndex()
|
||||||
|
if (activeIdx !== nextIndex) {
|
||||||
|
focusMessage(nextIndex)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,22 +237,54 @@ onMounted(() => {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
// Focus the last message on mount
|
// Focus the last message on mount
|
||||||
if (totalMessages.value > 0) {
|
if (totalMessages.value > 0) {
|
||||||
focusedMessageIndex.value = totalMessages.value - 1
|
focusMessage(totalMessages.value - 1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
|
const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
|
||||||
const messages = allMessages.value
|
const messages = allMessages.value
|
||||||
if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) {
|
if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) {
|
||||||
return messages[focusedMessageIndex.value]
|
return messages[focusedMessageIndex.value] ?? null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract URLs from text content
|
||||||
|
const extractUrls = (text: string): string[] => {
|
||||||
|
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
|
||||||
|
const matches = text.match(urlRegex) || []
|
||||||
|
return [...new Set(matches)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle open URL for the focused message (for mobile button)
|
||||||
|
const handleOpenUrlFocused = (): { action: 'none' | 'single' | 'multiple', urls: string[], message: ExtendedMessage | UnsentMessage | null } => {
|
||||||
|
const message = getFocusedMessage()
|
||||||
|
if (!message) {
|
||||||
|
return { action: 'none', urls: [], message: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow URL opening for unsent messages
|
||||||
|
if ('channelId' in message) {
|
||||||
|
return { action: 'none', urls: [], message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = extractUrls(message.content)
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return { action: 'none', urls: [], message }
|
||||||
|
} else if (urls.length === 1) {
|
||||||
|
return { action: 'single', urls, message }
|
||||||
|
} else {
|
||||||
|
return { action: 'multiple', urls, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
focusMessageById,
|
focusMessageById,
|
||||||
getFocusedMessage
|
getFocusedMessage,
|
||||||
|
handleOpenUrlFocused,
|
||||||
|
extractUrls
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -207,8 +207,8 @@ const switchCamera = async () => {
|
|||||||
|
|
||||||
// Determine if this is likely a front camera
|
// Determine if this is likely a front camera
|
||||||
const currentCamera = availableCameras.value[currentCameraIndex.value]
|
const currentCamera = availableCameras.value[currentCameraIndex.value]
|
||||||
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') ||
|
isFrontCamera.value = currentCamera?.label.toLowerCase().includes('front') ||
|
||||||
currentCamera.label.toLowerCase().includes('user')
|
currentCamera?.label.toLowerCase().includes('user') || false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await startCamera()
|
await startCamera()
|
||||||
|
|||||||
@@ -281,7 +281,10 @@ const performDelete = async () => {
|
|||||||
|
|
||||||
// Switch to first available channel if we were on the deleted channel
|
// Switch to first available channel if we were on the deleted channel
|
||||||
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
|
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
|
||||||
await appStore.setCurrentChannel(appStore.channels[0].id)
|
const firstChannel = appStore.channels[0]
|
||||||
|
if (firstChannel) {
|
||||||
|
await appStore.setCurrentChannel(firstChannel.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// For delete, we can't do offline fallback easily since it affects server state
|
// For delete, we can't do offline fallback easily since it affects server state
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const uploadFiles = async () => {
|
|||||||
// For single file, use the filename as message content
|
// For single file, use the filename as message content
|
||||||
// For multiple files, show count
|
// For multiple files, show count
|
||||||
const messageContent = selectedFiles.value.length === 1
|
const messageContent = selectedFiles.value.length === 1
|
||||||
? selectedFiles.value[0].name
|
? selectedFiles.value[0]?.name || 'Uploaded file'
|
||||||
: `Uploaded ${selectedFiles.value.length} files`
|
: `Uploaded ${selectedFiles.value.length} files`
|
||||||
|
|
||||||
// Create a message first to attach files to
|
// Create a message first to attach files to
|
||||||
@@ -151,6 +151,10 @@ const uploadFiles = async () => {
|
|||||||
// Upload the first file (backend uses single file per message)
|
// Upload the first file (backend uses single file per message)
|
||||||
const file = selectedFiles.value[0]
|
const file = selectedFiles.value[0]
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No file selected')
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file)
|
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file)
|
||||||
uploadProgress.value[0] = 100
|
uploadProgress.value[0] = 100
|
||||||
|
|||||||
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="link-selection-dialog">
|
||||||
|
<p class="link-selection-dialog__description">
|
||||||
|
Select a link to open:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="link-selection-dialog__links">
|
||||||
|
<button
|
||||||
|
v-for="(link, index) in links"
|
||||||
|
:key="index"
|
||||||
|
class="link-selection-dialog__link"
|
||||||
|
@click="openLink(link)"
|
||||||
|
:title="link"
|
||||||
|
>
|
||||||
|
<span class="link-selection-dialog__link-text">{{ formatLink(link) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-selection-dialog__actions">
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
const formatLink = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// Show domain + pathname, truncate if too long
|
||||||
|
let display = parsed.hostname + parsed.pathname
|
||||||
|
if (display.length > 50) {
|
||||||
|
display = display.slice(0, 47) + '...'
|
||||||
|
}
|
||||||
|
return display
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, truncate the raw URL
|
||||||
|
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLink = (url: string) => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
toastStore.success('Opening link')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-selection-dialog {
|
||||||
|
padding: 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__description {
|
||||||
|
color: #374151;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link:hover,
|
||||||
|
.link-selection-dialog__link:focus {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.link-selection-dialog__description {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link {
|
||||||
|
background: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link:hover,
|
||||||
|
.link-selection-dialog__link:focus {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__actions {
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -202,6 +202,7 @@ import type { ExtendedMessage } from '@/types'
|
|||||||
interface Props {
|
interface Props {
|
||||||
message: ExtendedMessage
|
message: ExtendedMessage
|
||||||
open: boolean
|
open: boolean
|
||||||
|
startEditing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -404,6 +405,11 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
|
// Auto-start editing if requested
|
||||||
|
if (props.startEditing) {
|
||||||
|
startEditing()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
@@ -146,6 +146,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Data Backup</h3>
|
||||||
|
|
||||||
|
<p class="setting-description">
|
||||||
|
Download a complete backup of all channels, messages, and data. Restore will replace all existing data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleBackup"
|
||||||
|
:loading="isBackingUp"
|
||||||
|
>
|
||||||
|
Download Backup
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="triggerRestoreInput"
|
||||||
|
:disabled="isRestoring"
|
||||||
|
>
|
||||||
|
Restore from Backup
|
||||||
|
</BaseButton>
|
||||||
|
<input
|
||||||
|
ref="restoreInput"
|
||||||
|
type="file"
|
||||||
|
accept=".db,.sqlite,.sqlite3"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleRestoreFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Export Data</h3>
|
||||||
|
|
||||||
|
<p class="setting-description">
|
||||||
|
Export all channels and messages in various formats.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label for="export-format">Format</label>
|
||||||
|
<select id="export-format" v-model="exportFormat" class="select">
|
||||||
|
<option value="markdown">Markdown (zipped)</option>
|
||||||
|
<option value="html-single">HTML (single file)</option>
|
||||||
|
<option value="html-individual">HTML (individual files, zipped)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleExport"
|
||||||
|
:loading="isExporting"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>Account</h3>
|
<h3>Account</h3>
|
||||||
|
|
||||||
@@ -218,6 +281,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Confirmation Dialog -->
|
||||||
|
<div v-if="showRestoreConfirm" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Restore from Backup</h3>
|
||||||
|
<p>This will replace all existing data with the backup. All current channels, messages, and data will be overwritten. This cannot be undone.</p>
|
||||||
|
<p v-if="pendingRestoreFile" class="file-info">
|
||||||
|
File: {{ pendingRestoreFile.name }}
|
||||||
|
</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="cancelRestore"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="handleRestore"
|
||||||
|
:loading="isRestoring"
|
||||||
|
>
|
||||||
|
Restore Backup
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -228,6 +319,8 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { apiService } from '@/services/api'
|
||||||
|
import { getExporter, downloadBlob, type ExportFormat } from '@/utils/export'
|
||||||
import { clear } from 'idb-keyval'
|
import { clear } from 'idb-keyval'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import type { AppSettings } from '@/types'
|
import type { AppSettings } from '@/types'
|
||||||
@@ -244,9 +337,16 @@ const { availableVoices, speak, setVoice } = useAudio()
|
|||||||
|
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isResetting = ref(false)
|
const isResetting = ref(false)
|
||||||
|
const isBackingUp = ref(false)
|
||||||
|
const isRestoring = ref(false)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
const exportFormat = ref<ExportFormat>('markdown')
|
||||||
const showResetConfirm = ref(false)
|
const showResetConfirm = ref(false)
|
||||||
|
const showRestoreConfirm = ref(false)
|
||||||
|
const pendingRestoreFile = ref<File | null>(null)
|
||||||
const selectedVoiceURI = ref('')
|
const selectedVoiceURI = ref('')
|
||||||
const soundInput = ref()
|
const soundInput = ref()
|
||||||
|
const restoreInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
// Computed property for current server URL
|
// Computed property for current server URL
|
||||||
const currentServerUrl = computed(() => authStore.serverUrl)
|
const currentServerUrl = computed(() => authStore.serverUrl)
|
||||||
@@ -334,6 +434,85 @@ const handleResetData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
isBackingUp.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.downloadBackup()
|
||||||
|
toastStore.success('Backup downloaded successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup failed:', error)
|
||||||
|
toastStore.error('Failed to download backup')
|
||||||
|
} finally {
|
||||||
|
isBackingUp.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerRestoreInput = () => {
|
||||||
|
restoreInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreFileSelect = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
pendingRestoreFile.value = file
|
||||||
|
showRestoreConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so the same file can be selected again
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!pendingRestoreFile.value) return
|
||||||
|
|
||||||
|
isRestoring.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiService.restoreBackup(pendingRestoreFile.value)
|
||||||
|
toastStore.success(`Restored ${result.stats.channels} channels, ${result.stats.messages} messages`)
|
||||||
|
|
||||||
|
// Clear local cache and reload data
|
||||||
|
await clear()
|
||||||
|
appStore.$reset()
|
||||||
|
|
||||||
|
showRestoreConfirm.value = false
|
||||||
|
pendingRestoreFile.value = null
|
||||||
|
emit('close')
|
||||||
|
|
||||||
|
// Reload the page to refresh all data
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restore failed:', error)
|
||||||
|
toastStore.error((error as Error).message || 'Failed to restore backup')
|
||||||
|
} finally {
|
||||||
|
isRestoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRestore = () => {
|
||||||
|
showRestoreConfirm.value = false
|
||||||
|
pendingRestoreFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
isExporting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exporter = getExporter(exportFormat.value)
|
||||||
|
const blob = await exporter.export(appStore.channels, appStore.messages)
|
||||||
|
downloadBlob(blob, exporter.filename)
|
||||||
|
toastStore.success('Export completed')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error)
|
||||||
|
toastStore.error('Export failed')
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Copy current settings to local state
|
// Copy current settings to local state
|
||||||
Object.assign(localSettings, appStore.settings)
|
Object.assign(localSettings, appStore.settings)
|
||||||
@@ -382,6 +561,22 @@ onMounted(() => {
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
@@ -543,5 +738,14 @@ onMounted(() => {
|
|||||||
.confirm-dialog p {
|
.confirm-dialog p {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
background: #374151;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -230,7 +230,7 @@ let animationInterval: number | null = null
|
|||||||
|
|
||||||
const startWaveAnimation = () => {
|
const startWaveAnimation = () => {
|
||||||
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
|
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
|
||||||
animationInterval = setInterval(() => {
|
animationInterval = window.setInterval(() => {
|
||||||
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
|
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ const props = defineProps<Props>()
|
|||||||
const containerRef = ref<HTMLElement>()
|
const containerRef = ref<HTMLElement>()
|
||||||
const focusedChannelIndex = ref(0)
|
const focusedChannelIndex = ref(0)
|
||||||
|
|
||||||
|
// For alphanumeric navigation
|
||||||
|
const lastSearchChar = ref('')
|
||||||
|
const lastSearchTime = ref(0)
|
||||||
|
const searchResetDelay = 1000 // Reset after 1 second
|
||||||
|
|
||||||
// Handle individual channel events
|
// Handle individual channel events
|
||||||
const handleChannelSelect = (channelId: number) => {
|
const handleChannelSelect = (channelId: number) => {
|
||||||
emit('select-channel', channelId)
|
emit('select-channel', channelId)
|
||||||
@@ -103,6 +108,13 @@ const handleChannelKeydown = (event: KeyboardEvent, channelIndex: number) => {
|
|||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// Handle alphanumeric navigation (a-z, 0-9)
|
||||||
|
const char = event.key.toLowerCase()
|
||||||
|
if (/^[a-z0-9]$/.test(char)) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleAlphanumericNavigation(char, channelIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +134,47 @@ const focusChannel = (index: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAlphanumericNavigation = (char: string, currentIndex: number) => {
|
||||||
|
if (props.channels.length === 0) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const sameChar = lastSearchChar.value === char && (now - lastSearchTime.value) < searchResetDelay
|
||||||
|
|
||||||
|
lastSearchChar.value = char
|
||||||
|
lastSearchTime.value = now
|
||||||
|
|
||||||
|
// Find channels starting with the character
|
||||||
|
const matchingIndices: number[] = []
|
||||||
|
props.channels.forEach((channel, index) => {
|
||||||
|
if (channel.name.toLowerCase().startsWith(char)) {
|
||||||
|
matchingIndices.push(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matchingIndices.length === 0) return
|
||||||
|
|
||||||
|
// If pressing the same character repeatedly, cycle through matches
|
||||||
|
if (sameChar) {
|
||||||
|
// Find the next match after current index
|
||||||
|
const nextMatch = matchingIndices.find(index => index > currentIndex)
|
||||||
|
if (nextMatch !== undefined) {
|
||||||
|
focusChannel(nextMatch)
|
||||||
|
} else {
|
||||||
|
// Wrap around to the first match
|
||||||
|
const firstMatch = matchingIndices[0]
|
||||||
|
if (firstMatch !== undefined) {
|
||||||
|
focusChannel(firstMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New character: jump to first match
|
||||||
|
const firstMatch = matchingIndices[0]
|
||||||
|
if (firstMatch !== undefined) {
|
||||||
|
focusChannel(firstMatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Watch for channels changes and adjust focus
|
// Watch for channels changes and adjust focus
|
||||||
watch(() => props.channels.length, (newLength) => {
|
watch(() => props.channels.length, (newLength) => {
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export function useAudio() {
|
|||||||
console.warn('AudioContext resume failed, user interaction required:', error)
|
console.warn('AudioContext resume failed, user interaction required:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play a silent buffer to truly unlock AudioContext on iOS PWA
|
||||||
|
// On iOS, resume() alone is insufficient — audio must be routed through the context during a user gesture
|
||||||
|
if (globalAudioContext.state === 'running') {
|
||||||
|
try {
|
||||||
|
const silentBuffer = globalAudioContext.createBuffer(1, 1, 22050)
|
||||||
|
const source = globalAudioContext.createBufferSource()
|
||||||
|
source.buffer = silentBuffer
|
||||||
|
source.connect(globalAudioContext.destination)
|
||||||
|
source.start(0)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Silent buffer unlock failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a single sound file
|
// Load a single sound file
|
||||||
@@ -94,7 +108,6 @@ export function useAudio() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Starting to load all sounds...')
|
console.log('Starting to load all sounds...')
|
||||||
soundsLoaded = true
|
|
||||||
|
|
||||||
// Load basic sounds
|
// Load basic sounds
|
||||||
const basicSounds = {
|
const basicSounds = {
|
||||||
@@ -135,9 +148,11 @@ export function useAudio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
soundsLoaded = true
|
||||||
console.log('All sounds loaded and ready to play')
|
console.log('All sounds loaded and ready to play')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading sounds:', error)
|
console.error('Error loading sounds:', error)
|
||||||
|
// Don't set soundsLoaded so a retry can happen on next play attempt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +166,12 @@ export function useAudio() {
|
|||||||
console.error('AudioContext not initialized')
|
console.error('AudioContext not initialized')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If AudioContext exists but sounds never loaded successfully, retry
|
||||||
|
if (!soundsLoaded) {
|
||||||
|
await loadAllSounds()
|
||||||
|
}
|
||||||
|
|
||||||
const source = globalAudioContext.createBufferSource()
|
const source = globalAudioContext.createBufferSource()
|
||||||
source.buffer = buffer
|
source.buffer = buffer
|
||||||
source.connect(globalAudioContext.destination)
|
source.connect(globalAudioContext.destination)
|
||||||
@@ -174,14 +195,20 @@ export function useAudio() {
|
|||||||
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
|
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
|
||||||
if (globalWaterSounds.length > 0) {
|
if (globalWaterSounds.length > 0) {
|
||||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
const buffer = globalWaterSounds[randomIndex]
|
||||||
|
if (buffer) {
|
||||||
|
await playSoundBuffer(buffer)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Water sounds not loaded - trying to load them now')
|
console.warn('Water sounds not loaded - trying to load them now')
|
||||||
if (globalAudioContext) {
|
if (globalAudioContext) {
|
||||||
await loadAllSounds()
|
await loadAllSounds()
|
||||||
if (globalWaterSounds.length > 0) {
|
if (globalWaterSounds.length > 0) {
|
||||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
const buffer = globalWaterSounds[randomIndex]
|
||||||
|
if (buffer) {
|
||||||
|
await playSoundBuffer(buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +217,10 @@ export function useAudio() {
|
|||||||
const playSent = async () => {
|
const playSent = async () => {
|
||||||
if (globalSentSounds.length > 0) {
|
if (globalSentSounds.length > 0) {
|
||||||
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
|
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
|
||||||
await playSoundBuffer(globalSentSounds[randomIndex])
|
const buffer = globalSentSounds[randomIndex]
|
||||||
|
if (buffer) {
|
||||||
|
await playSoundBuffer(buffer)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Sent sounds not loaded')
|
console.warn('Sent sounds not loaded')
|
||||||
}
|
}
|
||||||
@@ -239,7 +269,7 @@ export function useAudio() {
|
|||||||
recordingStartTime = Date.now()
|
recordingStartTime = Date.now()
|
||||||
|
|
||||||
// Update duration every 100ms
|
// Update duration every 100ms
|
||||||
recordingInterval = setInterval(() => {
|
recordingInterval = window.setInterval(() => {
|
||||||
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
@@ -303,7 +333,7 @@ export function useAudio() {
|
|||||||
// Select default voice (prefer English voices)
|
// Select default voice (prefer English voices)
|
||||||
if (!selectedVoice.value && voices.length > 0) {
|
if (!selectedVoice.value && voices.length > 0) {
|
||||||
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
|
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
|
||||||
selectedVoice.value = englishVoice || voices[0]
|
selectedVoice.value = englishVoice || voices[0] || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,17 +432,22 @@ export function useAudio() {
|
|||||||
audioSystemInitialized = true
|
audioSystemInitialized = true
|
||||||
|
|
||||||
// Set up user gesture listeners to initialize audio and load sounds
|
// Set up user gesture listeners to initialize audio and load sounds
|
||||||
|
// Include touchstart for iOS PWA standalone mode where it fires before click
|
||||||
|
const gestureEvents = ['click', 'keydown', 'touchstart'] as const
|
||||||
const initializeAudio = async () => {
|
const initializeAudio = async () => {
|
||||||
|
// Remove all gesture listeners immediately so this only fires once
|
||||||
|
for (const event of gestureEvents) {
|
||||||
|
document.removeEventListener(event, initializeAudio)
|
||||||
|
}
|
||||||
console.log('User interaction detected, initializing audio system...')
|
console.log('User interaction detected, initializing audio system...')
|
||||||
await initAudioOnUserGesture()
|
await initAudioOnUserGesture()
|
||||||
await loadAllSounds() // Load sounds after user interaction
|
await loadAllSounds() // Load sounds after user interaction
|
||||||
console.log('Audio system initialized')
|
console.log('Audio system initialized')
|
||||||
document.removeEventListener('click', initializeAudio)
|
|
||||||
document.removeEventListener('keydown', initializeAudio)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', initializeAudio, { once: true })
|
for (const event of gestureEvents) {
|
||||||
document.addEventListener('keydown', initializeAudio, { once: true })
|
document.addEventListener(event, initializeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize voices for speech synthesis
|
// Initialize voices for speech synthesis
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export function useKeyboardShortcuts() {
|
|||||||
// Allow certain shortcuts to work globally, even in input fields
|
// Allow certain shortcuts to work globally, even in input fields
|
||||||
const isGlobalShortcut = (shortcut.ctrlKey && shortcut.shiftKey) ||
|
const isGlobalShortcut = (shortcut.ctrlKey && shortcut.shiftKey) ||
|
||||||
shortcut.altKey ||
|
shortcut.altKey ||
|
||||||
shortcut.key === 'escape'
|
shortcut.key === 'escape' ||
|
||||||
|
(shortcut.ctrlKey && shortcut.key === 'k')
|
||||||
|
|
||||||
// Skip shortcuts that shouldn't work in input fields
|
// Skip shortcuts that shouldn't work in input fields
|
||||||
if (isInInputField && !isGlobalShortcut) {
|
if (isInInputField && !isGlobalShortcut) {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function useOfflineSync() {
|
|||||||
const startAutoSave = () => {
|
const startAutoSave = () => {
|
||||||
if (syncInterval) clearInterval(syncInterval)
|
if (syncInterval) clearInterval(syncInterval)
|
||||||
|
|
||||||
syncInterval = setInterval(async () => {
|
syncInterval = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await appStore.saveState()
|
await appStore.saveState()
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,17 @@ export function useWebSocket() {
|
|||||||
const channels = [...appStore.channels]
|
const channels = [...appStore.channels]
|
||||||
const channelIndex = channels.findIndex(c => c.id === channelId)
|
const channelIndex = channels.findIndex(c => c.id === channelId)
|
||||||
if (channelIndex !== -1) {
|
if (channelIndex !== -1) {
|
||||||
channels[channelIndex] = { ...channels[channelIndex], name: data.name }
|
const existingChannel = channels[channelIndex]
|
||||||
|
if (existingChannel) {
|
||||||
|
channels[channelIndex] = {
|
||||||
|
id: existingChannel.id,
|
||||||
|
name: data.name,
|
||||||
|
created_at: existingChannel.created_at
|
||||||
|
}
|
||||||
appStore.setChannels(channels)
|
appStore.setChannels(channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const setupEventHandlers = () => {
|
const setupEventHandlers = () => {
|
||||||
websocketService.on('message-created', handleMessageCreated)
|
websocketService.on('message-created', handleMessageCreated)
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ class ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setMessageChecked(channelId: number, messageId: number, checked: boolean | null): Promise<{ id: number, checked: boolean | null }> {
|
||||||
|
return this.request(`/channels/${channelId}/messages/${messageId}/checked`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ checked })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async moveMessage(channelId: number, messageId: number, targetChannelId: number): Promise<{ message: string, messageId: number, targetChannelId: number }> {
|
async moveMessage(channelId: number, messageId: number, targetChannelId: number): Promise<{ message: string, messageId: number, targetChannelId: number }> {
|
||||||
return this.request(`/channels/${channelId}/messages/${messageId}/move`, {
|
return this.request(`/channels/${channelId}/messages/${messageId}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -160,6 +167,55 @@ class ApiService {
|
|||||||
getFileUrl(filePath: string): string {
|
getFileUrl(filePath: string): string {
|
||||||
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup - returns a download URL
|
||||||
|
async downloadBackup(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/backup`, {
|
||||||
|
headers: { Authorization: this.token }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Backup failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header or use default
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
|
let filename = 'notebrook-backup.db'
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="?(.+?)"?(?:;|$)/)
|
||||||
|
if (match && match[1]) filename = match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore - upload a .db file
|
||||||
|
async restoreBackup(file: File): Promise<{ success: boolean; message: string; stats: { channels: number; messages: number; files: number } }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('database', file)
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/backup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: this.token },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
|
throw new Error(error.error || `Restore failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiService = new ApiService()
|
export const apiService = new ApiService()
|
||||||
@@ -35,6 +35,7 @@ export class SyncService {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
created_at: msg.createdAt || msg.created_at,
|
created_at: msg.createdAt || msg.created_at,
|
||||||
file_id: msg.fileId || msg.file_id,
|
file_id: msg.fileId || msg.file_id,
|
||||||
|
checked: typeof msg.checked === 'number' ? (msg.checked === 1) : (typeof msg.checked === 'boolean' ? msg.checked : null),
|
||||||
// Map the flattened file fields from backend
|
// Map the flattened file fields from backend
|
||||||
fileId: msg.fileId,
|
fileId: msg.fileId,
|
||||||
filePath: msg.filePath,
|
filePath: msg.filePath,
|
||||||
|
|||||||
@@ -73,11 +73,16 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelMessages = messages.value[message.channel_id]
|
const channelMessages = messages.value[message.channel_id]
|
||||||
|
if (!channelMessages) return
|
||||||
|
|
||||||
const existingIndex = channelMessages.findIndex(m => m.id === message.id)
|
const existingIndex = channelMessages.findIndex(m => m.id === message.id)
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Upsert: update existing to avoid duplicates from WebSocket vs sync
|
// Upsert: update existing to avoid duplicates from WebSocket vs sync
|
||||||
channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...message }
|
const existingMessage = channelMessages[existingIndex]
|
||||||
|
if (existingMessage) {
|
||||||
|
channelMessages[existingIndex] = { ...existingMessage, ...message }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
channelMessages.push(message)
|
channelMessages.push(message)
|
||||||
}
|
}
|
||||||
@@ -93,17 +98,28 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
|
const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
|
||||||
for (const channelId in messages.value) {
|
for (const channelId in messages.value) {
|
||||||
const channelMessages = messages.value[parseInt(channelId)]
|
const channelMessages = messages.value[parseInt(channelId)]
|
||||||
|
if (!channelMessages) continue
|
||||||
|
|
||||||
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
||||||
if (messageIndex !== -1) {
|
if (messageIndex !== -1) {
|
||||||
channelMessages[messageIndex] = { ...channelMessages[messageIndex], ...updates }
|
const existingMessage = channelMessages[messageIndex]
|
||||||
|
if (existingMessage) {
|
||||||
|
channelMessages[messageIndex] = { ...existingMessage, ...updates }
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setMessageChecked = (messageId: number, checked: boolean | null) => {
|
||||||
|
updateMessage(messageId, { checked })
|
||||||
|
}
|
||||||
|
|
||||||
const removeMessage = (messageId: number) => {
|
const removeMessage = (messageId: number) => {
|
||||||
for (const channelId in messages.value) {
|
for (const channelId in messages.value) {
|
||||||
const channelMessages = messages.value[parseInt(channelId)]
|
const channelMessages = messages.value[parseInt(channelId)]
|
||||||
|
if (!channelMessages) continue
|
||||||
|
|
||||||
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
||||||
if (messageIndex !== -1) {
|
if (messageIndex !== -1) {
|
||||||
channelMessages.splice(messageIndex, 1)
|
channelMessages.splice(messageIndex, 1)
|
||||||
@@ -123,6 +139,11 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = sourceMessages[messageIndex]
|
const message = sourceMessages[messageIndex]
|
||||||
|
if (!message) {
|
||||||
|
console.warn(`Message ${messageId} not found at index ${messageIndex}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sourceMessages.splice(messageIndex, 1)
|
sourceMessages.splice(messageIndex, 1)
|
||||||
|
|
||||||
// Update message's channel_id and add to target channel
|
// Update message's channel_id and add to target channel
|
||||||
@@ -133,6 +154,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetMessages = messages.value[targetChannelId]
|
const targetMessages = messages.value[targetChannelId]
|
||||||
|
if (!targetMessages) return
|
||||||
|
|
||||||
targetMessages.push(updatedMessage)
|
targetMessages.push(updatedMessage)
|
||||||
|
|
||||||
// Keep chronological order in target channel
|
// Keep chronological order in target channel
|
||||||
@@ -210,6 +233,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
setMessages,
|
setMessages,
|
||||||
addMessage,
|
addMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
|
setMessageChecked,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
moveMessage,
|
moveMessage,
|
||||||
addUnsentMessage,
|
addUnsentMessage,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Message {
|
|||||||
content: string
|
content: string
|
||||||
created_at: string
|
created_at: string
|
||||||
file_id?: number
|
file_id?: number
|
||||||
|
checked?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithFile extends Message {
|
export interface MessageWithFile extends Message {
|
||||||
|
|||||||
201
frontend-vue/src/utils/export.ts
Normal file
201
frontend-vue/src/utils/export.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
import type { Channel, ExtendedMessage } from '@/types'
|
||||||
|
|
||||||
|
export type ExportFormat = 'markdown' | 'html-single' | 'html-individual'
|
||||||
|
|
||||||
|
interface Exporter {
|
||||||
|
export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob>
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateString(): string {
|
||||||
|
return new Date().toISOString().split('T')[0] ?? 'export'
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'untitled'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Markdown Exporter ============
|
||||||
|
|
||||||
|
class MarkdownExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.zip`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'application/zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
const content = this.formatChannel(channel, channelMessages)
|
||||||
|
const filename = `${sanitizeFilename(channel.name)}.md`
|
||||||
|
zip.file(filename, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatChannel(channel: Channel, messages: ExtendedMessage[]): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(`# ${channel.name}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
lines.push(`## ${timestamp}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`### ${msg.content}`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HTML Single Exporter ============
|
||||||
|
|
||||||
|
class HtmlSingleExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.html`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const html = this.generateHtml(channels, messages)
|
||||||
|
return new Blob([html], { type: this.mimeType })
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateHtml(channels: Channel[], messages: Record<number, ExtendedMessage[]>): string {
|
||||||
|
const body: string[] = []
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
body.push(`<h2>${escapeHtml(channel.name)}</h2>`)
|
||||||
|
|
||||||
|
for (const msg of channelMessages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
body.push(`<h3>${escapeHtml(timestamp)}</h3>`)
|
||||||
|
body.push(`<h4>${escapeHtml(msg.content)}</h4>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notebrook Export</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
|
||||||
|
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||||
|
h2 { margin-top: 2rem; color: #2563eb; }
|
||||||
|
h3 { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
|
||||||
|
h4 { margin-top: 0; font-weight: normal; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Notebrook Export</h1>
|
||||||
|
${body.join('\n ')}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HTML Individual Exporter ============
|
||||||
|
|
||||||
|
class HtmlIndividualExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.zip`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'application/zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
const html = this.generateChannelHtml(channel, channelMessages)
|
||||||
|
const filename = `${sanitizeFilename(channel.name)}.html`
|
||||||
|
zip.file(filename, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateChannelHtml(channel: Channel, messages: ExtendedMessage[]): string {
|
||||||
|
const body: string[] = []
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
body.push(`<h2>${escapeHtml(timestamp)}</h2>`)
|
||||||
|
body.push(`<h3>${escapeHtml(msg.content)}</h3>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(channel.name)} - Notebrook Export</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
|
||||||
|
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||||
|
h2 { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
|
||||||
|
h3 { margin-top: 0; font-weight: normal; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escapeHtml(channel.name)}</h1>
|
||||||
|
${body.join('\n ')}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Factory ============
|
||||||
|
|
||||||
|
const exporters: Record<ExportFormat, Exporter> = {
|
||||||
|
'markdown': new MarkdownExporter(),
|
||||||
|
'html-single': new HtmlSingleExporter(),
|
||||||
|
'html-individual': new HtmlIndividualExporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExporter(format: ExportFormat): Exporter {
|
||||||
|
return exporters[format]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@
|
|||||||
:unsent-messages="appStore.unsentMessagesForChannel"
|
:unsent-messages="appStore.unsentMessagesForChannel"
|
||||||
ref="messagesContainer"
|
ref="messagesContainer"
|
||||||
@open-message-dialog="handleOpenMessageDialog"
|
@open-message-dialog="handleOpenMessageDialog"
|
||||||
|
@open-message-dialog-edit="handleOpenMessageDialogEdit"
|
||||||
|
@open-links="handleOpenLinks"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Message Input -->
|
<!-- Message Input -->
|
||||||
@@ -63,6 +65,8 @@
|
|||||||
@file-upload="showFileDialog = true"
|
@file-upload="showFileDialog = true"
|
||||||
@camera="showCameraDialog = true"
|
@camera="showCameraDialog = true"
|
||||||
@voice="showVoiceDialog = true"
|
@voice="showVoiceDialog = true"
|
||||||
|
@toggle-check="handleToggleCheckFocused"
|
||||||
|
@open-url="handleOpenUrlFocused"
|
||||||
ref="messageInput"
|
ref="messageInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,12 +129,20 @@
|
|||||||
v-if="selectedMessage"
|
v-if="selectedMessage"
|
||||||
:message="selectedMessage"
|
:message="selectedMessage"
|
||||||
:open="showMessageDialog"
|
:open="showMessageDialog"
|
||||||
|
:start-editing="shouldStartEditing"
|
||||||
@close="handleCloseMessageDialog"
|
@close="handleCloseMessageDialog"
|
||||||
@edit="handleEditMessage"
|
@edit="handleEditMessage"
|
||||||
@delete="handleDeleteMessage"
|
@delete="handleDeleteMessage"
|
||||||
@move="handleMoveMessage"
|
@move="handleMoveMessage"
|
||||||
/>
|
/>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
|
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
|
||||||
|
<LinkSelectionDialog
|
||||||
|
:links="selectedLinks"
|
||||||
|
@close="showLinkDialog = false"
|
||||||
|
/>
|
||||||
|
</BaseDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -163,6 +175,7 @@ import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
|
|||||||
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
||||||
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
||||||
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
||||||
|
import LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
||||||
@@ -195,7 +208,10 @@ const showFileDialog = ref(false)
|
|||||||
const showVoiceDialog = ref(false)
|
const showVoiceDialog = ref(false)
|
||||||
const showMessageDialog = ref(false)
|
const showMessageDialog = ref(false)
|
||||||
const showCameraDialog = ref(false)
|
const showCameraDialog = ref(false)
|
||||||
|
const showLinkDialog = ref(false)
|
||||||
const selectedMessage = ref<ExtendedMessage | null>(null)
|
const selectedMessage = ref<ExtendedMessage | null>(null)
|
||||||
|
const shouldStartEditing = ref(false)
|
||||||
|
const selectedLinks = ref<string[]>([])
|
||||||
|
|
||||||
// Mobile sidebar state
|
// Mobile sidebar state
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
@@ -226,11 +242,10 @@ const setupKeyboardShortcuts = () => {
|
|||||||
handler: () => { showSearchDialog.value = true }
|
handler: () => { showSearchDialog.value = true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ctrl+Shift+C - Channel selector focus
|
// Ctrl+K - Channel selector focus
|
||||||
addShortcut({
|
addShortcut({
|
||||||
key: 'c',
|
key: 'k',
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
shiftKey: true,
|
|
||||||
handler: () => {
|
handler: () => {
|
||||||
// Focus the first channel in the list
|
// Focus the first channel in the list
|
||||||
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
|
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
|
||||||
@@ -329,6 +344,51 @@ const setupKeyboardShortcuts = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleCheckFocused = async () => {
|
||||||
|
const focused = messagesContainer.value?.getFocusedMessage?.()
|
||||||
|
if (!focused || 'channelId' in focused) return
|
||||||
|
try {
|
||||||
|
const next = (focused as ExtendedMessage).checked !== true
|
||||||
|
appStore.setMessageChecked((focused as ExtendedMessage).id, next)
|
||||||
|
await apiService.setMessageChecked((focused as ExtendedMessage).channel_id, (focused as ExtendedMessage).id, next)
|
||||||
|
toastStore.info(next ? 'Marked as checked' : 'Marked as unchecked')
|
||||||
|
} catch (e) {
|
||||||
|
toastStore.error('Failed to toggle check')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle opening links from a message (when multiple links found)
|
||||||
|
const handleOpenLinks = (links: string[], message: ExtendedMessage | UnsentMessage) => {
|
||||||
|
selectedLinks.value = links
|
||||||
|
showLinkDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle open URL button press (mobile) - triggers URL opening for focused message
|
||||||
|
const handleOpenUrlFocused = () => {
|
||||||
|
const result = messagesContainer.value?.handleOpenUrlFocused?.()
|
||||||
|
if (!result || !result.message) {
|
||||||
|
toastStore.info('No message is focused')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.action === 'none') {
|
||||||
|
// No links found, fall back to edit mode
|
||||||
|
if ('created_at' in result.message) {
|
||||||
|
handleOpenMessageDialogEdit(result.message)
|
||||||
|
} else {
|
||||||
|
toastStore.info('No links found in this message')
|
||||||
|
}
|
||||||
|
} else if (result.action === 'single') {
|
||||||
|
// Single link, open directly
|
||||||
|
window.open(result.urls[0], '_blank', 'noopener,noreferrer')
|
||||||
|
toastStore.success('Opening link')
|
||||||
|
} else if (result.action === 'multiple') {
|
||||||
|
// Multiple links, show selection dialog
|
||||||
|
selectedLinks.value = result.urls
|
||||||
|
showLinkDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const selectChannel = async (channelId: number) => {
|
const selectChannel = async (channelId: number) => {
|
||||||
console.log('Selecting channel:', channelId)
|
console.log('Selecting channel:', channelId)
|
||||||
await appStore.setCurrentChannel(channelId)
|
await appStore.setCurrentChannel(channelId)
|
||||||
@@ -427,6 +487,11 @@ const announceLastMessage = (position: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messages[messageIndex]
|
const message = messages[messageIndex]
|
||||||
|
if (!message) {
|
||||||
|
toastStore.info('No message is available in this position')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const timeStr = formatTimestampForScreenReader(message.created_at)
|
const timeStr = formatTimestampForScreenReader(message.created_at)
|
||||||
const announcement = `${message.content}; sent ${timeStr}`
|
const announcement = `${message.content}; sent ${timeStr}`
|
||||||
|
|
||||||
@@ -447,6 +512,16 @@ const handleOpenMessageDialog = (message: ExtendedMessage | UnsentMessage) => {
|
|||||||
// Only allow dialog for sent messages (ExtendedMessage), not unsent ones
|
// Only allow dialog for sent messages (ExtendedMessage), not unsent ones
|
||||||
if ('created_at' in message) {
|
if ('created_at' in message) {
|
||||||
selectedMessage.value = message as ExtendedMessage
|
selectedMessage.value = message as ExtendedMessage
|
||||||
|
shouldStartEditing.value = false
|
||||||
|
showMessageDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenMessageDialogEdit = (message: ExtendedMessage | UnsentMessage) => {
|
||||||
|
// Only allow dialog for sent messages (ExtendedMessage), not unsent ones
|
||||||
|
if ('created_at' in message) {
|
||||||
|
selectedMessage.value = message as ExtendedMessage
|
||||||
|
shouldStartEditing.value = true
|
||||||
showMessageDialog.value = true
|
showMessageDialog.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,6 +529,7 @@ const handleOpenMessageDialog = (message: ExtendedMessage | UnsentMessage) => {
|
|||||||
const handleCloseMessageDialog = () => {
|
const handleCloseMessageDialog = () => {
|
||||||
showMessageDialog.value = false
|
showMessageDialog.value = false
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
|
shouldStartEditing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditMessage = async (messageId: number, content: string) => {
|
const handleEditMessage = async (messageId: number, content: string) => {
|
||||||
@@ -543,6 +619,15 @@ const isUnsentMessage = (messageId: string | number): boolean => {
|
|||||||
return typeof messageId === 'string' && messageId.startsWith('unsent_')
|
return typeof messageId === 'string' && messageId.startsWith('unsent_')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update document title when channel changes
|
||||||
|
watch(() => appStore.currentChannel, (channel) => {
|
||||||
|
if (channel) {
|
||||||
|
document.title = `${channel.name} - Notebrook`
|
||||||
|
} else {
|
||||||
|
document.title = 'Notebrook'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 1. Load saved state first (offline-first)
|
// 1. Load saved state first (offline-first)
|
||||||
@@ -572,7 +657,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 5. Auto-select first channel if none selected and we have channels
|
// 5. Auto-select first channel if none selected and we have channels
|
||||||
if (!appStore.currentChannelId && appStore.channels.length > 0) {
|
if (!appStore.currentChannelId && appStore.channels.length > 0) {
|
||||||
await selectChannel(appStore.channels[0].id)
|
const firstChannel = appStore.channels[0]
|
||||||
|
if (firstChannel) {
|
||||||
|
await selectChannel(firstChannel.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Auto-focus message input on page load
|
// 6. Auto-focus message input on page load
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173
|
port: 5173,
|
||||||
|
allowedHosts: true
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist'
|
outDir: 'dist'
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Notebrook</title>
|
|
||||||
|
|
||||||
<!-- Icons -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
|
|
||||||
<!-- Theme Color (For Mobile idk) -->
|
|
||||||
<meta name="theme-color" content="#ffffff" />
|
|
||||||
|
|
||||||
<!-- PWA Metadata -->
|
|
||||||
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
|
|
||||||
<meta name="application-name" content="Notebrook" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Notebrook" />
|
|
||||||
<meta name="msapplication-starturl" content="/" />
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
|
||||||
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Basic styles for the toasts */
|
|
||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
transition: opacity 0.3s, transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body role="application">
|
|
||||||
<div id="app"></div>
|
|
||||||
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', function () {
|
|
||||||
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
|
|
||||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
|
||||||
}, function (err) {
|
|
||||||
console.log('ServiceWorker registration failed: ', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Notebrook",
|
|
||||||
"short_name": "Notebrook",
|
|
||||||
"description": "Stream of conciousness accessible note taking",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/apple-touch-icon.png",
|
|
||||||
"sizes": "180x180",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/mstile-150x150.png",
|
|
||||||
"sizes": "150x150",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
4949
frontend/package-lock.json
generated
4949
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "notebrook-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.4.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"idb-keyval": "^6.2.1",
|
|
||||||
"vite-plugin-pwa": "^0.20.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,104 +0,0 @@
|
|||||||
import { IChannel } from "./model/channel";
|
|
||||||
import { IChannelList } from "./model/channel-list";
|
|
||||||
import { IMessage } from "./model/message";
|
|
||||||
import { IUnsentMessage } from "./model/unsent-message";
|
|
||||||
import { state } from "./state";
|
|
||||||
|
|
||||||
|
|
||||||
export const API = {
|
|
||||||
token: "",
|
|
||||||
path: "http://localhost:3000",
|
|
||||||
|
|
||||||
async request(method: string, path: string, body?: any) {
|
|
||||||
if (!API.token) {
|
|
||||||
throw new Error("API token was not set.");
|
|
||||||
}
|
|
||||||
return fetch(`${API.path}/${path}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": API.token
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async checkToken() {
|
|
||||||
const response = await API.request("GET", "check-token");
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error("Invalid token in request");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getChannels() {
|
|
||||||
const response = await API.request("GET", "channels");
|
|
||||||
const json = await response.json();
|
|
||||||
return json.channels as IChannel[];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getChannel(id: string) {
|
|
||||||
const response = await API.request("GET", `channels/${id}`);
|
|
||||||
const json = await response.json();
|
|
||||||
return json.channel as IChannel;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createChannel(name: string) {
|
|
||||||
const response = await API.request("POST", "channels", { name });
|
|
||||||
const json = await response.json();
|
|
||||||
return json as IChannel;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteChannel(id: string) {
|
|
||||||
await API.request("DELETE", `channels/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMessages(channelId: string) {
|
|
||||||
const response = await API.request("GET", `channels/${channelId}/messages`);
|
|
||||||
console.log(response)
|
|
||||||
const json = await response.json();
|
|
||||||
return json.messages as IMessage[];
|
|
||||||
},
|
|
||||||
|
|
||||||
async createMessage(channelId: string, content: string) {
|
|
||||||
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
|
|
||||||
const json = await response.json();
|
|
||||||
return json as IMessage;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteMessage(channelId: string, messageId: string) {
|
|
||||||
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Authorization": API.token
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
|
|
||||||
async mergeChannels(channelId: string, targetChannelId: string) {
|
|
||||||
await API.request("PUT", `channels/${channelId}/merge`, { targetChannelId });
|
|
||||||
},
|
|
||||||
|
|
||||||
async search(query: string, channelId?: string) {
|
|
||||||
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
|
|
||||||
const response = await API.request("GET", queryPath);
|
|
||||||
const json = await response.json();
|
|
||||||
return json.results as IMessage[];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getFiles(channelId: string, messageId: string) {
|
|
||||||
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
|
|
||||||
const json = await response.json();
|
|
||||||
return json.files as string[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export class ChunkProcessor<T> {
|
|
||||||
private chunkSize: number;
|
|
||||||
|
|
||||||
constructor(chunkSize: number = 1000) {
|
|
||||||
this.chunkSize = chunkSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
|
|
||||||
const totalChunks = Math.ceil(array.length / this.chunkSize);
|
|
||||||
|
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
|
||||||
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
|
|
||||||
await this.processChunk(chunk, callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
callback(chunk);
|
|
||||||
resolve();
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { IChannel } from "../model/channel";
|
|
||||||
import { showToast } from "../speech";
|
|
||||||
import { state } from "../state";
|
|
||||||
import { Button, TextInput } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
import { MergeDialog } from "./merge-dialog";
|
|
||||||
import { RemoveDialog } from "./remove-dialog";
|
|
||||||
|
|
||||||
export class ChannelDialog extends Dialog<IChannel | null> {
|
|
||||||
private channel: IChannel;
|
|
||||||
private nameField: TextInput;
|
|
||||||
private idField: 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.idField = new TextInput("Channel ID (for use with API)");
|
|
||||||
this.idField.setPosition(45, 10, 50, 10);
|
|
||||||
this.idField.setReadonly(true);
|
|
||||||
this.idField.setValue(channel.id.toString());
|
|
||||||
|
|
||||||
this.makeDefault = new Button("Make default");
|
|
||||||
this.makeDefault.setPosition(20, 70, 10, 10);
|
|
||||||
this.makeDefault.onClick(() => {
|
|
||||||
state.defaultChannelId = this.channel.id;
|
|
||||||
showToast(`${channel.name} is now the default channel.`);
|
|
||||||
});
|
|
||||||
this.mergeButton = new Button("Merge");
|
|
||||||
this.mergeButton.setPosition(40, 70, 10, 10);
|
|
||||||
this.mergeButton.onClick(() => {
|
|
||||||
this.mergeChannel();
|
|
||||||
});
|
|
||||||
if (state.channelList.channels.length === 1) {
|
|
||||||
this.mergeButton.setDisabled(true);
|
|
||||||
}
|
|
||||||
this.deleteButton = new Button("Delete");
|
|
||||||
this.deleteButton.setPosition(60, 70, 10, 10);
|
|
||||||
this.deleteButton.onClick(() => {
|
|
||||||
this.deleteChannel();
|
|
||||||
});
|
|
||||||
this.add(this.nameField);
|
|
||||||
this.add(this.idField);
|
|
||||||
this.add(this.makeDefault);
|
|
||||||
this.add(this.mergeButton);
|
|
||||||
this.add(this.deleteButton);
|
|
||||||
this.setOkAction(() => {
|
|
||||||
this.channel.name = this.nameField.getValue();
|
|
||||||
return this.channel;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mergeChannel() {
|
|
||||||
const res = await new MergeDialog().open();
|
|
||||||
if (res) {
|
|
||||||
this.choose(this.channel);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async deleteChannel() {
|
|
||||||
const res = await new RemoveDialog(this.channel.id.toString()).open();
|
|
||||||
if (res) {
|
|
||||||
this.choose(null);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { API } from "../api";
|
|
||||||
import { showToast } from "../speech";
|
|
||||||
import { TextInput } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
|
|
||||||
export class CreateChannelDialog extends Dialog<string> {
|
|
||||||
private nameField: TextInput;
|
|
||||||
|
|
||||||
public constructor() {
|
|
||||||
super("Create new channel");
|
|
||||||
this.nameField = new TextInput("Name of new channel");
|
|
||||||
this.add(this.nameField);
|
|
||||||
this.setOkAction(() => {
|
|
||||||
return this.nameField.getValue();
|
|
||||||
});
|
|
||||||
this.nameField.onKeyDown((key) => {
|
|
||||||
if (key === "Enter") {
|
|
||||||
this.choose(this.nameField.getValue());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Button } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
import { API } from "../api";
|
|
||||||
import { Dropdown } from "../ui/dropdown";
|
|
||||||
import { state } from "../state";
|
|
||||||
import { showToast } from "../speech";
|
|
||||||
|
|
||||||
export class MergeDialog extends Dialog<boolean> {
|
|
||||||
private channelList: Dropdown;
|
|
||||||
private mergeButton: Button;
|
|
||||||
protected cancelButton: Button;
|
|
||||||
|
|
||||||
public constructor() {
|
|
||||||
super("Merge channels", false);
|
|
||||||
this.channelList = new Dropdown("Target channel", []);
|
|
||||||
this.channelList.setPosition(10, 10, 80, 20);
|
|
||||||
this.mergeButton = new Button("Merge");
|
|
||||||
this.mergeButton.setPosition(30, 30, 40, 30);
|
|
||||||
this.mergeButton.onClick(() => this.merge());
|
|
||||||
this.cancelButton = new Button("Cancel");
|
|
||||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
|
||||||
this.cancelButton.onClick(() => this.cancel());
|
|
||||||
this.add(this.channelList);
|
|
||||||
this.add(this.mergeButton);
|
|
||||||
this.add(this.cancelButton);
|
|
||||||
this.setupChannelList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupChannelList() {
|
|
||||||
this.channelList.clearOptions();
|
|
||||||
state.channelList.getChannels().forEach((channel) => {
|
|
||||||
if (channel.id !== state.currentChannel!.id) this.channelList.addOption(channel.id.toString(), channel.name);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
private async merge() {
|
|
||||||
const currentChannel = state.currentChannel;
|
|
||||||
const target = this.channelList.getSelectedValue();
|
|
||||||
const targetChannel = state.getChannelById(parseInt(target));
|
|
||||||
console.log(currentChannel, targetChannel);
|
|
||||||
if (!targetChannel || !currentChannel) this.cancel();
|
|
||||||
try {
|
|
||||||
const res = await API.mergeChannels(currentChannel!.id.toString(), target);
|
|
||||||
currentChannel!.messages = [];
|
|
||||||
showToast("Channels were merged.");
|
|
||||||
this.choose(true);
|
|
||||||
} catch (e) {
|
|
||||||
showToast("Failed to merge channels: " + e);
|
|
||||||
this.choose(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { API } from "../api";
|
|
||||||
import { IMessage } from "../model/message";
|
|
||||||
import { Button, Container, TextInput} from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
import { Text } from "../ui";
|
|
||||||
import { MultilineInput } from "../ui/multiline-input";
|
|
||||||
import { state } from "../state";
|
|
||||||
export class MessageDialog extends Dialog<IMessage | null> {
|
|
||||||
private message: IMessage;
|
|
||||||
private messageText: MultilineInput;
|
|
||||||
private deleteButton: Button;
|
|
||||||
private fileInfoContainer?: Container;
|
|
||||||
|
|
||||||
public constructor(message: IMessage) {
|
|
||||||
super("Message");
|
|
||||||
this.message = message;
|
|
||||||
this.messageText = new MultilineInput("Message");
|
|
||||||
this.messageText.setValue(message.content);
|
|
||||||
this.messageText.setPosition(10, 10, 80, 20);
|
|
||||||
|
|
||||||
this.deleteButton = new Button("Delete");
|
|
||||||
this.deleteButton.setPosition(10, 90, 80, 10);
|
|
||||||
this.deleteButton.onClick(async () => {
|
|
||||||
await API.deleteMessage(state.currentChannel!.id.toString(), this.message.id.toString());
|
|
||||||
this.choose(null);
|
|
||||||
});
|
|
||||||
this.add(this.messageText);
|
|
||||||
this.add(this.deleteButton);
|
|
||||||
if (this.message.fileId !== null) {
|
|
||||||
this.fileInfoContainer = new Container("File info");
|
|
||||||
this.fileInfoContainer.setPosition(10, 50, 30, 80);
|
|
||||||
this.add(this.fileInfoContainer);
|
|
||||||
this.handleMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMessage() {
|
|
||||||
if (this.message?.fileType?.toLowerCase().includes("audio")) {
|
|
||||||
const audio = new Audio(`${API.path}/${this.message.filePath}`);
|
|
||||||
audio.autoplay = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// display info about files, or the image if it is an image. Also display all metadata.
|
|
||||||
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
|
|
||||||
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
|
|
||||||
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
|
|
||||||
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
|
|
||||||
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Button } from "../ui";
|
|
||||||
import { Audio } from "../ui/audio";
|
|
||||||
import { AudioRecorder } from "../ui/audio-recorder";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
|
|
||||||
export class RecordAudioDialog extends Dialog<Blob> {
|
|
||||||
private audioRecorder: AudioRecorder;
|
|
||||||
private recordButton: Button;
|
|
||||||
private stopButton: Button;
|
|
||||||
private playButton: Button;
|
|
||||||
private saveButton: Button;
|
|
||||||
private discardButton: Button;
|
|
||||||
private audioBlob: Blob | undefined;
|
|
||||||
private audioPlayer?: Audio;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super("Record audio", false);
|
|
||||||
this.audioRecorder = new AudioRecorder("Record from microphone");
|
|
||||||
this.audioRecorder.onRecordingComplete(() => {
|
|
||||||
this.audioBlob = this.audioRecorder.getRecording();
|
|
||||||
this.saveButton.setDisabled(false);
|
|
||||||
});
|
|
||||||
this.recordButton = new Button("Record");
|
|
||||||
this.recordButton.setPosition(30, 30, 40, 30);
|
|
||||||
this.recordButton.onClick(() => this.startRecording());
|
|
||||||
this.stopButton = new Button("Stop");
|
|
||||||
this.stopButton.setPosition(70, 40, 30, 30);
|
|
||||||
this.stopButton.onClick(() => this.stopRecording());
|
|
||||||
this.stopButton.setDisabled(true);
|
|
||||||
this.saveButton = new Button("Save");
|
|
||||||
this.saveButton.setPosition(10, 80, 50, 20);
|
|
||||||
this.saveButton.onClick(() => this.saveRecording());
|
|
||||||
this.saveButton.setDisabled(true);
|
|
||||||
this.playButton = new Button("Play");
|
|
||||||
this.playButton.setPosition(0, 40, 30, 30);
|
|
||||||
this.playButton.onClick(() => {
|
|
||||||
if (this.audioBlob) {
|
|
||||||
this.audioPlayer = new Audio("Recorded audio");
|
|
||||||
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
|
|
||||||
this.audioPlayer.play();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.playButton.setDisabled(true);
|
|
||||||
this.discardButton = new Button("Discard");
|
|
||||||
this.discardButton.setPosition(50, 90, 50, 10);
|
|
||||||
this.discardButton.onClick(() => this.cancel());
|
|
||||||
this.add(this.recordButton);
|
|
||||||
this.add(this.stopButton);
|
|
||||||
this.add(this.playButton);
|
|
||||||
this.add(this.saveButton);
|
|
||||||
this.add(this.discardButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startRecording() {
|
|
||||||
this.audioRecorder.startRecording();
|
|
||||||
this.stopButton.setDisabled(false);
|
|
||||||
this.recordButton.setDisabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopRecording() {
|
|
||||||
this.audioRecorder.stopRecording();
|
|
||||||
this.recordButton.setDisabled(false);
|
|
||||||
this.stopButton.setDisabled(true);
|
|
||||||
this.playButton.setDisabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveRecording() {
|
|
||||||
if (this.audioBlob) {
|
|
||||||
this.choose(this.audioBlob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Button } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
import { Text } from "../ui";
|
|
||||||
import { API } from "../api";
|
|
||||||
import { state } from "../state";
|
|
||||||
import { showToast } from "../speech";
|
|
||||||
|
|
||||||
export class RemoveDialog extends Dialog<boolean> {
|
|
||||||
private content: Text;
|
|
||||||
private confirmButton: Button;
|
|
||||||
protected cancelButton: Button;
|
|
||||||
|
|
||||||
public constructor(channelId: string) {
|
|
||||||
super("Remove channel", false);
|
|
||||||
this.content = new Text("Are you sure you want to remove this channel?");
|
|
||||||
this.confirmButton = new Button("Remove");
|
|
||||||
this.confirmButton.setPosition(30, 30, 40, 30);
|
|
||||||
this.confirmButton.onClick(() => this.doRemove());
|
|
||||||
this.cancelButton = new Button("Cancel");
|
|
||||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
|
||||||
this.cancelButton.onClick(() => this.cancel());
|
|
||||||
this.add(this.content);
|
|
||||||
this.add(this.confirmButton);
|
|
||||||
this.add(this.cancelButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doRemove() {
|
|
||||||
try {
|
|
||||||
const res = await API.deleteChannel(state.currentChannel!.id.toString());
|
|
||||||
state.removeChannel(state.currentChannel!);
|
|
||||||
showToast("Channel was removed.");
|
|
||||||
this.choose(true);
|
|
||||||
} catch (e) {
|
|
||||||
showToast("Failed to remove channel: " + e);
|
|
||||||
|
|
||||||
this.choose(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { API } from "../api";
|
|
||||||
import { IMessage } from "../model/message";
|
|
||||||
import { Button, List, ListItem, TextInput } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
|
|
||||||
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
|
|
||||||
private searchField: TextInput;
|
|
||||||
private searchButton: Button;
|
|
||||||
private resultsList: List;
|
|
||||||
private closeButton: Button;
|
|
||||||
|
|
||||||
public constructor() {
|
|
||||||
super("Search for message", false);
|
|
||||||
this.searchField = new TextInput("Search query");
|
|
||||||
this.searchField.setPosition(5, 5, 80, 20);
|
|
||||||
this.searchField.onKeyDown((key) => {
|
|
||||||
if (key === "Enter") {
|
|
||||||
this.searchButton.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.searchButton = new Button("Search");
|
|
||||||
this.searchButton.setPosition(85, 5, 10, 20);
|
|
||||||
this.searchButton.onClick(async () => {
|
|
||||||
const messages = await API.search(this.searchField.getValue());
|
|
||||||
console.log(messages);
|
|
||||||
this.renderResults(messages);
|
|
||||||
})
|
|
||||||
this.resultsList = new List("Results");
|
|
||||||
this.resultsList.setPosition(5, 20, 90, 70);
|
|
||||||
this.closeButton = new Button("Close");
|
|
||||||
this.closeButton.setPosition(5, 90, 90, 5);
|
|
||||||
this.closeButton.onClick(() => this.cancel());
|
|
||||||
this.add(this.searchField);
|
|
||||||
this.add(this.searchButton);
|
|
||||||
this.add(this.resultsList);
|
|
||||||
this.add(this.closeButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderResults(messages: IMessage[]) {
|
|
||||||
this.resultsList.clear();
|
|
||||||
messages.forEach((message) => {
|
|
||||||
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
|
|
||||||
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
|
|
||||||
this.resultsList.add(itm);
|
|
||||||
});
|
|
||||||
this.resultsList.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Button } from "../ui";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
import { state } from "../state";
|
|
||||||
|
|
||||||
export class SettingsDialog extends Dialog<void> {
|
|
||||||
private resetButton: Button;
|
|
||||||
|
|
||||||
public constructor() {
|
|
||||||
super("Settings");
|
|
||||||
this.resetButton = new Button("Reset frontend");
|
|
||||||
this.resetButton.setPosition(30, 20, 30, 30);
|
|
||||||
this.resetButton.onClick(() => {
|
|
||||||
this.reset();
|
|
||||||
});
|
|
||||||
this.add(this.resetButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
private reset() {
|
|
||||||
state.clear().then(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { API } from "../api";
|
|
||||||
import { state } from "../state";
|
|
||||||
import { Button } from "../ui";
|
|
||||||
import { Camera } from "../ui/camera";
|
|
||||||
import { Dialog } from "../ui/dialog";
|
|
||||||
|
|
||||||
export class TakePhotoDialog extends Dialog<Blob> {
|
|
||||||
private camera: Camera;
|
|
||||||
private takePhotoButton: Button;
|
|
||||||
private discardButton: Button;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super("Take photo", false);
|
|
||||||
this.camera = new Camera("Photo camera");
|
|
||||||
this.camera.setPosition(10, 15, 80, 75);
|
|
||||||
this.camera.startCamera();
|
|
||||||
this.takePhotoButton = new Button("Take photo");
|
|
||||||
this.takePhotoButton.setPosition(10, 90, 80, 10);
|
|
||||||
this.discardButton = new Button("Cancel");
|
|
||||||
this.discardButton.setPosition(5, 5, 10, 10);
|
|
||||||
this.discardButton.onClick(() => this.cancel());
|
|
||||||
this.add(this.camera);
|
|
||||||
this.add(this.takePhotoButton);
|
|
||||||
this.add(this.discardButton);
|
|
||||||
this.takePhotoButton.onClick(async () => {
|
|
||||||
const photo = await this.camera.savePhotoToBlob();
|
|
||||||
if (photo) this.choose(photo);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export type MessageCreated = {
|
|
||||||
channelId: string,
|
|
||||||
id: string,
|
|
||||||
content: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageDeleted = {
|
|
||||||
channelId: string,
|
|
||||||
messageId: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageUpdated = {
|
|
||||||
id: string,
|
|
||||||
content: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChannelCreated = {
|
|
||||||
name: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChannelDeleted = {
|
|
||||||
channelId: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChannelUpdated = {
|
|
||||||
channelId: string,
|
|
||||||
name: string,
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
export type Message<T> = {
|
|
||||||
type: string,
|
|
||||||
data?: T,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageHandler<T> = (message: Message<T>) => void;
|
|
||||||
|
|
||||||
export class MessagingSystem {
|
|
||||||
private handlers: Record<string, MessageHandler<any>[]> = {};
|
|
||||||
|
|
||||||
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
|
|
||||||
if (!this.handlers[type]) {
|
|
||||||
this.handlers[type] = [];
|
|
||||||
}
|
|
||||||
if (!this.handlers[type].includes(handler)) {
|
|
||||||
this.handlers[type].push(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
|
|
||||||
if (this.handlers[type]) {
|
|
||||||
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
|
|
||||||
const wrappedHandler = (message: Message<T>) => {
|
|
||||||
handler(message);
|
|
||||||
this.unregisterHandler(type, wrappedHandler);
|
|
||||||
};
|
|
||||||
this.registerHandler(type, wrappedHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const handler = (message: Message<T>) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
resolve(message.data!);
|
|
||||||
this.unregisterHandler(type, handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.registerHandler(type, handler);
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
if (timeout) {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
this.unregisterHandler(type, handler);
|
|
||||||
reject(new Error(`Timeout waiting for message of type '${type}'`));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public sendMessage<T>(message: Message<T>): void {
|
|
||||||
const handlers = this.handlers[message.type];
|
|
||||||
if (handlers) {
|
|
||||||
handlers.forEach(handler => handler(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { IMessage, Message } from "./message";
|
|
||||||
|
|
||||||
export interface IChannel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
messages: IMessage[];
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Channel implements IChannel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
messages: Message[];
|
|
||||||
createdAt: number;
|
|
||||||
private messageToIdMap: Map<number, Message>;
|
|
||||||
|
|
||||||
constructor(channel: IChannel) {
|
|
||||||
this.id = channel.id;
|
|
||||||
this.name = channel.name;
|
|
||||||
this.messages = [];
|
|
||||||
this.messageToIdMap = new Map();
|
|
||||||
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
|
|
||||||
this.createdAt = channel.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public addMessage(message: Message): void {
|
|
||||||
this.messages.push(message);
|
|
||||||
this.messageToIdMap.set(message.id, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeMessage(messageId: number): void {
|
|
||||||
this.messages = this.messages.filter(message => message.id !== messageId);
|
|
||||||
this.messageToIdMap.delete(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessage(messageId: number): Message|undefined {
|
|
||||||
return this.messageToIdMap.get(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessageByContent(content: string): Message|undefined {
|
|
||||||
return this.messages.find(message => message.content === content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessages(): Message[] {
|
|
||||||
return this.messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessageIds(): number[] {
|
|
||||||
return this.messages.map(message => message.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessageContents(): string[] {
|
|
||||||
return this.messages.map(message => message.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessageId(content: string): number|undefined {
|
|
||||||
const message = this.getMessageByContent(content);
|
|
||||||
return message ? message.id : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IChannelList } from "./channel-list";
|
|
||||||
import { IUnsentMessage } from "./unsent-message";
|
|
||||||
|
|
||||||
export interface IState {
|
|
||||||
token: string;
|
|
||||||
apiUrl: string;
|
|
||||||
defaultChannelId: number;
|
|
||||||
channelList: IChannelList;
|
|
||||||
unsentMessages: IUnsentMessage[];
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
const audioContext = new AudioContext();
|
|
||||||
|
|
||||||
const soundFiles = {
|
|
||||||
intro: 'intro.wav',
|
|
||||||
login: 'login.wav',
|
|
||||||
copy: 'copy.wav',
|
|
||||||
uploadFailed: 'uploadfail.wav'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type SoundName = keyof typeof soundFiles;
|
|
||||||
|
|
||||||
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
|
|
||||||
|
|
||||||
const waterSounds: AudioBuffer[] = [];
|
|
||||||
const sentSounds: AudioBuffer[] = [];
|
|
||||||
|
|
||||||
async function loadSound(url: string): Promise<AudioBuffer> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
return await audioContext.decodeAudioData(arrayBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllSounds() {
|
|
||||||
for (const key in soundFiles) {
|
|
||||||
const soundName = key as SoundName;
|
|
||||||
sounds[soundName] = await loadSound(soundFiles[soundName]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
const buffer = await loadSound(`water${i}.wav`);
|
|
||||||
waterSounds.push(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i <= 6; i++) {
|
|
||||||
const buffer = await loadSound(`sent${i}.wav`);
|
|
||||||
sentSounds.push(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function playSoundBuffer(buffer: AudioBuffer) {
|
|
||||||
if (audioContext.state === 'suspended') {
|
|
||||||
audioContext.resume();
|
|
||||||
}
|
|
||||||
const source = audioContext.createBufferSource();
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.connect(audioContext.destination);
|
|
||||||
source.start(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playSound(name: SoundName) {
|
|
||||||
const buffer = sounds[name];
|
|
||||||
if (buffer) {
|
|
||||||
playSoundBuffer(buffer);
|
|
||||||
} else {
|
|
||||||
console.error(`Sound ${name} not loaded.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playWater() {
|
|
||||||
if (waterSounds.length > 0) {
|
|
||||||
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
|
|
||||||
playSoundBuffer(sound);
|
|
||||||
} else {
|
|
||||||
console.error("Water sounds not loaded.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playSent() {
|
|
||||||
if (sentSounds.length > 0) {
|
|
||||||
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
|
|
||||||
playSoundBuffer(sound);
|
|
||||||
} else {
|
|
||||||
console.error("Sent sounds not loaded.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAllSounds().then(() => {
|
|
||||||
console.log('All sounds loaded and ready to play');
|
|
||||||
}).catch(error => {
|
|
||||||
console.error('Error loading sounds:', error);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { MessagingSystem } from "./events/messaging-system";
|
|
||||||
import { IChannel, Channel } from "./model/channel";
|
|
||||||
import { IChannelList, ChannelList } from "./model/channel-list";
|
|
||||||
import { IState } from "./model/state";
|
|
||||||
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
|
|
||||||
import { get, set, clear } from "idb-keyval";
|
|
||||||
|
|
||||||
|
|
||||||
export class State implements IState {
|
|
||||||
token!: string;
|
|
||||||
apiUrl!: string;
|
|
||||||
channelList!: ChannelList;
|
|
||||||
unsentMessages!: IUnsentMessage[];
|
|
||||||
currentChannel!: Channel | null;
|
|
||||||
defaultChannelId!: number;
|
|
||||||
public events: MessagingSystem;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.token = "";
|
|
||||||
this.channelList = new ChannelList();
|
|
||||||
this.unsentMessages = [];
|
|
||||||
this.events = new MessagingSystem();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getToken(): string {
|
|
||||||
return this.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setToken(token: string): void {
|
|
||||||
this.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannelList(): IChannelList {
|
|
||||||
return this.channelList;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setChannelList(channelList: ChannelList): void {
|
|
||||||
this.channelList = channelList;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getUnsentMessages(): IUnsentMessage[] {
|
|
||||||
return this.unsentMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
|
|
||||||
this.unsentMessages = unsentMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
|
||||||
// stringify everything here except the currentChannel object.
|
|
||||||
const { currentChannel, events, ...state } = this;
|
|
||||||
await set("notebrook", state);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async load(): Promise<void> {
|
|
||||||
const saved = await get("notebrook");
|
|
||||||
if (saved) {
|
|
||||||
this.token = saved.token;
|
|
||||||
this.apiUrl = saved.apiUrl;
|
|
||||||
this.channelList = new ChannelList( saved.channelList);
|
|
||||||
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
|
|
||||||
this.defaultChannelId = saved.defaultChannelId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async clear(): Promise<void> {
|
|
||||||
this.token = "";
|
|
||||||
this.channelList = new ChannelList();
|
|
||||||
this.unsentMessages = [];
|
|
||||||
this.currentChannel = null;
|
|
||||||
this.defaultChannelId = -1;
|
|
||||||
|
|
||||||
await clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannelById(id: number) {
|
|
||||||
return this.channelList.getChannel(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannelByName(name: string) {
|
|
||||||
return this.channelList.getChannelByName(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public findChannelByQuery(query: string) {
|
|
||||||
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public addChannel(channel: Channel) {
|
|
||||||
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeChannel(channel: IChannel) {
|
|
||||||
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addUnsentMessage(message: UnsentMessage) {
|
|
||||||
this.unsentMessages.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeUnsentMessage(message: IUnsentMessage) {
|
|
||||||
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannels() {
|
|
||||||
return this.channelList.channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrentChannel() {
|
|
||||||
return this.currentChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setCurrentChannel(channel: Channel) {
|
|
||||||
this.currentChannel = channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDefaultChannelId() {
|
|
||||||
return this.defaultChannelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setDefaultChannelId(id: number) {
|
|
||||||
this.defaultChannelId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getApiUrl() {
|
|
||||||
return this.apiUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setApiUrl(url: string) {
|
|
||||||
this.apiUrl = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMessageById(id: number) {
|
|
||||||
return this.currentChannel!.getMessage(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const state = new State();
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export class Toast {
|
|
||||||
private container: HTMLElement;
|
|
||||||
private timeout: number;
|
|
||||||
|
|
||||||
constructor(timeout: number = 3000) {
|
|
||||||
this.container = document.querySelector('.toast-container') as HTMLElement;
|
|
||||||
this.timeout = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
public show(message: string): void {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'toast';
|
|
||||||
toast.textContent = message;
|
|
||||||
|
|
||||||
this.container.appendChild(toast);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
toast.classList.add('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.hide(toast);
|
|
||||||
}, this.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
private hide(toast: HTMLElement): void {
|
|
||||||
toast.classList.remove('show');
|
|
||||||
toast.addEventListener('transitionend', () => {
|
|
||||||
toast.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,76 +0,0 @@
|
|||||||
import { UINode } from "./node";
|
|
||||||
|
|
||||||
export class AudioRecorder extends UINode {
|
|
||||||
private audioElement: HTMLAudioElement;
|
|
||||||
private mediaRecorder: MediaRecorder | null;
|
|
||||||
private audioChunks: Blob[];
|
|
||||||
private stream: MediaStream | null;
|
|
||||||
private recording?: Blob;
|
|
||||||
|
|
||||||
public constructor(title: string) {
|
|
||||||
super(title);
|
|
||||||
this.audioElement = document.createElement("audio");
|
|
||||||
this.mediaRecorder = null;
|
|
||||||
this.audioChunks = [];
|
|
||||||
this.stream = null;
|
|
||||||
|
|
||||||
this.audioElement.setAttribute("controls", "true");
|
|
||||||
this.audioElement.setAttribute("aria-label", title);
|
|
||||||
this.element.appendChild(this.audioElement);
|
|
||||||
|
|
||||||
this.setRole("audio-recorder");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async startRecording() {
|
|
||||||
try {
|
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
|
|
||||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
|
||||||
this.mediaRecorder.ondataavailable = (event) => {
|
|
||||||
this.audioChunks.push(event.data);
|
|
||||||
};
|
|
||||||
this.mediaRecorder.onstop = () => {
|
|
||||||
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
|
||||||
this.recording = audioBlob;
|
|
||||||
this.audioChunks = [];
|
|
||||||
const audioUrl = URL.createObjectURL(audioBlob);
|
|
||||||
this.audioElement.src = audioUrl;
|
|
||||||
this.triggerRecordingComplete(audioUrl);
|
|
||||||
};
|
|
||||||
this.mediaRecorder.start();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error accessing microphone:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public stopRecording() {
|
|
||||||
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
|
||||||
this.mediaRecorder.stop();
|
|
||||||
}
|
|
||||||
if (this.stream) {
|
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
|
||||||
this.stream = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.element;
|
|
||||||
}
|
|
||||||
|
|
||||||
public onRecordingComplete(callback: (audioUrl: string) => void) {
|
|
||||||
this.element.addEventListener("recording-complete", (event: Event) => {
|
|
||||||
const customEvent = event as CustomEvent;
|
|
||||||
callback(customEvent.detail.audioUrl);
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected triggerRecordingComplete(audioUrl: string) {
|
|
||||||
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
|
|
||||||
this.element.dispatchEvent(event);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRecording() {
|
|
||||||
return this.recording;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { UINode } from "./node";
|
|
||||||
|
|
||||||
export class Audio extends UINode {
|
|
||||||
private audioElement: HTMLAudioElement;
|
|
||||||
|
|
||||||
public constructor(title: string, src: string | MediaStream = "") {
|
|
||||||
super(title);
|
|
||||||
this.audioElement = document.createElement("audio");
|
|
||||||
if (typeof src === "string") {
|
|
||||||
this.audioElement.src = src; // Set src if it's a string URL
|
|
||||||
} else if (src instanceof MediaStream) {
|
|
||||||
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
|
|
||||||
}
|
|
||||||
this.audioElement.setAttribute("aria-label", title);
|
|
||||||
this.element.appendChild(this.audioElement);
|
|
||||||
this.setRole("audio");
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.audioElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setSource(src: string | MediaStream) {
|
|
||||||
if (typeof src === "string") {
|
|
||||||
this.audioElement.src = src;
|
|
||||||
} else if (src instanceof MediaStream) {
|
|
||||||
this.audioElement.srcObject = src;
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public play() {
|
|
||||||
this.audioElement.play();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public pause() {
|
|
||||||
this.audioElement.pause();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setControls(show: boolean) {
|
|
||||||
this.audioElement.controls = show;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setLoop(loop: boolean) {
|
|
||||||
this.audioElement.loop = loop;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setMuted(muted: boolean) {
|
|
||||||
this.audioElement.muted = muted;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setAutoplay(autoplay: boolean) {
|
|
||||||
this.audioElement.autoplay = autoplay;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setVolume(volume: number) {
|
|
||||||
this.audioElement.volume = volume;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { UINode } from "./node";
|
|
||||||
|
|
||||||
export class Button extends UINode {
|
|
||||||
private buttonElement: HTMLButtonElement;
|
|
||||||
public constructor(title: string, hasPopup: boolean = false) {
|
|
||||||
super(title);
|
|
||||||
this.buttonElement = document.createElement("button");
|
|
||||||
this.buttonElement.innerText = title;
|
|
||||||
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
|
|
||||||
this.element.appendChild(this.buttonElement);
|
|
||||||
this.element.setAttribute("aria-label", this.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
public focus() {
|
|
||||||
this.buttonElement.focus();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public click() {
|
|
||||||
this.buttonElement.click();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.buttonElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setText(text: string) {
|
|
||||||
this.title = text;
|
|
||||||
this.buttonElement.innerText = text;
|
|
||||||
this.element.setAttribute("aria-label", this.title);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setDisabled(val: boolean) {
|
|
||||||
this.buttonElement.disabled = val;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { UINode } from "./node";
|
|
||||||
|
|
||||||
export class Camera extends UINode {
|
|
||||||
private videoElement: HTMLVideoElement;
|
|
||||||
private canvasElement: HTMLCanvasElement;
|
|
||||||
private stream: MediaStream | null;
|
|
||||||
|
|
||||||
public constructor(title: string) {
|
|
||||||
super(title);
|
|
||||||
this.videoElement = document.createElement("video");
|
|
||||||
this.canvasElement = document.createElement("canvas");
|
|
||||||
this.stream = null;
|
|
||||||
|
|
||||||
this.videoElement.setAttribute("aria-label", title);
|
|
||||||
this.element.appendChild(this.videoElement);
|
|
||||||
this.element.appendChild(this.canvasElement);
|
|
||||||
|
|
||||||
this.setRole("camera");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async startCamera() {
|
|
||||||
try {
|
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
||||||
this.videoElement.srcObject = this.stream;
|
|
||||||
this.videoElement.play();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error accessing camera:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public stopCamera() {
|
|
||||||
if (this.stream) {
|
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
|
||||||
this.stream = null;
|
|
||||||
}
|
|
||||||
this.videoElement.pause();
|
|
||||||
this.videoElement.srcObject = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public takePhoto(): HTMLCanvasElement | null {
|
|
||||||
if (this.stream) {
|
|
||||||
const context = this.canvasElement.getContext("2d");
|
|
||||||
if (context) {
|
|
||||||
this.canvasElement.width = this.videoElement.videoWidth;
|
|
||||||
this.canvasElement.height = this.videoElement.videoHeight;
|
|
||||||
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
|
|
||||||
return this.canvasElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public savePhoto(): string | null {
|
|
||||||
const photoCanvas = this.takePhoto();
|
|
||||||
if (photoCanvas) {
|
|
||||||
return photoCanvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public savePhotoToBlob(): Promise<Blob | null> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const photoCanvas = this.takePhoto();
|
|
||||||
if (photoCanvas) {
|
|
||||||
photoCanvas.toBlob((blob) => {
|
|
||||||
resolve(blob);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { UINode } from "./node";
|
|
||||||
|
|
||||||
export class Canvas extends UINode {
|
|
||||||
private canvasElement: HTMLCanvasElement;
|
|
||||||
public constructor(title: string) {
|
|
||||||
super(title);
|
|
||||||
this.canvasElement = document.createElement("canvas");
|
|
||||||
|
|
||||||
this.canvasElement.setAttribute("tabindex", "-1");
|
|
||||||
this.element.appendChild(this.canvasElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
public focus() {
|
|
||||||
this.canvasElement.focus();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public click() {
|
|
||||||
this.canvasElement.click();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.canvasElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public click() {
|
|
||||||
this.checkboxElement.click();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public isChecked(): boolean {
|
|
||||||
return this.checkboxElement.checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setChecked(value: boolean) {
|
|
||||||
this.checkboxElement.checked = value;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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) {
|
|
||||||
this.title = text;
|
|
||||||
this.summaryElement.innerText = text;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public isCollapsed(): boolean {
|
|
||||||
return this.detailsElement.hasAttribute("open");
|
|
||||||
}
|
|
||||||
|
|
||||||
public expand(val: boolean) {
|
|
||||||
if (val) {
|
|
||||||
this.detailsElement.setAttribute("open", "true");
|
|
||||||
} else {
|
|
||||||
this.detailsElement.removeAttribute("open");
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public _onFocus() {
|
|
||||||
this.children[this.focused].focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
public add(node: UINode) {
|
|
||||||
this.children.push(node);
|
|
||||||
node._onConnect();
|
|
||||||
this.containerElement.appendChild(node.render());
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove(node: UINode) {
|
|
||||||
this.children.splice(this.children.indexOf(node), 1);
|
|
||||||
node._onDisconnect();
|
|
||||||
this.containerElement.removeChild(node.render());
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return this.containerElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChildren(): UINode[] {
|
|
||||||
return this.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement() {
|
|
||||||
return this.containerElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setAriaLabel(text: string) {
|
|
||||||
this.containerElement.setAttribute("aria-label", text);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getElement(): HTMLElement {
|
|
||||||
return this.inputElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setText(text: string) {
|
|
||||||
this.title = text;
|
|
||||||
this.titleElement.innerText = text;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue(): string {
|
|
||||||
return this.inputElement.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setValue(value: string) {
|
|
||||||
this.inputElement.value = value;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user