Compare commits

..

34 Commits

Author SHA1 Message Date
b5d4a6ab76 Attempt to fix audio playing on iOS 2026-02-17 11:19:51 +01:00
9ea94e0e31 change Authorization to authorization because wtf notebrook yo 2026-01-10 11:20:30 +00:00
81022c4e21 update rust lib with channel creation 2026-01-10 10:59:19 +00:00
ebe8389cb3 add lib folder 2026-01-10 09:45:35 +00:00
051c032f1d link opening with shift enter 2026-01-09 20:15:58 +00:00
7e49e14901 get rid of some .npm that was never put on gitignore before 2026-01-08 16:21:37 +00:00
74fbd7dc4a add export features 2026-01-08 10:52:24 +00:00
619fcdb9ae chore: update deps and add TypeScript null safety checks 2026-01-04 06:44:41 +00:00
d786a7463b feat: press e to edit 2025-10-22 05:30:10 +02:00
fca1046047 add first letter navigation, switch channels with ctrl k and not ctrl shift c 2025-10-17 10:55:38 +02:00
221aa1c2af fix: allow hosts 2025-09-13 08:19:53 +02:00
bfe77ae86a fix: fix focus behaviour 2025-09-13 08:03:41 +02:00
181ae28548 fix: fix check not working in backend 2025-09-13 07:50:55 +02:00
fab05f32ec feat: implement check functionality 2025-09-13 07:45:19 +02:00
ec1a2ba7f0 fix: fix message focus 2025-09-13 07:33:49 +02:00
64f0f55d10 delete old frontend 2025-09-13 07:22:35 +02:00
60c2a18dbe More css 2025-09-02 22:48:55 +02:00
6286c1e0c9 Try to fix styles 2025-09-02 22:35:24 +02:00
28f6fad818 Try some style fixes 2025-08-26 14:38:57 +02:00
5c76c35d81 Message dialog and move individual message 2025-08-25 12:47:07 +02:00
2b1bf5040f Remove selected from channel list 2025-08-25 12:07:15 +02:00
22b8392fd5 fix: fix delete message logic 2025-08-25 09:05:05 +02:00
9948d1c25b feat: implement delete 2025-08-24 07:13:17 +02:00
cf15a0f9c2 fix: remove stupid hashtag 2025-08-22 19:36:25 +02:00
452192d0a9 a11y: add application role 2025-08-22 19:17:37 +02:00
0d50359dae a11y: make channel list an actual listbox 2025-08-22 19:16:57 +02:00
420ff46f05 a11y: more cosmetic ARIA changes 2025-08-22 19:08:59 +02:00
b312065d3d feat: focus settings on load 2025-08-22 19:01:45 +02:00
b07916309e fix/a11y: fix focus server input, double focus on messsages view and show only active channel info button 2025-08-22 06:47:08 +02:00
6585ec2abb Remove some aria 2025-08-21 14:13:41 +02:00
4dcacd0d73 Fix time display, fix file attachments not working properly after sending without refresh 2025-08-21 14:06:37 +02:00
f2ac7d7209 Add arrow key nav to channel list 2025-08-21 13:45:13 +02:00
fa1cbdf97e Try to fix data messages not showing up after being sent without refresh 2025-08-20 22:59:14 +02:00
8c0f8c6b44 Add custom URL support to vue frontend 2025-08-20 22:50:38 +02:00
154 changed files with 7500 additions and 11266 deletions

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,3 @@
-- Add tri-state checked column to messages (NULL | 0 | 1)
ALTER TABLE messages ADD COLUMN checked INTEGER NULL;

2022
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

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

View File

@@ -18,7 +18,16 @@ export const uploadFile = async (req: Request, res: Response) => {
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!); const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`); logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType }); res.json({
id: result.lastInsertRowid,
channel_id: parseInt(channelId),
message_id: parseInt(messageId),
file_path: filePath,
file_type: fileType,
file_size: fileSize,
original_name: originalName,
created_at: new Date().toISOString()
});
} }

View File

@@ -52,3 +52,46 @@ export const getMessages = async (req: Request, res: Response) => {
res.json({ messages }); res.json({ messages });
} }
export const moveMessage = async (req: Request, res: Response) => {
const { messageId } = req.params;
const { targetChannelId } = req.body;
if (!messageId || !targetChannelId) {
return res.status(400).json({ error: 'Message ID and target channel ID are required' });
}
try {
const result = await MessageService.moveMessage(messageId, targetChannelId);
logger.info(`Message ${messageId} moved to channel ${targetChannelId}`);
res.json({
message: 'Message moved successfully',
messageId: parseInt(messageId),
targetChannelId: parseInt(targetChannelId)
});
} catch (error: any) {
if (error.message === 'Message not found') {
return res.status(404).json({ error: 'Message not found' });
}
logger.critical(`Failed to move message ${messageId}:`, error);
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 });
}

View File

@@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => {
events.on('message-deleted', (id) => { events.on('message-deleted', (id) => {
ws.send(JSON.stringify({ type: 'message-deleted', data: {id }})); ws.send(JSON.stringify({ type: 'message-deleted', data: {id }}));
}); });
events.on('message-moved', (messageId, sourceChannelId, targetChannelId) => {
ws.send(JSON.stringify({ type: 'message-moved', data: {messageId, sourceChannelId, targetChannelId }}));
});
events.on('channel-created', (channel) => { events.on('channel-created', (channel) => {
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }})); ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
}); });

View File

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

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

View File

@@ -6,6 +6,8 @@ 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/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);

View File

@@ -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,

View File

@@ -11,11 +11,16 @@ export const uploadFile = async (channelId: string, messageId: string, filePath:
const result2 = updateQuery.run({ fileId: fileId, messageId: messageId }); const result2 = updateQuery.run({ fileId: fileId, messageId: messageId });
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName); events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
return result2; '' return result;
} }
export const getFiles = async (messageId: string) => { export const getFiles = async (messageId: string) => {
const query = db.prepare(`SELECT * FROM files WHERE messageId = $messageId`); // Get the file linked to this message via the fileId in the messages table
const query = db.prepare(`
SELECT files.* FROM files
JOIN messages ON messages.fileId = files.id
WHERE messages.id = $messageId
`);
const rows = query.all({ messageId: messageId }); const rows = query.all({ messageId: messageId });
return rows; return rows;
} }

View File

@@ -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
@@ -81,3 +81,36 @@ export const getMessage = async (id: string) => {
const row = query.get({ id: id }); const row = query.get({ id: id });
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) => {
// Get current message to emit proper events
const currentMessage = await getMessage(messageId);
if (!currentMessage) {
throw new Error('Message not found');
}
const query = db.prepare(`UPDATE messages SET channelId = $targetChannelId WHERE id = $messageId`);
const result = query.run({ messageId: messageId, targetChannelId: targetChannelId });
if (result.changes === 0) {
throw new Error('Message not found or not updated');
}
// Update FTS table if enabled
if (FTS5Enabled) {
// FTS table doesn't need channelId update, just content remains searchable
// No additional FTS changes needed since content hasn't changed
}
events.emit('message-moved', messageId, (currentMessage as any).channelId, targetChannelId);
return result;
}

View File

@@ -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 {

228
frontend-vue/- Normal file
View File

@@ -0,0 +1,228 @@
<template>
<div class="base-textarea">
<label v-if="label" :for="textareaId" class="base-textarea__label">
{{ label }}
<span v-if="required" class="base-textarea__required">*</span>
</label>
<div class="base-textarea__wrapper">
<textarea
:id="textareaId"
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:rows="rows"
:maxlength="maxlength"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? `${textareaId}-error` : undefined"
:class="[
'base-textarea__field',
{ 'base-textarea__field--error': error }
]"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="handleKeydown"
@keyup="$emit('keyup', $event)"
/>
</div>
<div v-if="showCharCount && maxlength" class="base-textarea__char-count">
{{ modelValue.length }}/{{ maxlength }}
</div>
<div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
{{ error }}
</div>
<div v-else-if="helpText" class="base-textarea__help">
{{ helpText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
rows?: number
maxlength?: number
showCharCount?: boolean
error?: string
helpText?: string
id?: string
autoResize?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
readonly: false,
required: false,
rows: 3,
showCharCount: false,
autoResize: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
submit: []
}>()
const textareaRef = ref<HTMLTextAreaElement>()
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
if (props.autoResize) {
autoResize(target)
}
}
const handleKeydown = (event: KeyboardEvent) => {
emit('keydown', event)
// Submit on Ctrl+Enter or Cmd+Enter
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
emit('submit')
}
}
const autoResize = (textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
const focus = () => {
textareaRef.value?.focus()
}
const selectAll = () => {
textareaRef.value?.select()
}
defineExpose({
focus,
selectAll,
textareaRef
})
</script>
<style scoped>
.base-textarea {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.base-textarea__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-textarea__required {
color: #ef4444;
}
.base-textarea__wrapper {
position: relative;
}
.base-textarea__field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background-color: #ffffff;
color: #111827;
transition: all 0.2s ease;
outline: none;
resize: vertical;
min-height: 3rem;
}
.base-textarea__field:focus {
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.base-textarea__field:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
resize: none;
}
.base-textarea__field:readonly {
background-color: #f9fafb;
cursor: default;
resize: none;
}
.base-textarea__field--error {
border-color: #ef4444;
}
.base-textarea__field--error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-textarea__char-count {
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
.base-textarea__error {
font-size: 0.875rem;
color: #ef4444;
}
.base-textarea__help {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-textarea__label {
color: rgba(255, 255, 255, 0.87);
}
.base-textarea__field {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-textarea__field:disabled,
.base-textarea__field:readonly {
background-color: #1f2937;
color: #9ca3af;
}
.base-textarea__help,
.base-textarea__char-count {
color: #9ca3af;
}
}
</style>

View File

@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Notebrook</title> <title>Notebrook</title>
<meta name="description" content="Light note taking app in messenger style"> <meta name="description" content="Light note taking app in messenger style">
</head> </head>
<body> <body role="application">
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -25,7 +25,7 @@ const toastStore = useToastStore()
<style> <style>
#app { #app {
height: 100vh; height: var(--vh-dynamic, 100vh);
width: 100vw; width: 100vw;
overflow: hidden; overflow: hidden;
} }

View File

@@ -24,7 +24,7 @@
interface Props { interface Props {
type?: 'button' | 'submit' | 'reset' type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean
ariaLabel?: string ariaLabel?: string
@@ -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>
@@ -65,6 +67,12 @@ const handleKeydown = (event: KeyboardEvent) => {
transition: all 0.2s ease; transition: all 0.2s ease;
outline: none; outline: none;
text-decoration: none; text-decoration: none;
/* iOS-specific optimizations */
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
} }
.base-button:focus-visible { .base-button:focus-visible {
@@ -78,19 +86,32 @@ const handleKeydown = (event: KeyboardEvent) => {
} }
/* Sizes */ /* Sizes */
.base-button--xs {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
min-height: 2.25rem; /* 36px - smaller but still usable */
min-width: 2.25rem;
}
.base-button--sm { .base-button--sm {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
min-height: 2.75rem; /* 44px minimum for iOS touch targets */
min-width: 2.75rem;
} }
.base-button--md { .base-button--md {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 1rem; font-size: 1rem;
min-height: 2.75rem;
min-width: 2.75rem;
} }
.base-button--lg { .base-button--lg {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
font-size: 1.125rem; font-size: 1.125rem;
min-height: 3rem;
min-width: 3rem;
} }
/* Variants */ /* Variants */
@@ -126,6 +147,19 @@ const handleKeydown = (event: KeyboardEvent) => {
.base-button--ghost { .base-button--ghost {
background-color: transparent; background-color: transparent;
color: #646cff; color: #646cff;
/* Ensure ghost buttons always meet minimum touch targets */
min-height: 2.75rem;
min-width: 2.75rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Adjust xs ghost buttons for better emoji display */
.base-button--ghost.base-button--xs {
min-height: 2.25rem;
min-width: 2.25rem;
padding: 0.25rem; /* Tighter padding for emoji buttons */
} }
.base-button--ghost:hover:not(:disabled) { .base-button--ghost:hover:not(:disabled) {

View File

@@ -8,7 +8,6 @@
@keydown.esc="handleClose" @keydown.esc="handleClose"
role="dialog" role="dialog"
:aria-labelledby="titleId" :aria-labelledby="titleId"
:aria-describedby="contentId"
aria-modal="true" aria-modal="true"
> >
<div <div
@@ -17,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">
@@ -88,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(
@@ -102,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()
} }
} }
} }
@@ -120,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)

View File

@@ -157,6 +157,9 @@ defineExpose({
outline: none; outline: none;
resize: vertical; resize: vertical;
min-height: 3rem; min-height: 3rem;
/* iOS-specific optimizations */
-webkit-appearance: none;
-webkit-border-radius: 8px;
} }
.base-textarea__field:focus { .base-textarea__field:focus {

View File

@@ -34,6 +34,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const fileExtension = computed(() => { const fileExtension = computed(() => {
if (!props.file.original_name) return ''
return props.file.original_name.split('.').pop()?.toLowerCase() || '' return props.file.original_name.split('.').pop()?.toLowerCase() || ''
}) })

View File

@@ -2,7 +2,7 @@
<div class="input-actions"> <div class="input-actions">
<BaseButton <BaseButton
variant="ghost" variant="ghost"
size="sm" size="xs"
@click="$emit('file-upload')" @click="$emit('file-upload')"
aria-label="Upload file" aria-label="Upload file"
:disabled="disabled" :disabled="disabled"
@@ -12,7 +12,7 @@
<BaseButton <BaseButton
variant="ghost" variant="ghost"
size="sm" size="xs"
@click="$emit('camera')" @click="$emit('camera')"
aria-label="Take photo" aria-label="Take photo"
:disabled="disabled" :disabled="disabled"
@@ -22,7 +22,7 @@
<BaseButton <BaseButton
variant="ghost" variant="ghost"
size="sm" size="xs"
@click="$emit('voice')" @click="$emit('voice')"
aria-label="Record voice message" aria-label="Record voice message"
:disabled="disabled" :disabled="disabled"
@@ -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>
@@ -67,7 +90,15 @@ defineEmits<{
.input-actions { .input-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; 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>

View File

@@ -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()
@@ -76,6 +80,7 @@ defineExpose({
<style scoped> <style scoped>
.message-input-container { .message-input-container {
padding: 1rem; padding: 1rem;
padding-bottom: calc(1rem + var(--safe-area-inset-bottom));
background: white; background: white;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
@@ -83,10 +88,35 @@ defineExpose({
.message-input { .message-input {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 0.75rem; gap: 0.5rem; /* Reduced gap to save space */
max-width: 100%; max-width: 100%;
} }
.message-input :deep(.base-textarea) {
flex: 1; /* Take all available space */
min-width: 200px; /* Ensure minimum usable width */
}
.message-input :deep(.input-actions) {
flex-shrink: 0; /* Don't allow action buttons to shrink */
}
/* Mobile responsiveness */
@media (max-width: 480px) {
.message-input-container {
padding: 0.75rem; /* Slightly less padding on very small screens */
}
.message-input :deep(.base-textarea) {
min-width: 150px; /* Allow smaller minimum width on mobile */
}
/* Ensure buttons remain accessible on small screens */
.message-input :deep(.input-actions) {
gap: 0.125rem; /* Even tighter gap on mobile */
}
}
/* Dark mode */ /* Dark mode */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.message-input-container { .message-input-container {

View File

@@ -4,13 +4,18 @@
'message', 'message',
{ 'message--unsent': isUnsent } { 'message--unsent': isUnsent }
]" ]"
ref="rootEl"
:data-message-id="message.id" :data-message-id="message.id"
:tabindex="tabindex || 0" :tabindex="tabindex ?? -1"
:aria-label="messageAriaLabel" :aria-label="messageAriaLabel"
role="listitem" role="option"
@keydown="handleKeydown" @keydown="handleKeydown"
@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>
@@ -20,8 +25,22 @@
</div> </div>
<div class="message__meta"> <div class="message__meta">
<time v-if="!isUnsent && 'created_at' in message" class="message__time"> <button
{{ formatTime(message.created_at) }} 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
v-if="!isUnsent && 'created_at' in message"
class="message__time"
:datetime="message.created_at"
>
{{ formatSmartTimestamp(message.created_at) }}
</time> </time>
<span v-else class="message__status">Sending...</span> <span v-else class="message__status">Sending...</span>
</div> </div>
@@ -29,10 +48,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, nextTick } from 'vue'
import { useAudio } from '@/composables/useAudio' import { useAudio } from '@/composables/useAudio'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
import FileAttachment from './FileAttachment.vue' import FileAttachment from './FileAttachment.vue'
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types' import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
@@ -42,6 +64,13 @@ interface Props {
tabindex?: number tabindex?: number
} }
const emit = defineEmits<{
'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>(), {
isUnsent: false isUnsent: false
}) })
@@ -52,35 +81,68 @@ const { speak, playSound } = useAudio()
const toastStore = useToastStore() const toastStore = useToastStore()
const appStore = useAppStore() const appStore = useAppStore()
// Root element ref for DOM-based focus management
const rootEl = ref<HTMLElement | null>(null)
// Fallback: focus the chat input textarea
const focusFallbackToInput = () => {
const inputEl = document.querySelector('.message-input .base-textarea__field') as HTMLElement | null
if (inputEl) {
inputEl.focus()
}
}
// Check if message has a file attachment // Check if message has a file attachment
const hasFileAttachment = computed(() => { 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
// Check if we have the minimum required file metadata
if (!props.message.filePath || !props.message.originalName) {
console.warn('File attachment missing metadata:', {
fileId: props.message.fileId,
filePath: props.message.filePath,
originalName: props.message.originalName,
fileType: props.message.fileType
})
return null
}
return { return {
id: props.message.fileId!, id: props.message.fileId!,
channel_id: props.message.channel_id, channel_id: props.message.channel_id,
message_id: props.message.id, message_id: props.message.id,
file_path: props.message.filePath!, file_path: props.message.filePath!,
file_type: props.message.fileType!, file_type: props.message.fileType || 'application/octet-stream',
file_size: props.message.fileSize!, file_size: props.message.fileSize || 0,
original_name: props.message.originalName!, original_name: props.message.originalName!,
created_at: props.message.fileCreatedAt || props.message.created_at created_at: props.message.fileCreatedAt || props.message.created_at
} }
}) })
const formatTime = (timestamp: string): string => { // formatTime function removed - now using formatSmartTimestamp from utils
return new Date(timestamp).toLocaleTimeString()
}
// 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
@@ -95,8 +157,8 @@ const messageAriaLabel = computed(() => {
// Add timestamp // Add timestamp
if ('created_at' in props.message && props.message.created_at) { if ('created_at' in props.message && props.message.created_at) {
const time = formatTime(props.message.created_at) const time = formatTimestampForScreenReader(props.message.created_at)
label += `. Sent at ${time}` label += `. Sent ${time}`
} }
// Add status for unsent messages // Add status for unsent messages
@@ -104,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
@@ -127,17 +189,71 @@ const getFileType = (filename: string): string => {
} }
} }
const handleClick = () => {
// Only open dialog for sent messages (not unsent ones)
if (!props.isUnsent) {
emit('open-dialog', props.message)
}
}
// 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) {
@@ -146,8 +262,126 @@ const handleKeydown = (event: KeyboardEvent) => {
} else { } else {
toastStore.info('Text-to-speech is disabled') toastStore.info('Text-to-speech is disabled')
} }
} else if (event.key === 'Delete') {
event.preventDefault()
handleDelete()
} }
} }
// Delete current message (supports sent and unsent)
const handleDelete = async () => {
try {
// Capture neighboring elements before removal
const current = rootEl.value
const prevEl = (current?.previousElementSibling as HTMLElement | null) || null
const nextEl = (current?.nextElementSibling as HTMLElement | null) || null
const isFirst = !prevEl
const targetToFocus = isFirst ? nextEl : prevEl
if (props.isUnsent) {
// Unsent local message
const unsent = props.message as UnsentMessage
appStore.removeUnsentMessage(unsent.id)
toastStore.success('Unsent message removed')
// focus the closest message
await nextTick()
if (targetToFocus && document.contains(targetToFocus)) {
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
targetToFocus.focus()
} else {
focusFallbackToInput()
}
return
}
// Sent message: optimistic removal, then server delete
const msg = props.message as ExtendedMessage
// Capture original position for potential rollback
const channelMessages = appStore.messages[msg.channel_id] || []
const originalIndex = channelMessages.findIndex(m => m.id === msg.id)
// Optimistically remove from local state for snappy UI
appStore.removeMessage(msg.id)
// Focus the closest message immediately after local removal
await nextTick()
if (targetToFocus && document.contains(targetToFocus)) {
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
targetToFocus.focus()
} else {
focusFallbackToInput()
}
try {
await apiService.deleteMessage(msg.channel_id, msg.id)
// Attempt to sync the channel to reconcile with server state
try {
await syncService.syncChannelMessages(msg.channel_id)
} catch (syncError) {
console.warn('Post-delete sync failed; continuing with local state.', syncError)
}
toastStore.success('Message deleted')
} catch (error) {
// Rollback local removal on failure
if (originalIndex !== -1) {
const list = appStore.messages[msg.channel_id] || []
list.splice(Math.min(originalIndex, list.length), 0, msg)
}
await nextTick()
const restoredEl = document.querySelector(`[data-message-id="${msg.id}"]`) as HTMLElement | null
if (restoredEl) {
if (!restoredEl.hasAttribute('tabindex')) restoredEl.setAttribute('tabindex', '-1')
restoredEl.focus()
}
throw error
}
} catch (error) {
console.error('Failed to delete message:', error)
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>
@@ -157,10 +391,14 @@ const handleKeydown = (event: KeyboardEvent) => {
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
} }
.message:hover { .message:hover {
background: #f1f3f4; background: #f1f3f4;
border-color: #3b82f6;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
} }
.message:focus { .message:focus {
@@ -206,6 +444,31 @@ const handleKeydown = (event: KeyboardEvent) => {
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;
@@ -215,6 +478,8 @@ const handleKeydown = (event: KeyboardEvent) => {
.message:hover { .message:hover {
background: #374151; background: #374151;
border-color: #60a5fa;
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1);
} }
.message__content { .message__content {
@@ -226,3 +491,4 @@ const handleKeydown = (event: KeyboardEvent) => {
} }
} }
</style> </style>

View File

@@ -1,34 +1,24 @@
<template> <template>
<div <div class="messages-container" ref="containerRef" @keydown="handleKeydown" @focusin="handleFocusIn" tabindex="-1" role="listbox"
class="messages-container" :aria-label="messagesAriaLabel">
ref="containerRef"
@keydown="handleKeydown"
tabindex="0"
role="list"
:aria-label="messagesAriaLabel"
:aria-description="navigationHint"
>
<div class="messages" role="presentation"> <div class="messages" role="presentation">
<!-- Regular Messages --> <!-- Regular Messages -->
<MessageItem <MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
v-for="(message, index) in messages" :tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
:key="message.id" :aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
:message="message"
:tabindex="index === focusedMessageIndex ? 0 : -1"
:data-message-index="index"
@focus="focusedMessageIndex = index" @focus="focusedMessageIndex = index"
/> @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 <MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
v-for="(unsentMsg, index) in unsentMessages" :is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
:key="unsentMsg.id" :aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
:message="unsentMsg" :data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
:is-unsent="true" @open-dialog="emit('open-message-dialog', $event)"
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1" @open-dialog-edit="emit('open-message-dialog-edit', $event)"
:data-message-index="messages.length + index" @open-links="(links, msg) => emit('open-links', links, msg)" />
@focus="focusedMessageIndex = messages.length + index"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -45,6 +35,9 @@ 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-edit': [message: ExtendedMessage | UnsentMessage]
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
}>() }>()
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -66,7 +59,7 @@ const messagesAriaLabel = computed(() => {
} else if (total === 1) { } else if (total === 1) {
return 'Messages list, 1 message' return 'Messages list, 1 message'
} else { } else {
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}` return `Messages list, ${total} messages`
} }
}) })
@@ -76,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':
@@ -124,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(() => {
@@ -150,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) {
@@ -158,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()
} else {
focusMessage(newTotal - 1)
scrollToBottom()
}
}
}
// For deletions, defer to the totalMessages watcher below to clamp and focus
} }
scrollToBottom() )
})
// 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)
} }
}) })
@@ -178,13 +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 messages = allMessages.value
if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) {
return messages[focusedMessageIndex.value] ?? 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,
handleOpenUrlFocused,
extractUrls
}) })
</script> </script>
@@ -194,6 +294,12 @@ defineExpose({
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
background: #fafafa; background: #fafafa;
/* iOS-specific scroll optimizations */
-webkit-overflow-scrolling: touch;
-webkit-scroll-behavior: smooth;
scroll-behavior: smooth;
scroll-padding-top: 1rem;
scroll-padding-bottom: 1rem;
} }
.messages-container:focus { .messages-container:focus {

View File

@@ -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()
@@ -280,9 +280,13 @@ const sendPhoto = async () => {
// Upload photo // Upload photo
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file) const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
// Immediately update the local message with file metadata // Create complete message with file metadata
const updatedMessage = { const completeMessage = {
...message, id: message.id,
channel_id: appStore.currentChannelId!,
content: message.content,
created_at: message.created_at,
file_id: uploadedFile.id,
fileId: uploadedFile.id, fileId: uploadedFile.id,
filePath: uploadedFile.file_path, filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type, fileType: uploadedFile.file_type,
@@ -291,8 +295,8 @@ const sendPhoto = async () => {
fileCreatedAt: uploadedFile.created_at fileCreatedAt: uploadedFile.created_at
} }
// Update the message in the store // Add the complete message to the store (this will trigger immediate UI update)
appStore.updateMessage(message.id, updatedMessage) appStore.addMessage(completeMessage)
toastStore.success('Photo sent!') toastStore.success('Photo sent!')
emit('sent') emit('sent')

View File

@@ -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

View File

@@ -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

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

View File

@@ -0,0 +1,726 @@
<template>
<div class="message-dialog">
<div class="dialog-header">
<h2>Message Details</h2>
<button class="close-button" @click="$emit('close')" aria-label="Close dialog">
</button>
</div>
<div class="dialog-content">
<!-- Message Info Section -->
<div class="info-section">
<div class="info-item">
<label>Message ID</label>
<span>#{{ message.id }}</span>
</div>
<div class="info-item">
<label>Sent</label>
<time :datetime="message.created_at">
{{ formatTimestampForScreenReader(message.created_at) }}
</time>
</div>
<div class="info-item">
<label>Channel</label>
<span>{{ channelName }}</span>
</div>
<div v-if="hasFileAttachment" class="info-item">
<label>Attachment</label>
<div class="file-info">
<span class="file-name">{{ message.originalName }}</span>
<span class="file-size">({{ formatFileSize(message.fileSize || 0) }})</span>
</div>
</div>
</div>
<!-- Content Section -->
<div class="content-section">
<label for="message-content">Message Content</label>
<div v-if="!isEditing" class="content-display">
<p>{{ message.content }}</p>
<BaseButton
@click="startEditing"
variant="secondary"
size="sm"
class="edit-button"
>
Edit
</BaseButton>
</div>
<div v-else class="content-edit">
<BaseTextarea
id="message-content"
v-model="editedContent"
placeholder="Message content..."
:rows="4"
auto-resize
ref="contentTextarea"
/>
<div class="edit-actions">
<BaseButton @click="saveEdit" :disabled="!canSave" :loading="isSaving">
Save
</BaseButton>
<BaseButton @click="cancelEdit" variant="secondary">
Cancel
</BaseButton>
</div>
</div>
</div>
<!-- File Actions Section (if file attachment exists) -->
<div v-if="hasFileAttachment" class="file-actions-section">
<h3>File Actions</h3>
<div class="action-buttons">
<BaseButton @click="downloadFile" variant="secondary">
Download {{ message.originalName }}
</BaseButton>
<BaseButton
v-if="isImageFile"
@click="viewImage"
variant="secondary"
>
View Image
</BaseButton>
<BaseButton
v-if="isAudioFile"
@click="playAudio"
variant="secondary"
>
{{ isPlaying ? 'Stop' : 'Play' }} Audio
</BaseButton>
</div>
</div>
<!-- Message Actions Section -->
<div class="actions-section">
<h3>Message Actions</h3>
<div class="action-buttons">
<BaseButton @click="copyMessage" variant="secondary">
Copy Content
</BaseButton>
<BaseButton
@click="readAloud"
variant="secondary"
:disabled="!ttsEnabled"
>
Read Aloud
</BaseButton>
<BaseButton
@click="showDeleteConfirm = true"
variant="danger"
>
Delete Message
</BaseButton>
<BaseButton
@click="showMoveDialog = true"
variant="secondary"
>
Move Message
</BaseButton>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteConfirm" class="confirm-overlay">
<div class="confirm-dialog">
<h3>Delete Message</h3>
<p>Are you sure you want to delete this message? This cannot be undone.</p>
<div class="confirm-actions">
<BaseButton
@click="showDeleteConfirm = false"
variant="secondary"
>
Cancel
</BaseButton>
<BaseButton
@click="deleteMessage"
variant="danger"
:loading="isDeleting"
>
Delete
</BaseButton>
</div>
</div>
</div>
<!-- Move Message Dialog -->
<div v-if="showMoveDialog" class="confirm-overlay">
<div class="confirm-dialog">
<h3>Move Message</h3>
<p>Select the channel to move this message to:</p>
<select v-model="selectedTargetChannelId" class="channel-select">
<option value="">Select a channel...</option>
<option
v-for="channel in availableChannels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}
</option>
</select>
<div class="confirm-actions">
<BaseButton
@click="showMoveDialog = false"
variant="secondary"
>
Cancel
</BaseButton>
<BaseButton
@click="moveMessage"
variant="primary"
:loading="isMoving"
:disabled="!selectedTargetChannelId || selectedTargetChannelId === message.channel_id"
>
Move
</BaseButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import { formatTimestampForScreenReader } from '@/utils/time'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import type { ExtendedMessage } from '@/types'
interface Props {
message: ExtendedMessage
open: boolean
startEditing?: boolean
}
const emit = defineEmits<{
close: []
edit: [messageId: number, content: string]
delete: [messageId: number]
move: [messageId: number, targetChannelId: number]
}>()
const props = defineProps<Props>()
const appStore = useAppStore()
const toastStore = useToastStore()
const { speak, playSound } = useAudio()
// Component state
const isEditing = ref(false)
const editedContent = ref('')
const showDeleteConfirm = ref(false)
const showMoveDialog = ref(false)
const selectedTargetChannelId = ref<number | ''>('')
const isSaving = ref(false)
const isDeleting = ref(false)
const isMoving = ref(false)
const isPlaying = ref(false)
const contentTextarea = ref()
// Computed properties
const channelName = computed(() => {
const channel = appStore.channels.find(c => c.id === props.message.channel_id)
return channel?.name || `Channel ${props.message.channel_id}`
})
const hasFileAttachment = computed(() => {
return !!(props.message.fileId && props.message.originalName)
})
const isImageFile = computed(() => {
if (!props.message.originalName) return false
const ext = props.message.originalName.split('.').pop()?.toLowerCase()
return ext && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)
})
const isAudioFile = computed(() => {
if (!props.message.originalName) return false
const ext = props.message.originalName.split('.').pop()?.toLowerCase()
return ext && ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)
})
const canSave = computed(() => {
return editedContent.value.trim().length > 0 &&
editedContent.value.trim() !== props.message.content
})
const ttsEnabled = computed(() => appStore.settings.ttsEnabled)
const availableChannels = computed(() =>
appStore.channels.filter(channel => channel.id !== props.message.channel_id)
)
// Methods
const startEditing = async () => {
isEditing.value = true
editedContent.value = props.message.content
await nextTick()
contentTextarea.value?.focus()
}
const cancelEdit = () => {
isEditing.value = false
editedContent.value = ''
}
const saveEdit = async () => {
if (!canSave.value) return
isSaving.value = true
try {
emit('edit', props.message.id, editedContent.value.trim())
isEditing.value = false
toastStore.success('Message updated successfully')
} catch (error) {
toastStore.error('Failed to update message')
} finally {
isSaving.value = false
}
}
const deleteMessage = async () => {
isDeleting.value = true
try {
emit('delete', props.message.id)
showDeleteConfirm.value = false
toastStore.success('Message deleted successfully')
} catch (error) {
toastStore.error('Failed to delete message')
} finally {
isDeleting.value = false
}
}
const moveMessage = async () => {
if (!selectedTargetChannelId.value || selectedTargetChannelId.value === props.message.channel_id) {
return
}
isMoving.value = true
try {
emit('move', props.message.id, selectedTargetChannelId.value as number)
showMoveDialog.value = false
selectedTargetChannelId.value = ''
toastStore.success('Message moved successfully')
} catch (error) {
toastStore.error('Failed to move message')
} finally {
isMoving.value = false
}
}
const copyMessage = async () => {
try {
await navigator.clipboard.writeText(props.message.content)
playSound('copy')
toastStore.success('Message copied to clipboard')
} catch (error) {
toastStore.error('Failed to copy message')
}
}
const readAloud = async () => {
if (appStore.settings.ttsEnabled) {
try {
await speak(props.message.content)
toastStore.info('Reading message aloud')
} catch (error) {
toastStore.error('Failed to read message aloud')
}
} else {
toastStore.info('Text-to-speech is disabled')
}
}
const downloadFile = () => {
if (props.message.filePath) {
const link = document.createElement('a')
link.href = `/api/files/${props.message.filePath}`
link.download = props.message.originalName || 'download'
link.click()
toastStore.success('Download started')
}
}
const viewImage = () => {
if (props.message.filePath) {
window.open(`/api/files/${props.message.filePath}`, '_blank')
}
}
const playAudio = () => {
if (props.message.filePath) {
if (isPlaying.value) {
// Stop audio (would need audio instance management)
isPlaying.value = false
} else {
const audio = new Audio(`/api/files/${props.message.filePath}`)
audio.onended = () => { isPlaying.value = false }
audio.onerror = () => {
isPlaying.value = false
toastStore.error('Failed to play audio file')
}
audio.play()
isPlaying.value = true
}
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Handle escape key to close dialog
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (isEditing.value) {
cancelEdit()
} else if (showDeleteConfirm.value) {
showDeleteConfirm.value = false
} else if (showMoveDialog.value) {
showMoveDialog.value = false
selectedTargetChannelId.value = ''
} else {
emit('close')
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
// Auto-start editing if requested
if (props.startEditing) {
startEditing()
}
})
// Cleanup on unmount
const cleanup = () => {
document.removeEventListener('keydown', handleKeydown)
}
defineExpose({ cleanup })
</script>
<style scoped>
.message-dialog {
background: white;
border-radius: 12px;
width: 90vw;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.dialog-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-button:hover {
background: #f3f4f6;
color: #374151;
}
.dialog-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-item label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-item span,
.info-item time {
font-size: 0.875rem;
color: #374151;
}
.file-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.file-name {
font-weight: 500;
}
.file-size {
font-size: 0.75rem !important;
color: #6b7280 !important;
}
.content-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.content-section > label {
font-weight: 600;
color: #374151;
}
.content-display {
position: relative;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.content-display p {
margin: 0;
line-height: 1.5;
color: #374151;
white-space: pre-wrap;
}
.edit-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.content-edit {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.file-actions-section,
.actions-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.file-actions-section h3,
.actions-section h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: white;
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
margin: 1rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.confirm-dialog h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #dc2626;
}
.confirm-dialog p {
margin: 0 0 1.5rem 0;
color: #6b7280;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.channel-select {
width: 100%;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.channel-select:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.message-dialog {
background: #1f2937;
}
.dialog-header {
border-bottom-color: #374151;
}
.dialog-header h2 {
color: #f9fafb;
}
.close-button {
color: #9ca3af;
}
.close-button:hover {
background: #374151;
color: #f3f4f6;
}
.info-section {
background: #374151;
}
.info-item label {
color: #9ca3af;
}
.info-item span,
.info-item time {
color: #f3f4f6;
}
.content-section > label,
.file-actions-section h3,
.actions-section h3 {
color: #f3f4f6;
}
.content-display {
background: #374151;
border-color: #4b5563;
}
.content-display p {
color: #f3f4f6;
}
.confirm-dialog {
background: #1f2937;
}
.confirm-dialog p {
color: #9ca3af;
}
.channel-select {
background: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.channel-select:focus {
outline-color: #60a5fa;
}
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.message-dialog {
width: 95vw;
margin: 1rem;
}
.info-section {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.edit-actions {
flex-direction: column-reverse;
}
}
</style>

View File

@@ -58,7 +58,7 @@
{{ result.content }} {{ result.content }}
</div> </div>
<div class="result-time"> <div class="result-time">
{{ formatTime(result.created_at) }} {{ formatSmartTimestamp(result.created_at) }}
</div> </div>
</div> </div>
</div> </div>
@@ -79,6 +79,7 @@ import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api' import { apiService } from '@/services/api'
import { formatSmartTimestamp } from '@/utils/time'
import BaseInput from '@/components/base/BaseInput.vue' import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import type { Message, ExtendedMessage } from '@/types' import type { Message, ExtendedMessage } from '@/types'
@@ -140,16 +141,7 @@ const getChannelName = (channelId: number): string => {
return channel?.name || `Channel ${channelId}` return channel?.name || `Channel ${channelId}`
} }
const formatTime = (timestamp: string): string => { // formatTime function removed - now using formatSmartTimestamp from utils
if (!timestamp) return 'Unknown time'
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return date.toLocaleString()
}
onMounted(() => { onMounted(() => {
searchInput.value?.focus() searchInput.value?.focus()

View File

@@ -6,6 +6,7 @@
<label class="setting-item"> <label class="setting-item">
<input <input
ref="soundInput"
type="checkbox" type="checkbox"
v-model="localSettings.soundEnabled" v-model="localSettings.soundEnabled"
class="checkbox" class="checkbox"
@@ -145,6 +146,100 @@
</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">
<h3>Account</h3>
<div class="setting-item">
<label>Current Server</label>
<div class="server-info">
{{ currentServerUrl || 'Default' }}
</div>
</div>
<div class="setting-actions">
<BaseButton
type="button"
variant="secondary"
@click="handleLogout"
:disabled="isSaving"
>
Logout
</BaseButton>
<BaseButton
type="button"
variant="danger"
@click="showResetConfirm = true"
:disabled="isSaving"
>
Reset All Data
</BaseButton>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<BaseButton <BaseButton
type="button" type="button"
@@ -161,14 +256,72 @@
</BaseButton> </BaseButton>
</div> </div>
</form> </form>
<!-- Reset Data Confirmation Dialog -->
<div v-if="showResetConfirm" class="confirm-overlay">
<div class="confirm-dialog">
<h3>Reset All Data</h3>
<p>This will permanently delete all local data including messages, settings, and authentication. This cannot be undone.</p>
<div class="confirm-actions">
<BaseButton
type="button"
variant="secondary"
@click="showResetConfirm = false"
>
Cancel
</BaseButton>
<BaseButton
type="button"
variant="danger"
@click="handleResetData"
:loading="isResetting"
>
Reset All Data
</BaseButton>
</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>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
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 BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import type { AppSettings } from '@/types' import type { AppSettings } from '@/types'
@@ -176,12 +329,27 @@ const emit = defineEmits<{
close: [] close: []
}>() }>()
const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore() const toastStore = useToastStore()
const { availableVoices, speak, setVoice } = useAudio() const { availableVoices, speak, setVoice } = useAudio()
const isSaving = ref(false) const isSaving = 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 showRestoreConfirm = ref(false)
const pendingRestoreFile = ref<File | null>(null)
const selectedVoiceURI = ref('') const selectedVoiceURI = ref('')
const soundInput = ref()
const restoreInput = ref<HTMLInputElement>()
// Computed property for current server URL
const currentServerUrl = computed(() => authStore.serverUrl)
const localSettings = reactive<AppSettings>({ const localSettings = reactive<AppSettings>({
soundEnabled: true, soundEnabled: true,
speechEnabled: true, speechEnabled: true,
@@ -229,12 +397,129 @@ const handleSave = async () => {
} }
} }
const handleLogout = async () => {
try {
await authStore.clearAuth()
toastStore.success('Logged out successfully')
emit('close')
router.push('/auth')
} catch (error) {
console.error('Logout failed:', error)
toastStore.error('Logout failed')
}
}
const handleResetData = async () => {
isResetting.value = true
try {
// Clear all IndexedDB data
await clear()
// Clear stores
await authStore.clearAuth()
appStore.$reset()
toastStore.success('All data has been reset')
showResetConfirm.value = false
emit('close')
// Redirect to auth page
router.push('/auth')
} catch (error) {
console.error('Reset failed:', error)
toastStore.error('Failed to reset data')
} finally {
isResetting.value = false
}
}
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)
// Set up voice selection // Set up voice selection
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || '' selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
soundInput.value.focus();
}) })
</script> </script>
@@ -276,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;
@@ -340,6 +641,63 @@ onMounted(() => {
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
.server-info {
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
font-family: monospace;
font-size: 0.875rem;
color: #374151;
word-break: break-all;
}
.setting-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: white;
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
margin: 1rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.confirm-dialog h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #dc2626;
}
.confirm-dialog p {
margin: 0 0 1.5rem 0;
color: #6b7280;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Dark mode */ /* Dark mode */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.setting-group h3 { .setting-group h3 {
@@ -360,5 +718,34 @@ onMounted(() => {
.form-actions { .form-actions {
border-top-color: #374151; border-top-color: #374151;
} }
.server-info {
color: rgba(255, 255, 255, 0.87);
}
.confirm-overlay {
background: rgba(0, 0, 0, 0.8);
}
.confirm-dialog {
background: #1f2937;
}
.confirm-dialog h3 {
color: rgba(255, 255, 255, 0.87);
}
.confirm-dialog p {
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>

View File

@@ -188,9 +188,13 @@ const sendVoiceMessage = async () => {
// Upload voice file // Upload voice file
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file) const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
// Immediately update the local message with file metadata // Create complete message with file metadata
const updatedMessage = { const completeMessage = {
...message, id: message.id,
channel_id: appStore.currentChannelId!,
content: message.content,
created_at: message.created_at,
file_id: uploadedFile.id,
fileId: uploadedFile.id, fileId: uploadedFile.id,
filePath: uploadedFile.file_path, filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type, fileType: uploadedFile.file_type,
@@ -199,8 +203,8 @@ const sendVoiceMessage = async () => {
fileCreatedAt: uploadedFile.created_at fileCreatedAt: uploadedFile.created_at
} }
// Update the message in the store // Add the complete message to the store (this will trigger immediate UI update)
appStore.updateMessage(message.id, updatedMessage) appStore.addMessage(completeMessage)
toastStore.success('Voice message sent!') toastStore.success('Voice message sent!')
clearRecording() clearRecording()
@@ -226,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)
} }

View File

@@ -1,20 +1,26 @@
<template> <template>
<div class="channel-list-container"> <div class="channel-list-container" ref="containerRef">
<ul class="channel-list" role="list"> <ul class="channel-list" role="listbox" aria-label="Channels">
<ChannelListItem <ChannelListItem
v-for="channel in channels" v-for="(channel, index) in channels"
:key="channel.id" :key="channel.id"
:channel="channel" :channel="channel"
:is-active="channel.id === currentChannelId" :is-active="channel.id === currentChannelId"
:unread-count="unreadCounts[channel.id]" :unread-count="unreadCounts[channel.id]"
@select="$emit('select-channel', $event)" :tabindex="index === focusedChannelIndex ? 0 : -1"
:channel-index="index"
:data-channel-index="index"
@select="handleChannelSelect"
@info="$emit('channel-info', $event)" @info="$emit('channel-info', $event)"
@keydown="handleChannelKeydown"
@focus="handleChannelFocus"
/> />
</ul> </ul>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import ChannelListItem from './ChannelListItem.vue' import ChannelListItem from './ChannelListItem.vue'
import type { Channel } from '@/types' import type { Channel } from '@/types'
@@ -24,12 +30,182 @@ interface Props {
unreadCounts: Record<number, number> unreadCounts: Record<number, number>
} }
defineProps<Props>() const emit = defineEmits<{
defineEmits<{
'select-channel': [channelId: number] 'select-channel': [channelId: number]
'channel-info': [channel: Channel] 'channel-info': [channel: Channel]
}>() }>()
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
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
const handleChannelSelect = (channelId: number) => {
emit('select-channel', channelId)
}
const handleChannelFocus = (index: number) => {
focusedChannelIndex.value = index
}
const handleChannelKeydown = (event: KeyboardEvent, channelIndex: number) => {
if (props.channels.length === 0) return
// Don't handle keys with modifiers - let them bubble up for global shortcuts
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}
let newIndex = channelIndex
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
newIndex = Math.max(0, channelIndex - 1)
break
case 'ArrowDown':
event.preventDefault()
newIndex = Math.min(props.channels.length - 1, channelIndex + 1)
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = props.channels.length - 1
break
case 'Enter':
case ' ':
event.preventDefault()
const selectedChannel = props.channels[channelIndex]
if (selectedChannel) {
emit('select-channel', selectedChannel.id)
}
return
case 'i':
case 'I':
// Only handle 'i' without modifiers
if (!event.shiftKey) {
event.preventDefault()
const infoChannel = props.channels[channelIndex]
if (infoChannel) {
emit('channel-info', infoChannel)
}
return
}
break
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
}
if (newIndex !== channelIndex) {
focusChannel(newIndex)
}
}
const focusChannel = (index: number) => {
focusedChannelIndex.value = index
nextTick(() => {
const buttonElement = containerRef.value?.querySelector(`[data-channel-index="${index}"] .channel-button`) as HTMLElement
if (buttonElement) {
buttonElement.focus()
buttonElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
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(() => props.channels.length, (newLength) => {
if (focusedChannelIndex.value >= newLength) {
focusedChannelIndex.value = Math.max(0, newLength - 1)
}
})
// Set initial focus to current channel or first channel
watch(() => props.currentChannelId, (newChannelId) => {
if (newChannelId) {
const index = props.channels.findIndex(channel => channel.id === newChannelId)
if (index !== -1) {
focusedChannelIndex.value = index
}
}
}, { immediate: true })
onMounted(() => {
// Focus the current channel if available
if (props.currentChannelId) {
const index = props.channels.findIndex(channel => channel.id === props.currentChannelId)
if (index !== -1) {
focusedChannelIndex.value = index
}
}
})
defineExpose({
focusChannel
})
</script> </script>
<style scoped> <style scoped>
@@ -37,8 +213,13 @@ defineEmits<{
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem 0; padding: 0.5rem 0;
/* iOS-specific scroll optimizations */
-webkit-overflow-scrolling: touch;
-webkit-scroll-behavior: smooth;
scroll-behavior: smooth;
} }
.channel-list { .channel-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -4,13 +4,19 @@
'channel-item', 'channel-item',
{ 'channel-item--active': isActive } { 'channel-item--active': isActive }
]" ]"
:data-channel-index="channelIndex"
role="listitem"
> >
<div class="channel-wrapper"> <div class="channel-wrapper">
<button <button
class="channel-button" class="channel-button"
@click="$emit('select', channel.id)" @click="$emit('select', channel.id)"
:aria-pressed="isActive" @focus="handleFocus"
:aria-label="`Select channel ${channel.name}`" role="option"
:aria-current="isActive"
@keydown="handleKeydown"
:tabindex="tabindex"
:aria-label="channelAriaLabel"
> >
<span class="channel-name">{{ channel.name }}</span> <span class="channel-name">{{ channel.name }}</span>
<span v-if="unreadCount" class="channel-unread"> <span v-if="unreadCount" class="channel-unread">
@@ -18,7 +24,7 @@
</span> </span>
</button> </button>
<button <button v-if="isActive"
class="channel-info-button" class="channel-info-button"
@click.stop="$emit('info', channel)" @click.stop="$emit('info', channel)"
:aria-label="`Channel info for ${channel.name}`" :aria-label="`Channel info for ${channel.name}`"
@@ -31,20 +37,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { Channel } from '@/types' import type { Channel } from '@/types'
interface Props { interface Props {
channel: Channel channel: Channel
isActive: boolean isActive: boolean
unreadCount?: number unreadCount?: number
tabindex?: number
channelIndex?: number
} }
defineProps<Props>() const emit = defineEmits<{
defineEmits<{
select: [channelId: number] select: [channelId: number]
info: [channel: Channel] info: [channel: Channel]
focus: [index: number]
keydown: [event: KeyboardEvent, index: number]
}>() }>()
const props = defineProps<Props>()
// Better ARIA label that announces the channel name and unread count
const channelAriaLabel = computed(() => {
let label = `${props.channel.name}`
if (props.unreadCount) {
label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
}
return label
})
const handleFocus = () => {
if (props.channelIndex !== undefined) {
emit('focus', props.channelIndex)
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (props.channelIndex !== undefined) {
emit('keydown', event, props.channelIndex)
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,15 +1,28 @@
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar__header"> <div class="sidebar__header">
<h1 class="sidebar__title">Notebrook</h1> <div class="sidebar__header-left">
<BaseButton <h1 class="sidebar__title">Notebrook</h1>
variant="ghost" </div>
size="sm" <div class="sidebar__header-right">
@click="$emit('create-channel')" <BaseButton
aria-label="Create new channel" variant="ghost"
> size="sm"
+ @click="$emit('create-channel')"
</BaseButton> aria-label="Create new channel"
>
+
</BaseButton>
<BaseButton
variant="ghost"
size="sm"
class="sidebar__close-button"
@click="$emit('close')"
aria-label="Close sidebar"
>
</BaseButton>
</div>
</div> </div>
<div class="sidebar__content"> <div class="sidebar__content">
@@ -53,6 +66,7 @@ defineEmits<{
'select-channel': [channelId: number] 'select-channel': [channelId: number]
'channel-info': [channel: Channel] 'channel-info': [channel: Channel]
'settings': [] 'settings': []
'close': []
}>() }>()
</script> </script>
@@ -63,7 +77,7 @@ defineEmits<{
border-right: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: var(--vh-dynamic, 100vh);
} }
.sidebar__header { .sidebar__header {
@@ -76,6 +90,17 @@ defineEmits<{
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar__header-left {
display: flex;
align-items: center;
}
.sidebar__header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar__title { .sidebar__title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
@@ -99,6 +124,10 @@ defineEmits<{
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar__close-button {
display: none; /* Hidden by default on desktop */
}
/* Dark mode */ /* Dark mode */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.sidebar { .sidebar {
@@ -129,10 +158,15 @@ defineEmits<{
.sidebar__header { .sidebar__header {
padding: 1rem; padding: 1rem;
padding-top: calc(1rem + var(--safe-area-inset-top));
} }
.sidebar__title { .sidebar__title {
font-size: 1.125rem; font-size: 1.125rem;
} }
.sidebar__close-button {
display: inline-flex; /* Show on mobile */
}
} }
</style> </style>

View File

@@ -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) {

View File

@@ -25,11 +25,8 @@ export function useKeyboardShortcuts() {
} }
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
// Skip shortcuts when focused on input/textarea elements
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') { const isInInputField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA'
return
}
const config: ShortcutConfig = { const config: ShortcutConfig = {
key: event.key.toLowerCase(), key: event.key.toLowerCase(),
@@ -44,6 +41,17 @@ export function useKeyboardShortcuts() {
const shortcut = shortcuts.value.get(shortcutKey) const shortcut = shortcuts.value.get(shortcutKey)
if (shortcut) { if (shortcut) {
// Allow certain shortcuts to work globally, even in input fields
const isGlobalShortcut = (shortcut.ctrlKey && shortcut.shiftKey) ||
shortcut.altKey ||
shortcut.key === 'escape' ||
(shortcut.ctrlKey && shortcut.key === 'k')
// Skip shortcuts that shouldn't work in input fields
if (isInInputField && !isGlobalShortcut) {
return
}
if (shortcut.preventDefault !== false) { if (shortcut.preventDefault !== false) {
event.preventDefault() event.preventDefault()
} }

View File

@@ -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()

View File

@@ -1,12 +1,14 @@
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { websocketService } from '@/services/websocket' import { websocketService } from '@/services/websocket'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
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 type { Channel, ExtendedMessage, FileAttachment } from '@/types' import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
export function useWebSocket() { export function useWebSocket() {
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore() const toastStore = useToastStore()
const { announceMessage } = useAudio() const { announceMessage } = useAudio()
@@ -62,6 +64,24 @@ export function useWebSocket() {
appStore.removeMessage(parseInt(data.id)) appStore.removeMessage(parseInt(data.id))
} }
const handleMessageMoved = (data: { messageId: string, sourceChannelId: string, targetChannelId: string }) => {
console.log('WebSocket: Message moved event received:', data)
const messageId = parseInt(data.messageId)
const sourceChannelId = parseInt(data.sourceChannelId)
const targetChannelId = parseInt(data.targetChannelId)
appStore.moveMessage(messageId, sourceChannelId, targetChannelId)
// Show toast notification if the move affects the current view
if (appStore.currentChannelId === sourceChannelId || appStore.currentChannelId === targetChannelId) {
const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
if (sourceChannel && targetChannel) {
toastStore.info(`Message moved from "${sourceChannel.name}" to "${targetChannel.name}"`)
}
}
}
const handleFileUploaded = (data: any) => { const handleFileUploaded = (data: any) => {
// Handle file upload events with flattened format // Handle file upload events with flattened format
const messageUpdate: Partial<ExtendedMessage> = { const messageUpdate: Partial<ExtendedMessage> = {
@@ -116,8 +136,15 @@ 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]
appStore.setChannels(channels) if (existingChannel) {
channels[channelIndex] = {
id: existingChannel.id,
name: data.name,
created_at: existingChannel.created_at
}
appStore.setChannels(channels)
}
} }
} }
@@ -125,6 +152,7 @@ export function useWebSocket() {
websocketService.on('message-created', handleMessageCreated) websocketService.on('message-created', handleMessageCreated)
websocketService.on('message-updated', handleMessageUpdated) websocketService.on('message-updated', handleMessageUpdated)
websocketService.on('message-deleted', handleMessageDeleted) websocketService.on('message-deleted', handleMessageDeleted)
websocketService.on('message-moved', handleMessageMoved)
websocketService.on('file-uploaded', handleFileUploaded) websocketService.on('file-uploaded', handleFileUploaded)
websocketService.on('channel-created', handleChannelCreated) websocketService.on('channel-created', handleChannelCreated)
websocketService.on('channel-deleted', handleChannelDeleted) websocketService.on('channel-deleted', handleChannelDeleted)
@@ -149,6 +177,7 @@ export function useWebSocket() {
websocketService.off('message-created', handleMessageCreated) websocketService.off('message-created', handleMessageCreated)
websocketService.off('message-updated', handleMessageUpdated) websocketService.off('message-updated', handleMessageUpdated)
websocketService.off('message-deleted', handleMessageDeleted) websocketService.off('message-deleted', handleMessageDeleted)
websocketService.off('message-moved', handleMessageMoved)
websocketService.off('file-uploaded', handleFileUploaded) websocketService.off('file-uploaded', handleFileUploaded)
websocketService.off('channel-created', handleChannelCreated) websocketService.off('channel-created', handleChannelCreated)
websocketService.off('channel-deleted', handleChannelDeleted) websocketService.off('channel-deleted', handleChannelDeleted)
@@ -157,6 +186,11 @@ export function useWebSocket() {
} }
onMounted(() => { onMounted(() => {
// Set custom server URL if available
if (authStore.serverUrl) {
websocketService.setServerUrl(authStore.serverUrl)
}
setupEventHandlers() setupEventHandlers()
websocketService.connect() websocketService.connect()
}) })

View File

@@ -9,6 +9,11 @@ class ApiService {
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null') console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
} }
setBaseUrl(url: string) {
this.baseUrl = url
console.log('API service base URL set:', url)
}
private getHeaders(): HeadersInit { private getHeaders(): HeadersInit {
return { return {
'Authorization': this.token, 'Authorization': this.token,
@@ -113,6 +118,20 @@ 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 }> {
return this.request(`/channels/${channelId}/messages/${messageId}/move`, {
method: 'PUT',
body: JSON.stringify({ targetChannelId })
})
}
// Files // Files
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> { async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
const formData = new FormData() const formData = new FormData()
@@ -148,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()

View File

@@ -8,7 +8,11 @@ export class SyncService {
} }
/** /**
* Sync messages for a channel: merge server data with local data * Sync messages for a channel: replace local data with server data
*
* Prunes any local messages that are no longer present on the server
* instead of keeping them around. We still keep unsent messages in the
* separate unsent queue handled elsewhere.
*/ */
async syncChannelMessages(channelId: number): Promise<void> { async syncChannelMessages(channelId: number): Promise<void> {
try { try {
@@ -20,54 +24,35 @@ export class SyncService {
const serverResponse = await apiService.getMessages(channelId) const serverResponse = await apiService.getMessages(channelId)
const serverMessages = serverResponse.messages const serverMessages = serverResponse.messages
// Get local messages console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
const localMessages = appStore.messages[channelId] || []
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`) // Transform and sort server messages only (pruning locals not on server)
const normalizedServerMessages: ExtendedMessage[] = serverMessages
.map((msg: any) => {
const transformedMsg: ExtendedMessage = {
id: msg.id,
channel_id: msg.channelId || msg.channel_id,
content: msg.content,
created_at: msg.createdAt || msg.created_at,
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
fileId: msg.fileId,
filePath: msg.filePath,
fileType: msg.fileType,
fileSize: msg.fileSize,
originalName: msg.originalName,
fileCreatedAt: msg.fileCreatedAt
}
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
return transformedMsg
})
.sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
// Merge messages using a simple strategy: console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
// 1. Create a map of all messages by ID
// 2. Server messages take precedence (they may have been updated)
// 3. Keep local messages that don't exist on server (may be unsent)
const messageMap = new Map<number, ExtendedMessage>() // Update local storage with server truth
appStore.setMessages(channelId, normalizedServerMessages)
// Add local messages first
localMessages.forEach(msg => {
if (typeof msg.id === 'number') {
messageMap.set(msg.id, msg)
}
})
// Add/update with server messages (server wins for conflicts)
serverMessages.forEach((msg: any) => {
// Transform server message format to match our types
const transformedMsg: ExtendedMessage = {
id: msg.id,
channel_id: msg.channelId || msg.channel_id,
content: msg.content,
created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id,
// Map the flattened file fields from backend
fileId: msg.fileId,
filePath: msg.filePath,
fileType: msg.fileType,
fileSize: msg.fileSize,
originalName: msg.originalName,
fileCreatedAt: msg.fileCreatedAt
}
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
messageMap.set(msg.id, transformedMsg)
})
// Convert back to array, sorted by creation time
const mergedMessages = Array.from(messageMap.values())
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log(`Merged result: ${mergedMessages.length} messages`)
// Update local storage
appStore.setMessages(channelId, mergedMessages)
await appStore.saveState() await appStore.saveState()
} catch (error) { } catch (error) {
@@ -77,7 +62,7 @@ export class SyncService {
} }
/** /**
* Attempt to send all unsent messages * Attempt to send all unsent messages (text and file messages)
*/ */
async retryUnsentMessages(): Promise<void> { async retryUnsentMessages(): Promise<void> {
const appStore = this.getAppStore() const appStore = this.getAppStore()
@@ -86,28 +71,68 @@ export class SyncService {
for (const unsentMsg of [...unsentMessages]) { for (const unsentMsg of [...unsentMessages]) {
try { try {
console.log(`Sending unsent message: ${unsentMsg.content}`) console.log(`Sending unsent ${unsentMsg.messageType || 'text'} message: ${unsentMsg.content}`)
// Try to send the message if (unsentMsg.messageType === 'voice' || unsentMsg.messageType === 'image') {
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content) // Handle file message retry
console.log(`Successfully sent unsent message, got ID: ${response.id}`) if (!unsentMsg.fileData) {
console.error(`File message ${unsentMsg.id} missing file data, removing`)
appStore.removeUnsentMessage(unsentMsg.id)
continue
}
// Create the sent message // Create message and upload file
const sentMessage: ExtendedMessage = { const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
id: response.id,
channel_id: unsentMsg.channelId, // Create file from stored blob data
content: unsentMsg.content, const file = new File([unsentMsg.fileData.blob], unsentMsg.fileData.fileName, {
created_at: new Date().toISOString() type: unsentMsg.fileData.fileType
})
// Upload file
const uploadedFile = await apiService.uploadFile(unsentMsg.channelId, response.id, file)
// Create complete message with file metadata
const sentMessage: ExtendedMessage = {
id: response.id,
channel_id: unsentMsg.channelId,
content: unsentMsg.content,
created_at: response.created_at,
file_id: uploadedFile.id,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
appStore.addMessage(sentMessage)
console.log(`Successfully sent unsent ${unsentMsg.messageType} message, got ID: ${response.id}`)
} else {
// Handle text message retry (existing logic)
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
console.log(`Successfully sent unsent text message, got ID: ${response.id}`)
// Create the sent message
const sentMessage: ExtendedMessage = {
id: response.id,
channel_id: unsentMsg.channelId,
content: unsentMsg.content,
created_at: new Date().toISOString()
}
appStore.addMessage(sentMessage)
} }
// Add to messages and remove from unsent // Remove from unsent messages
appStore.addMessage(sentMessage)
appStore.removeUnsentMessage(unsentMsg.id) appStore.removeUnsentMessage(unsentMsg.id)
// Save state immediately after successful send to ensure UI updates // Save state immediately after successful send to ensure UI updates
await appStore.saveState() await appStore.saveState()
console.log(`Moved unsent message ${unsentMsg.id} to sent messages with ID ${response.id}`) console.log(`Moved unsent message ${unsentMsg.id} to sent messages`)
console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`) console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`)
} catch (error) { } catch (error) {
@@ -201,6 +226,65 @@ export class SyncService {
throw error // Re-throw so caller knows it failed throw error // Re-throw so caller knows it failed
} }
} }
/**
* Send a file message with optimistic updates and offline support
*/
async sendFileMessage(channelId: number, content: string, file: File, messageType: 'voice' | 'image' = 'image'): Promise<void> {
try {
console.log(`Optimistically sending ${messageType} message: ${content}`)
// Try to send immediately
const message = await apiService.createMessage(channelId, content)
// Upload file
const uploadedFile = await apiService.uploadFile(channelId, message.id, file)
// Success - create complete message with file metadata
const completeMessage: ExtendedMessage = {
id: message.id,
channel_id: channelId,
content: content,
created_at: message.created_at,
file_id: uploadedFile.id,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
const appStore = this.getAppStore()
appStore.addMessage(completeMessage)
console.log(`${messageType} message sent successfully with ID: ${message.id}`)
} catch (error) {
console.warn(`Failed to send ${messageType} message immediately, queuing for later:`, error)
// Queue file message for retry when back online
const unsentMessage: UnsentMessage = {
id: `unsent_${messageType}_${Date.now()}_${Math.random()}`,
channelId: channelId,
content: content,
timestamp: Date.now(),
retries: 0,
messageType: messageType,
fileData: {
blob: file,
fileName: file.name,
fileType: file.type,
fileSize: file.size
}
}
const appStore = this.getAppStore()
appStore.addUnsentMessage(unsentMessage)
await appStore.saveState()
throw error // Re-throw so caller knows it failed
}
}
} }
export const syncService = new SyncService() export const syncService = new SyncService()

View File

@@ -6,20 +6,35 @@ class WebSocketService {
private maxReconnectAttempts = 5 private maxReconnectAttempts = 5
private reconnectInterval = 1000 private reconnectInterval = 1000
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map() private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
private customServerUrl: string | null = null
setServerUrl(url: string) {
this.customServerUrl = url
}
connect() { connect() {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
return return
} }
// In development, connect to backend server (port 3000) // Determine WebSocket URL
// In production, use same host as frontend let wsUrl: string
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' if (this.customServerUrl) {
const host = isDev ? 'localhost:3000' : window.location.host // Use custom server URL
const wsUrl = `${protocol}//${host}` const url = new URL(this.customServerUrl)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${protocol}//${url.host}`
} else {
// Use default behavior
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = isDev ? 'localhost:3000' : window.location.host
wsUrl = `${protocol}//${host}`
}
try { try {
console.log('Connecting to WebSocket:', wsUrl)
this.ws = new WebSocket(wsUrl) this.ws = new WebSocket(wsUrl)
this.setupEventListeners() this.setupEventListeners()
} catch (error) { } catch (error) {

View File

@@ -71,8 +71,26 @@ export const useAppStore = defineStore('app', () => {
if (!messages.value[message.channel_id]) { if (!messages.value[message.channel_id]) {
messages.value[message.channel_id] = [] messages.value[message.channel_id] = []
} }
messages.value[message.channel_id].push(message)
console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages') const channelMessages = messages.value[message.channel_id]
if (!channelMessages) return
const existingIndex = channelMessages.findIndex(m => m.id === message.id)
if (existingIndex !== -1) {
// Upsert: update existing to avoid duplicates from WebSocket vs sync
const existingMessage = channelMessages[existingIndex]
if (existingMessage) {
channelMessages[existingIndex] = { ...existingMessage, ...message }
}
} else {
channelMessages.push(message)
}
// Keep chronological order by created_at
channelMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log('Store: Messages for channel', message.channel_id, 'now has', channelMessages.length, 'messages')
// Note: Auto-save is now handled by the sync service to avoid excessive I/O // Note: Auto-save is now handled by the sync service to avoid excessive I/O
} }
@@ -80,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)
@@ -99,6 +128,42 @@ export const useAppStore = defineStore('app', () => {
} }
} }
const moveMessage = (messageId: number, sourceChannelId: number, targetChannelId: number) => {
// Find and remove message from source channel
const sourceMessages = messages.value[sourceChannelId] || []
const messageIndex = sourceMessages.findIndex(m => m.id === messageId)
if (messageIndex === -1) {
console.warn(`Message ${messageId} not found in source channel ${sourceChannelId}`)
return
}
const message = sourceMessages[messageIndex]
if (!message) {
console.warn(`Message ${messageId} not found at index ${messageIndex}`)
return
}
sourceMessages.splice(messageIndex, 1)
// Update message's channel_id and add to target channel
const updatedMessage = { ...message, channel_id: targetChannelId }
if (!messages.value[targetChannelId]) {
messages.value[targetChannelId] = []
}
const targetMessages = messages.value[targetChannelId]
if (!targetMessages) return
targetMessages.push(updatedMessage)
// Keep chronological order in target channel
targetMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log(`Message ${messageId} moved from channel ${sourceChannelId} to ${targetChannelId}`)
}
const addUnsentMessage = (message: UnsentMessage) => { const addUnsentMessage = (message: UnsentMessage) => {
unsentMessages.value.push(message) unsentMessages.value.push(message)
} }
@@ -168,7 +233,9 @@ export const useAppStore = defineStore('app', () => {
setMessages, setMessages,
addMessage, addMessage,
updateMessage, updateMessage,
setMessageChecked,
removeMessage, removeMessage,
moveMessage,
addUnsentMessage, addUnsentMessage,
removeUnsentMessage, removeUnsentMessage,
updateSettings, updateSettings,

View File

@@ -4,27 +4,58 @@ import { get, set } from 'idb-keyval'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null) const token = ref<string | null>(null)
const serverUrl = ref<string | null>(null)
const isAuthenticated = ref(false) const isAuthenticated = ref(false)
const setToken = async (newToken: string) => { // Get default server URL based on environment
const getDefaultServerUrl = () => {
return import.meta.env.DEV ? 'http://localhost:3000' : ''
}
const setToken = async (newToken: string, customServerUrl?: string) => {
token.value = newToken token.value = newToken
isAuthenticated.value = true isAuthenticated.value = true
await set('auth_token', newToken)
// Set server URL or use default
const urlToUse = customServerUrl || getDefaultServerUrl()
serverUrl.value = urlToUse
// Save both token and server URL
await Promise.all([
set('auth_token', newToken),
set('server_url', urlToUse)
])
}
const setServerUrl = async (url: string) => {
serverUrl.value = url
await set('server_url', url)
} }
const clearAuth = async () => { const clearAuth = async () => {
token.value = null token.value = null
serverUrl.value = null
isAuthenticated.value = false isAuthenticated.value = false
await set('auth_token', null) await Promise.all([
set('auth_token', null),
set('server_url', null)
])
} }
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const storedToken = await get('auth_token') const [storedToken, storedServerUrl] = await Promise.all([
get('auth_token'),
get('server_url')
])
if (storedToken) { if (storedToken) {
// Set server URL or use default
const urlToUse = storedServerUrl || getDefaultServerUrl()
serverUrl.value = urlToUse
// Verify token with backend // Verify token with backend
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : '' const response = await fetch(`${urlToUse}/check-token`, {
const response = await fetch(`${baseUrl}/check-token`, {
headers: { Authorization: storedToken } headers: { Authorization: storedToken }
}) })
@@ -42,15 +73,15 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
const authenticate = async (authToken: string): Promise<boolean> => { const authenticate = async (authToken: string, customServerUrl?: string): Promise<boolean> => {
try { try {
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : '' const urlToUse = customServerUrl || getDefaultServerUrl()
const response = await fetch(`${baseUrl}/check-token`, { const response = await fetch(`${urlToUse}/check-token`, {
headers: { Authorization: authToken } headers: { Authorization: authToken }
}) })
if (response.ok) { if (response.ok) {
await setToken(authToken) await setToken(authToken, urlToUse)
return true return true
} else { } else {
await clearAuth() await clearAuth()
@@ -65,10 +96,13 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
token, token,
serverUrl,
isAuthenticated, isAuthenticated,
setToken, setToken,
setServerUrl,
clearAuth, clearAuth,
checkAuth, checkAuth,
authenticate authenticate,
getDefaultServerUrl
} }
}) })

View File

@@ -1,3 +1,26 @@
/* CSS Custom Properties for iOS Safe Areas and Dynamic Viewport */
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0);
--safe-area-inset-right: env(safe-area-inset-right, 0);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
--safe-area-inset-left: env(safe-area-inset-left, 0);
/* Dynamic viewport height that accounts for iOS Safari UI changes */
--vh-actual: 100vh;
--vh-small: 100vh; /* Fallback for browsers without svh support */
--vh-large: 100vh; /* Fallback for browsers without lvh support */
--vh-dynamic: 100vh; /* Fallback for browsers without dvh support */
/* Use newer viewport units where supported */
--vh-small: 100svh; /* Small viewport height - excludes browser UI */
--vh-large: 100lvh; /* Large viewport height - includes browser UI */
--vh-dynamic: 100dvh; /* Dynamic viewport height - changes with browser UI */
/* Header height calculations */
--header-base-height: 4rem; /* Base header height */
--header-total-height: calc(var(--header-base-height) + var(--safe-area-inset-top, 0px));
}
/* Minimal reset styles only */ /* Minimal reset styles only */
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -7,7 +30,7 @@ body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100vw; width: 100vw;
height: 100vh; height: var(--vh-dynamic, 100vh);
overflow: hidden; overflow: hidden;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
@@ -16,6 +39,9 @@ body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
/* iOS-specific optimizations */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
} }
#app { #app {
@@ -26,6 +52,26 @@ body {
overflow: hidden; overflow: hidden;
} }
/* iOS-specific touch and interaction optimizations */
* {
/* Disable callouts on iOS for better touch interactions */
-webkit-touch-callout: none;
/* Enable momentum scrolling globally for iOS */
-webkit-overflow-scrolling: touch;
}
/* Disable text selection only on UI elements, not form elements */
button, [role="button"], .no-select {
-webkit-user-select: none;
user-select: none;
}
/* Ensure text selection works in content and form areas */
input, textarea, [contenteditable="true"], .allow-select, p, span, div:not([role]), article, section {
-webkit-user-select: text;
user-select: text;
}
/* Accessibility helpers */ /* Accessibility helpers */
.sr-only { .sr-only {
position: absolute; position: absolute;

View File

@@ -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 {
@@ -72,6 +73,14 @@ export interface UnsentMessage {
content: string content: string
timestamp: number timestamp: number
retries: number retries: number
// File message support (for future offline retry capability)
messageType?: 'text' | 'voice' | 'image'
fileData?: {
blob: Blob
fileName: string
fileType: string
fileSize: number
}
} }
export interface AppSettings { export interface AppSettings {
@@ -84,6 +93,7 @@ export interface AppSettings {
selectedVoiceURI: string | null selectedVoiceURI: string | null
defaultChannelId: number | null defaultChannelId: number | null
theme: 'light' | 'dark' | 'auto' theme: 'light' | 'dark' | 'auto'
serverUrl?: string | null
} }
// Audio Types // Audio Types

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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)
}

View File

@@ -0,0 +1,94 @@
/**
* Smart timestamp formatting that shows appropriate level of detail based on message age
*/
export function formatSmartTimestamp(timestamp: string): string {
const now = new Date()
const date = new Date(timestamp)
// Handle invalid dates
if (isNaN(date.getTime())) {
return 'Invalid date'
}
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
// Same day (today)
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
// Yesterday
if (diffDays === 1) {
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `Yesterday ${timeStr}`
}
// This week (2-6 days ago)
if (diffDays <= 6) {
const dayStr = date.toLocaleDateString([], { weekday: 'short' })
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${dayStr} ${timeStr}`
}
// This year (more than a week ago)
if (now.getFullYear() === date.getFullYear()) {
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// Different year
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
/**
* Format timestamp for accessibility/screen readers with full context
*/
export function formatTimestampForScreenReader(timestamp: string): string {
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
// Same day
if (diffDays === 0) {
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `today at ${timeStr}`
}
// Yesterday
if (diffDays === 1) {
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `yesterday at ${timeStr}`
}
// This week
if (diffDays <= 6) {
const dayStr = date.toLocaleDateString([], { weekday: 'long' })
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${dayStr} at ${timeStr}`
}
// Older messages - use full date and time
return date.toLocaleDateString([], {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}

View File

@@ -7,6 +7,15 @@
</div> </div>
<form @submit.prevent="handleAuth" class="auth-form"> <form @submit.prevent="handleAuth" class="auth-form">
<BaseInput
v-model="serverUrl"
ref="serverInput"
type="url"
label="Server URL (optional)"
:placeholder="defaultServerUrl"
:disabled="isLoading"
/>
<BaseInput <BaseInput
v-model="token" v-model="token"
type="password" type="password"
@@ -47,9 +56,13 @@ const toastStore = useToastStore()
const { playSound } = useAudio() const { playSound } = useAudio()
const token = ref('') const token = ref('')
const serverUrl = ref('')
const error = ref('') const error = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const tokenInput = ref() const tokenInput = ref()
const serverInput = ref()
// Get default server URL for placeholder
const defaultServerUrl = authStore.getDefaultServerUrl()
const handleAuth = async () => { const handleAuth = async () => {
if (!token.value.trim()) return if (!token.value.trim()) return
@@ -58,18 +71,20 @@ const handleAuth = async () => {
error.value = '' error.value = ''
try { try {
const success = await authStore.authenticate(token.value.trim()) // Use custom server URL if provided, otherwise use default
const customUrl = serverUrl.value.trim() || undefined
const success = await authStore.authenticate(token.value.trim(), customUrl)
if (success) { if (success) {
await playSound('login') await playSound('login')
toastStore.success('Authentication successful!') toastStore.success('Authentication successful!')
router.push('/') router.push('/')
} else { } else {
error.value = 'Invalid authentication token' error.value = 'Invalid authentication token or server URL'
tokenInput.value?.focus() serverInput.value?.focus()
} }
} catch (err) { } catch (err) {
error.value = 'Authentication failed. Please try again.' error.value = 'Authentication failed. Please check your token and server URL.'
console.error('Auth error:', err) console.error('Auth error:', err)
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -77,14 +92,14 @@ const handleAuth = async () => {
} }
onMounted(() => { onMounted(() => {
tokenInput.value?.focus() serverInput.value?.focus()
playSound('intro') playSound('intro')
}) })
</script> </script>
<style scoped> <style scoped>
.auth-view { .auth-view {
height: 100vh; height: var(--vh-dynamic, 100vh);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -36,6 +36,7 @@
@select-channel="(id) => { selectChannel(id); sidebarOpen = false }" @select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
@channel-info="handleChannelInfo" @channel-info="handleChannelInfo"
@settings="showSettings = true" @settings="showSettings = true"
@close="sidebarOpen = false"
/> />
<!-- Main Content --> <!-- Main Content -->
@@ -53,6 +54,9 @@
:messages="appStore.currentMessages" :messages="appStore.currentMessages"
:unsent-messages="appStore.unsentMessagesForChannel" :unsent-messages="appStore.unsentMessagesForChannel"
ref="messagesContainer" ref="messagesContainer"
@open-message-dialog="handleOpenMessageDialog"
@open-message-dialog-edit="handleOpenMessageDialogEdit"
@open-links="handleOpenLinks"
/> />
<!-- Message Input --> <!-- Message Input -->
@@ -61,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>
@@ -117,6 +123,26 @@
@close="showChannelInfoDialog = false" @close="showChannelInfoDialog = false"
/> />
</BaseDialog> </BaseDialog>
<BaseDialog v-model:show="showMessageDialog" title="">
<MessageDialog
v-if="selectedMessage"
:message="selectedMessage"
:open="showMessageDialog"
:start-editing="shouldStartEditing"
@close="handleCloseMessageDialog"
@edit="handleEditMessage"
@delete="handleDeleteMessage"
@move="handleMoveMessage"
/>
</BaseDialog>
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
<LinkSelectionDialog
:links="selectedLinks"
@close="showLinkDialog = false"
/>
</BaseDialog>
</div> </div>
</template> </template>
@@ -130,6 +156,7 @@ import { useOfflineSync } from '@/composables/useOfflineSync'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useAudio } from '@/composables/useAudio' import { useAudio } from '@/composables/useAudio'
import { formatTimestampForScreenReader } from '@/utils/time'
import { apiService } from '@/services/api' import { apiService } from '@/services/api'
import { syncService } from '@/services/sync' import { syncService } from '@/services/sync'
@@ -147,9 +174,11 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue' 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 LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue'
// Types // Types
import type { ExtendedMessage, Channel } from '@/types' import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
@@ -158,10 +187,13 @@ const toastStore = useToastStore()
const { sendMessage: sendMessageOffline } = useOfflineSync() const { sendMessage: sendMessageOffline } = useOfflineSync()
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio() const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
// Set up services - ensure token is properly set // Set up services - ensure token and URL are properly set
if (authStore.token) { if (authStore.token) {
apiService.setToken(authStore.token) apiService.setToken(authStore.token)
} }
if (authStore.serverUrl) {
apiService.setBaseUrl(authStore.serverUrl)
}
// Refs // Refs
const messagesContainer = ref() const messagesContainer = ref()
@@ -174,7 +206,12 @@ const showSettings = ref(false)
const showSearchDialog = ref(false) const showSearchDialog = ref(false)
const showFileDialog = ref(false) const showFileDialog = ref(false)
const showVoiceDialog = ref(false) const showVoiceDialog = 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 shouldStartEditing = ref(false)
const selectedLinks = ref<string[]>([])
// Mobile sidebar state // Mobile sidebar state
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
@@ -205,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
@@ -276,6 +312,21 @@ const setupKeyboardShortcuts = () => {
} }
}) })
// Shift+Enter - Open message dialog for focused message
addShortcut({
key: 'enter',
shiftKey: true,
handler: () => {
const focusedMessage = messagesContainer.value?.getFocusedMessage()
if (focusedMessage) {
handleOpenMessageDialog(focusedMessage)
toastStore.info('Opening message dialog')
} else {
toastStore.info('No message is focused')
}
}
})
// Alt+Numbers - Announce last N messages // Alt+Numbers - Announce last N messages
for (let i = 1; i <= 9; i++) { for (let i = 1; i <= 9; i++) {
addShortcut({ addShortcut({
@@ -293,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)
@@ -306,6 +402,11 @@ const selectChannel = async (channelId: number) => {
} }
scrollToBottom() scrollToBottom()
// Auto-focus message input when switching channels
nextTick(() => {
messageInput.value?.focus()
})
} }
const handleSendMessage = async (content: string) => { const handleSendMessage = async (content: string) => {
@@ -356,9 +457,7 @@ const handleSelectMessage = async (message: ExtendedMessage) => {
} }
} }
const formatTime = (timestamp: string): string => { // formatTime function removed - now using formatTimestampForScreenReader from utils
return new Date(timestamp).toLocaleTimeString()
}
const handleVoiceSent = () => { const handleVoiceSent = () => {
// Voice message was sent successfully // Voice message was sent successfully
@@ -388,8 +487,13 @@ const announceLastMessage = (position: number) => {
} }
const message = messages[messageIndex] const message = messages[messageIndex]
const timeStr = formatTime(message.created_at) if (!message) {
const announcement = `${message.content}; ${timeStr}` toastStore.info('No message is available in this position')
return
}
const timeStr = formatTimestampForScreenReader(message.created_at)
const announcement = `${message.content}; sent ${timeStr}`
toastStore.info(announcement) toastStore.info(announcement)
@@ -403,6 +507,104 @@ const scrollToBottom = () => {
messagesContainer.value?.scrollToBottom() messagesContainer.value?.scrollToBottom()
} }
// Message dialog handlers
const handleOpenMessageDialog = (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 = 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
}
}
const handleCloseMessageDialog = () => {
showMessageDialog.value = false
selectedMessage.value = null
shouldStartEditing.value = false
}
const handleEditMessage = async (messageId: number, content: string) => {
try {
if (!appStore.currentChannelId) return
const response = await apiService.updateMessage(appStore.currentChannelId, messageId, content)
// Update the message in the local store
const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) {
const updatedMessage = { ...appStore.currentMessages[messageIndex], content: content }
appStore.updateMessage(messageId, updatedMessage)
}
// Update the selected message for the dialog
if (selectedMessage.value && selectedMessage.value.id === messageId) {
selectedMessage.value = { ...selectedMessage.value, content: content }
}
toastStore.success('Message updated successfully')
handleCloseMessageDialog()
} catch (error) {
console.error('Failed to edit message:', error)
toastStore.error('Failed to update message')
}
}
const handleDeleteMessage = async (messageId: number) => {
try {
if (!appStore.currentChannelId) return
await apiService.deleteMessage(appStore.currentChannelId, messageId)
// Remove the message from the local store
const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) {
appStore.currentMessages.splice(messageIndex, 1)
}
toastStore.success('Message deleted successfully')
handleCloseMessageDialog()
} catch (error) {
console.error('Failed to delete message:', error)
toastStore.error('Failed to delete message')
}
}
const handleMoveMessage = async (messageId: number, targetChannelId: number) => {
try {
if (!appStore.currentChannelId) return
// Find the source channel for the message
let sourceChannelId = appStore.currentChannelId
const currentMessage = appStore.currentMessages.find(m => m.id === messageId)
if (currentMessage) {
sourceChannelId = currentMessage.channel_id
}
await apiService.moveMessage(sourceChannelId, messageId, targetChannelId)
// Optimistically update local state
appStore.moveMessage(messageId, sourceChannelId, targetChannelId)
toastStore.success('Message moved successfully')
handleCloseMessageDialog()
} catch (error) {
console.error('Failed to move message:', error)
toastStore.error('Failed to move message')
}
}
const handleChannelCreated = async (channelId: number) => { const handleChannelCreated = async (channelId: number) => {
showChannelDialog.value = false showChannelDialog.value = false
await selectChannel(channelId) await selectChannel(channelId)
@@ -417,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)
@@ -446,10 +657,18 @@ 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. Set up periodic sync for unsent messages // 6. Auto-focus message input on page load
nextTick(() => {
messageInput.value?.focus()
})
// 7. Set up periodic sync for unsent messages
const syncInterval = setInterval(async () => { const syncInterval = setInterval(async () => {
if (appStore.unsentMessages.length > 0) { if (appStore.unsentMessages.length > 0) {
try { try {
@@ -470,7 +689,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.main-view { .main-view {
display: flex; display: flex;
height: 100vh; height: var(--vh-dynamic, 100vh);
background: #ffffff; background: #ffffff;
} }
@@ -512,20 +731,34 @@ onMounted(async () => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem; padding: 1rem;
padding-top: calc(1rem + var(--safe-area-inset-top));
padding-left: calc(1rem + var(--safe-area-inset-left));
padding-right: calc(1rem + var(--safe-area-inset-right));
background: #f9fafb; background: #f9fafb;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
position: sticky; position: fixed;
top: 0; top: 0;
z-index: 100; left: 0;
right: 0;
z-index: 500; /* Higher than sidebar to prevent conflicts */
} }
.mobile-menu-button, .mobile-menu-button,
.mobile-search-button { .mobile-search-button {
background: none; background: none;
border: none; border: none;
padding: 0.5rem; padding: 0.75rem;
cursor: pointer; cursor: pointer;
color: #6b7280; color: #6b7280;
min-height: 2.75rem; /* 44px minimum for iOS */
min-width: 2.75rem;
display: flex;
align-items: center;
justify-content: center;
/* iOS-specific optimizations */
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
} }
.mobile-menu-button:hover, .mobile-menu-button:hover,
@@ -555,7 +788,7 @@ onMounted(async () => {
@media (max-width: 768px) { @media (max-width: 768px) {
.main-view { .main-view {
flex-direction: column; flex-direction: column;
height: 100vh; height: var(--vh-dynamic, 100vh);
} }
.mobile-header { .mobile-header {
@@ -567,14 +800,16 @@ onMounted(async () => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100vh; height: var(--vh-dynamic, 100vh);
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease; transition: transform 0.3s ease, visibility 0.3s ease;
z-index: 300; z-index: 400; /* Lower than mobile header but higher than overlay */
visibility: hidden; /* Completely hide when closed */
} }
.sidebar.sidebar-open { .sidebar.sidebar-open {
transform: translateX(0); transform: translateX(0);
visibility: visible;
} }
.sidebar-overlay { .sidebar-overlay {
@@ -584,6 +819,7 @@ onMounted(async () => {
.main-content { .main-content {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
padding-top: var(--header-total-height); /* Account for fixed header height with safe area */
} }
.chat-container { .chat-container {

View File

@@ -40,7 +40,8 @@ export default defineConfig({
} }
}, },
server: { server: {
port: 5173 port: 5173,
allowedHosts: true
}, },
build: { build: {
outDir: 'dist' outDir: 'dist'

View File

@@ -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>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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