Compare commits
26 Commits
864f0a5a45
...
feat/new-f
| Author | SHA1 | Date | |
|---|---|---|---|
| d786a7463b | |||
| fca1046047 | |||
| 221aa1c2af | |||
| bfe77ae86a | |||
| 181ae28548 | |||
| fab05f32ec | |||
| ec1a2ba7f0 | |||
| 64f0f55d10 | |||
| 60c2a18dbe | |||
| 6286c1e0c9 | |||
| 28f6fad818 | |||
| 5c76c35d81 | |||
| 2b1bf5040f | |||
| 22b8392fd5 | |||
| 9948d1c25b | |||
| cf15a0f9c2 | |||
| 452192d0a9 | |||
| 0d50359dae | |||
| 420ff46f05 | |||
| b312065d3d | |||
| b07916309e | |||
| 6585ec2abb | |||
| 4dcacd0d73 | |||
| f2ac7d7209 | |||
| fa1cbdf97e | |||
| 8c0f8c6b44 |
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `frontend-vue/` – Vue 3 + Vite app. Key folders: `src/components`, `src/views`, `src/stores`, `src/services`, `src/composables`, `src/router`, and static assets in `public/`.
|
||||
- `backend/` – TypeScript API + WebSocket server. Key folders: `src/controllers`, `src/routes`, `src/services`, `src/utils`, `src/logging`, `src/jobs`. Database files in `schema.sql` and `migrations/`.
|
||||
- `etc/systemd/` – example unit files for deployment. `dockerfile` – container build (optional).
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Frontend
|
||||
- `cd frontend-vue && npm install`
|
||||
- `npm run dev` – start Vite dev server.
|
||||
- `npm run build` – type-check + production build.
|
||||
- `npm run preview` – preview production build.
|
||||
- `npm run lint` / `npm run format` – ESLint / Prettier.
|
||||
- Backend
|
||||
- `cd backend && npm install`
|
||||
- `npm run dev` – start server with `tsx --watch`.
|
||||
- `npm run start` – start server once (prod-like).
|
||||
- Note: backend was initialized with Bun; Node/npm + tsx is the primary flow.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- General: TypeScript, 2-space indent, small focused modules. File names: lowercase-kebab for modules (e.g., `message-service.ts`), `PascalCase.vue` for Vue SFCs.
|
||||
- Frontend: Prettier + ESLint enforced; prefer single quotes; composition API; component names in `PascalCase`.
|
||||
- Backend: Keep semicolons and consistent import quoting (matches current files). Use `PascalCase` for types/classes, `camelCase` for variables/functions.
|
||||
|
||||
## Testing Guidelines
|
||||
- No formal test runner is configured yet. If adding tests:
|
||||
- Place unit tests alongside code as `*.spec.ts` or in a `__tests__/` directory.
|
||||
- Keep tests fast and deterministic; document manual verification steps in PRs until a runner is introduced.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: imperative mood, concise subject (<=72 chars), include scope prefix when helpful, e.g. `frontend: fix message input blur` or `backend: add channel search`.
|
||||
- PRs must include: clear description, linked issue (if any), screenshots/GIFs for UI changes, manual test steps, and notes on migrations if touching `backend/migrations/`.
|
||||
- Ensure `npm run lint` (frontend) passes and the app runs locally for both services before requesting review.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Backend reads environment from `.env` (see `backend/src/config.ts`): important keys include `DB_PATH`, `API_TOKEN`, `UPLOAD_DIR`, `PORT`, `USE_SSL`, `OPENAI_API_KEY`, `OLLAMA_URL`, and related model settings.
|
||||
- Do not commit secrets. Provide `.env` examples in PRs when adding new variables.
|
||||
- For local SSL, set `USE_SSL=1` and supply `SSL_KEY/SSL_CERT`, or let the server generate a self-signed pair for development.
|
||||
|
||||
3
backend/migrations/3_checked.sql
Normal file
3
backend/migrations/3_checked.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add tri-state checked column to messages (NULL | 0 | 1)
|
||||
ALTER TABLE messages ADD COLUMN checked INTEGER NULL;
|
||||
|
||||
@@ -13,19 +13,20 @@ CREATE TABLE IF NOT EXISTS files (
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channelId INTEGER,
|
||||
content TEXT,
|
||||
fileId INTEGER NULL,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
|
||||
SET
|
||||
NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channelId INTEGER,
|
||||
content TEXT,
|
||||
fileId INTEGER NULL,
|
||||
checked INTEGER NULL,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
|
||||
SET
|
||||
NULL
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
content = 'messages',
|
||||
content_rowid = 'id'
|
||||
);
|
||||
);
|
||||
|
||||
@@ -18,7 +18,16 @@ export const uploadFile = async (req: Request, res: Response) => {
|
||||
|
||||
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
|
||||
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
|
||||
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType });
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,4 +51,47 @@ export const getMessages = async (req: Request, res: Response) => {
|
||||
const messages = await MessageService.getMessages(channelId);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => {
|
||||
events.on('message-deleted', (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) => {
|
||||
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { DB_PATH } from './config';
|
||||
import { logger } from './globals';
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
export let FTS5Enabled = true;
|
||||
|
||||
@@ -55,13 +56,25 @@ export const migrate = async () => {
|
||||
logger.info(`Migrations done`);
|
||||
}
|
||||
|
||||
logger.info(`Loading database at ${DB_PATH}`);
|
||||
|
||||
export const db = new Database(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);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
initializeDB();
|
||||
migrate();
|
||||
migrate();
|
||||
|
||||
@@ -4,8 +4,10 @@ import { authenticate } from '../middleware/auth';
|
||||
|
||||
export const router = Router({mergeParams: true});
|
||||
|
||||
router.post('/', authenticate, MessageController.createMessage);
|
||||
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
||||
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
||||
router.get('/', authenticate, MessageController.getMessages);
|
||||
router.post('/', authenticate, MessageController.createMessage);
|
||||
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.get('/', authenticate, MessageController.getMessages);
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@ export const uploadFile = async (channelId: string, messageId: string, filePath:
|
||||
const result2 = updateQuery.run({ fileId: fileId, messageId: messageId });
|
||||
|
||||
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
|
||||
return result2; ''
|
||||
return result;
|
||||
}
|
||||
|
||||
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 });
|
||||
return rows;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { db, FTS5Enabled } from "../db";
|
||||
import { events } from "../globals";
|
||||
|
||||
export const createMessage = async (channelId: string, content: string) => {
|
||||
const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
|
||||
const result = query.run({ channelId: channelId, content: content });
|
||||
export const createMessage = async (channelId: string, content: string) => {
|
||||
const query = db.prepare(`INSERT INTO messages (channelId, content, checked) VALUES ($channelId, $content, NULL)`);
|
||||
const result = query.run({ channelId: channelId, content: content });
|
||||
|
||||
const messageId = result.lastInsertRowid;
|
||||
console.log(`Adding message for search with id ${messageId}`);
|
||||
@@ -17,9 +17,9 @@ export const createMessage = async (channelId: string, content: string) => {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
|
||||
const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`);
|
||||
const result = query.run({ content: content, id: messageId });
|
||||
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
|
||||
const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`);
|
||||
const result = query.run({ content: content, id: messageId });
|
||||
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ export const deleteMessage = async (messageId: string) => {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const getMessages = async (channelId: string) => {
|
||||
const query = db.prepare(`
|
||||
SELECT
|
||||
messages.id, messages.channelId, messages.content, messages.createdAt,
|
||||
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||
FROM
|
||||
messages
|
||||
export const getMessages = async (channelId: string) => {
|
||||
const query = db.prepare(`
|
||||
SELECT
|
||||
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
|
||||
FROM
|
||||
messages
|
||||
LEFT JOIN
|
||||
files
|
||||
ON
|
||||
@@ -61,16 +61,16 @@ export const getMessages = async (channelId: string) => {
|
||||
messages.channelId = $channelId
|
||||
`);
|
||||
const rows = query.all({ channelId: channelId });
|
||||
return rows;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const getMessage = async (id: string) => {
|
||||
const query = db.prepare(`
|
||||
SELECT
|
||||
messages.id, messages.channelId, messages.content, messages.createdAt,
|
||||
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
|
||||
FROM
|
||||
messages
|
||||
export const getMessage = async (id: string) => {
|
||||
const query = db.prepare(`
|
||||
SELECT
|
||||
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
|
||||
FROM
|
||||
messages
|
||||
LEFT JOIN
|
||||
files
|
||||
ON
|
||||
@@ -79,5 +79,38 @@ export const getMessage = async (id: string) => {
|
||||
messages.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;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ export interface Channel {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
export interface Message {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
checked?: boolean | null;
|
||||
}
|
||||
|
||||
export interface File {
|
||||
id: number;
|
||||
@@ -18,4 +19,4 @@ export interface File {
|
||||
file_path: string;
|
||||
file_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
}
|
||||
|
||||
228
frontend-vue/-
Normal file
228
frontend-vue/-
Normal 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>
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<meta name="description" content="Light note taking app in messenger style">
|
||||
</head>
|
||||
<body>
|
||||
<body role="application">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -25,7 +25,7 @@ const toastStore = useToastStore()
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
interface Props {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
ariaLabel?: string
|
||||
@@ -46,7 +46,9 @@ const emit = defineEmits<{
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
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>
|
||||
@@ -65,6 +67,12 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
transition: all 0.2s ease;
|
||||
outline: 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 {
|
||||
@@ -78,19 +86,32 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
min-height: 2.75rem; /* 44px minimum for iOS touch targets */
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.base-button--md {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.base-button--lg {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
min-height: 3rem;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
@@ -126,6 +147,19 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
.base-button--ghost {
|
||||
background-color: transparent;
|
||||
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) {
|
||||
@@ -170,4 +204,4 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
@keydown.esc="handleClose"
|
||||
role="dialog"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="contentId"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
@@ -17,6 +16,7 @@
|
||||
'dialog',
|
||||
`dialog--${size}`
|
||||
]"
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dialog__header" v-if="$slots.header || title">
|
||||
@@ -88,6 +88,12 @@ const handleOverlayClick = () => {
|
||||
let lastFocusedElement: HTMLElement | null = null
|
||||
|
||||
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
|
||||
|
||||
const focusableElements = dialogRef.value?.querySelectorAll(
|
||||
@@ -120,16 +126,24 @@ watch(() => props.show, async (isVisible) => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Focus first focusable element or the dialog itself
|
||||
const firstFocusable = dialogRef.value?.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
) as HTMLElement
|
||||
|
||||
// Focus [autofocus] first, then first focusable, else the dialog itself
|
||||
const root = dialogRef.value as HTMLElement | undefined
|
||||
const selector = '[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
const firstFocusable = root?.querySelector(selector) as HTMLElement | null
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus()
|
||||
} 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 {
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', trapFocus)
|
||||
@@ -276,4 +290,4 @@ watch(() => props.show, async (isVisible) => {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -157,6 +157,9 @@ defineExpose({
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 3rem;
|
||||
/* iOS-specific optimizations */
|
||||
-webkit-appearance: none;
|
||||
-webkit-border-radius: 8px;
|
||||
}
|
||||
|
||||
.base-textarea__field:focus {
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (!props.file.original_name) return ''
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="input-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
@click="$emit('file-upload')"
|
||||
aria-label="Upload file"
|
||||
:disabled="disabled"
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
@click="$emit('camera')"
|
||||
aria-label="Take photo"
|
||||
:disabled="disabled"
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
@click="$emit('voice')"
|
||||
aria-label="Record voice message"
|
||||
:disabled="disabled"
|
||||
@@ -30,6 +30,16 @@
|
||||
🎤
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="$emit('toggle-check')"
|
||||
aria-label="Toggle check on focused message"
|
||||
:disabled="disabled"
|
||||
>
|
||||
✓
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -59,6 +69,7 @@ defineEmits<{
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
'toggle-check': []
|
||||
'send': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -67,7 +78,13 @@ defineEmits<{
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem; /* Reduced gap to save space */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* Mobile-only for the checked toggle button */
|
||||
.input-actions [aria-label="Toggle check on focused message"] { display: none; }
|
||||
@media (max-width: 480px) {
|
||||
.input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
@file-upload="$emit('file-upload')"
|
||||
@camera="$emit('camera')"
|
||||
@voice="$emit('voice')"
|
||||
@toggle-check="$emit('toggle-check')"
|
||||
@send="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
@@ -35,6 +36,7 @@ const emit = defineEmits<{
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
'toggle-check': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -76,6 +78,7 @@ defineExpose({
|
||||
<style scoped>
|
||||
.message-input-container {
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(1rem + var(--safe-area-inset-bottom));
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
@@ -83,10 +86,35 @@ defineExpose({
|
||||
.message-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem; /* Reduced gap to save space */
|
||||
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 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message-input-container {
|
||||
@@ -94,4 +122,4 @@ defineExpose({
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
'message',
|
||||
{ 'message--unsent': isUnsent }
|
||||
]"
|
||||
ref="rootEl"
|
||||
:data-message-id="message.id"
|
||||
:tabindex="tabindex || 0"
|
||||
:tabindex="tabindex ?? -1"
|
||||
:aria-label="messageAriaLabel"
|
||||
role="listitem"
|
||||
role="option"
|
||||
@keydown="handleKeydown"
|
||||
@click="handleClick"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<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 }}
|
||||
</div>
|
||||
|
||||
@@ -20,8 +25,22 @@
|
||||
</div>
|
||||
|
||||
<div class="message__meta">
|
||||
<time v-if="!isUnsent && 'created_at' in message" class="message__time">
|
||||
{{ formatTime(message.created_at) }}
|
||||
<button
|
||||
class="message__toggle"
|
||||
type="button"
|
||||
:aria-label="toggleAriaLabel"
|
||||
@click.stop="toggleChecked()"
|
||||
>
|
||||
<span v-if="isChecked === true">Uncheck</span>
|
||||
<span v-else-if="isChecked === false">Check</span>
|
||||
<span v-else>Check</span>
|
||||
</button>
|
||||
<time
|
||||
v-if="!isUnsent && 'created_at' in message"
|
||||
class="message__time"
|
||||
:datetime="message.created_at"
|
||||
>
|
||||
{{ formatSmartTimestamp(message.created_at) }}
|
||||
</time>
|
||||
<span v-else class="message__status">Sending...</span>
|
||||
</div>
|
||||
@@ -29,10 +48,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
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 type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
|
||||
|
||||
@@ -42,6 +64,12 @@ interface Props {
|
||||
tabindex?: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||
'focus': []
|
||||
}>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isUnsent: false
|
||||
})
|
||||
@@ -52,34 +80,67 @@ const { speak, playSound } = useAudio()
|
||||
const toastStore = useToastStore()
|
||||
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
|
||||
const hasFileAttachment = computed(() => {
|
||||
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
|
||||
const fileAttachment = computed((): FileAttachmentType | 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 {
|
||||
id: props.message.fileId!,
|
||||
channel_id: props.message.channel_id,
|
||||
message_id: props.message.id,
|
||||
file_path: props.message.filePath!,
|
||||
file_type: props.message.fileType!,
|
||||
file_size: props.message.fileSize!,
|
||||
file_type: props.message.fileType || 'application/octet-stream',
|
||||
file_size: props.message.fileSize || 0,
|
||||
original_name: props.message.originalName!,
|
||||
created_at: props.message.fileCreatedAt || props.message.created_at
|
||||
}
|
||||
})
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||
|
||||
// Create comprehensive aria-label for screen readers
|
||||
const messageAriaLabel = computed(() => {
|
||||
let prefix = ''
|
||||
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
|
||||
if (props.message.content) {
|
||||
@@ -95,8 +156,8 @@ const messageAriaLabel = computed(() => {
|
||||
|
||||
// Add timestamp
|
||||
if ('created_at' in props.message && props.message.created_at) {
|
||||
const time = formatTime(props.message.created_at)
|
||||
label += `. Sent at ${time}`
|
||||
const time = formatTimestampForScreenReader(props.message.created_at)
|
||||
label += `. Sent ${time}`
|
||||
}
|
||||
|
||||
// Add status for unsent messages
|
||||
@@ -104,7 +165,7 @@ const messageAriaLabel = computed(() => {
|
||||
label += '. Message is sending'
|
||||
}
|
||||
|
||||
return label
|
||||
return `${prefix}${label}`.trim()
|
||||
})
|
||||
|
||||
// Helper to determine file type for better description
|
||||
@@ -127,17 +188,36 @@ const getFileType = (filename: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Only open dialog for sent messages (not unsent ones)
|
||||
if (!props.isUnsent) {
|
||||
emit('open-dialog', props.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
if (event.key === ' ' || event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleChecked()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'c') {
|
||||
// Copy message content (only when no modifiers are pressed)
|
||||
navigator.clipboard.writeText(props.message.content)
|
||||
playSound('copy')
|
||||
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') {
|
||||
// Read message aloud (only when no modifiers are pressed)
|
||||
if (appStore.settings.ttsEnabled) {
|
||||
@@ -146,8 +226,113 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
} else {
|
||||
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 'Mark as checked'
|
||||
return 'Mark as checked'
|
||||
})
|
||||
|
||||
const toggleChecked = async () => {
|
||||
if (props.isUnsent) return
|
||||
const msg = props.message as ExtendedMessage
|
||||
const next = isChecked.value !== true
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -157,10 +342,14 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: #f1f3f4;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.message:focus {
|
||||
@@ -206,6 +395,31 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
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) {
|
||||
.message {
|
||||
background: #2d3748;
|
||||
@@ -215,6 +429,8 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
.message:hover {
|
||||
background: #374151;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.message__content {
|
||||
@@ -225,4 +441,5 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
color: #a0aec0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
class="messages-container"
|
||||
ref="containerRef"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
role="list"
|
||||
:aria-label="messagesAriaLabel"
|
||||
:aria-description="navigationHint"
|
||||
>
|
||||
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" @focusin="handleFocusIn" tabindex="-1" role="listbox"
|
||||
:aria-label="messagesAriaLabel">
|
||||
<div class="messages" role="presentation">
|
||||
<!-- Regular Messages -->
|
||||
<MessageItem
|
||||
v-for="(message, index) in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:tabindex="index === focusedMessageIndex ? 0 : -1"
|
||||
:data-message-index="index"
|
||||
<MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
|
||||
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
|
||||
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
||||
@focus="focusedMessageIndex = index"
|
||||
/>
|
||||
|
||||
@open-dialog="emit('open-message-dialog', $event)"
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
||||
|
||||
<!-- Unsent Messages -->
|
||||
<MessageItem
|
||||
v-for="(unsentMsg, index) in unsentMessages"
|
||||
:key="unsentMsg.id"
|
||||
:message="unsentMsg"
|
||||
:is-unsent="true"
|
||||
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||
:data-message-index="messages.length + index"
|
||||
@focus="focusedMessageIndex = messages.length + index"
|
||||
/>
|
||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
||||
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
||||
@open-dialog="emit('open-message-dialog', $event)"
|
||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,6 +33,8 @@ interface Props {
|
||||
|
||||
const emit = defineEmits<{
|
||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -60,13 +50,13 @@ const totalMessages = computed(() => allMessages.value.length)
|
||||
const messagesAriaLabel = computed(() => {
|
||||
const total = totalMessages.value
|
||||
const current = focusedMessageIndex.value + 1
|
||||
|
||||
|
||||
if (total === 0) {
|
||||
return 'Messages list, no messages'
|
||||
} else if (total === 1) {
|
||||
return 'Messages list, 1 message'
|
||||
} else {
|
||||
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
|
||||
return `Messages list, ${total} messages`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,55 +65,71 @@ const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 mess
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
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) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
||||
newIndex = Math.max(0, currentIndex - 1)
|
||||
break
|
||||
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
||||
newIndex = Math.min(totalMessages.value - 1, currentIndex + 1)
|
||||
break
|
||||
|
||||
|
||||
case 'PageUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
||||
newIndex = Math.max(0, currentIndex - 10)
|
||||
break
|
||||
|
||||
|
||||
case 'PageDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
|
||||
newIndex = Math.min(totalMessages.value - 1, currentIndex + 10)
|
||||
break
|
||||
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
newIndex = 0
|
||||
break
|
||||
|
||||
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
newIndex = totalMessages.value - 1
|
||||
break
|
||||
|
||||
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
selectCurrentMessage()
|
||||
return
|
||||
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (newIndex !== focusedMessageIndex.value) {
|
||||
focusMessage(newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
focusedMessageIndex.value = index
|
||||
nextTick(() => {
|
||||
@@ -150,6 +156,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 = () => {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
@@ -158,19 +187,46 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for new messages and auto-scroll
|
||||
watch(() => [props.messages.length, props.unsentMessages.length], () => {
|
||||
// When new messages arrive, focus the last message and scroll to bottom
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
// Watch for list length changes
|
||||
// - If items were added, move focus to the newest and scroll to bottom.
|
||||
// - If items were removed, keep current index when possible; otherwise clamp.
|
||||
watch(
|
||||
() => [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
|
||||
watch(() => totalMessages.value, (newTotal) => {
|
||||
if (focusedMessageIndex.value >= newTotal) {
|
||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
||||
watch(() => totalMessages.value, (newTotal, oldTotal) => {
|
||||
if (newTotal === 0) return
|
||||
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 +234,22 @@ onMounted(() => {
|
||||
scrollToBottom()
|
||||
// Focus the last message on mount
|
||||
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]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
focusMessageById
|
||||
focusMessageById,
|
||||
getFocusedMessage
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -194,6 +259,12 @@ defineExpose({
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
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 {
|
||||
@@ -230,21 +301,21 @@ defineExpose({
|
||||
.messages-container {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
|
||||
.messages-container:focus {
|
||||
outline-color: #60a5fa;
|
||||
}
|
||||
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -280,9 +280,13 @@ const sendPhoto = async () => {
|
||||
// Upload photo
|
||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||
|
||||
// Immediately update the local message with file metadata
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
// Create complete message with file metadata
|
||||
const completeMessage = {
|
||||
id: message.id,
|
||||
channel_id: appStore.currentChannelId!,
|
||||
content: message.content,
|
||||
created_at: message.created_at,
|
||||
file_id: uploadedFile.id,
|
||||
fileId: uploadedFile.id,
|
||||
filePath: uploadedFile.file_path,
|
||||
fileType: uploadedFile.file_type,
|
||||
@@ -291,8 +295,8 @@ const sendPhoto = async () => {
|
||||
fileCreatedAt: uploadedFile.created_at
|
||||
}
|
||||
|
||||
// Update the message in the store
|
||||
appStore.updateMessage(message.id, updatedMessage)
|
||||
// Add the complete message to the store (this will trigger immediate UI update)
|
||||
appStore.addMessage(completeMessage)
|
||||
|
||||
toastStore.success('Photo sent!')
|
||||
emit('sent')
|
||||
|
||||
726
frontend-vue/src/components/dialogs/MessageDialog.vue
Normal file
726
frontend-vue/src/components/dialogs/MessageDialog.vue
Normal 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>
|
||||
@@ -58,7 +58,7 @@
|
||||
{{ result.content }}
|
||||
</div>
|
||||
<div class="result-time">
|
||||
{{ formatTime(result.created_at) }}
|
||||
{{ formatSmartTimestamp(result.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +79,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import { formatSmartTimestamp } from '@/utils/time'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import type { Message, ExtendedMessage } from '@/types'
|
||||
@@ -140,16 +141,7 @@ const getChannelName = (channelId: number): string => {
|
||||
return channel?.name || `Channel ${channelId}`
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
if (!timestamp) return 'Unknown time'
|
||||
|
||||
const date = new Date(timestamp)
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid date'
|
||||
}
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value?.focus()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<label class="setting-item">
|
||||
<input
|
||||
ref="soundInput"
|
||||
type="checkbox"
|
||||
v-model="localSettings.soundEnabled"
|
||||
class="checkbox"
|
||||
@@ -145,6 +146,37 @@
|
||||
</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">
|
||||
<BaseButton
|
||||
type="button"
|
||||
@@ -161,14 +193,42 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { clear } from 'idb-keyval'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import type { AppSettings } from '@/types'
|
||||
|
||||
@@ -176,12 +236,20 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { availableVoices, speak, setVoice } = useAudio()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const showResetConfirm = ref(false)
|
||||
const selectedVoiceURI = ref('')
|
||||
const soundInput = ref()
|
||||
|
||||
// Computed property for current server URL
|
||||
const currentServerUrl = computed(() => authStore.serverUrl)
|
||||
const localSettings = reactive<AppSettings>({
|
||||
soundEnabled: true,
|
||||
speechEnabled: true,
|
||||
@@ -229,12 +297,50 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Copy current settings to local state
|
||||
Object.assign(localSettings, appStore.settings)
|
||||
|
||||
// Set up voice selection
|
||||
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
|
||||
soundInput.value.focus();
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -340,6 +446,63 @@ onMounted(() => {
|
||||
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 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.setting-group h3 {
|
||||
@@ -360,5 +523,25 @@ onMounted(() => {
|
||||
.form-actions {
|
||||
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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -188,9 +188,13 @@ const sendVoiceMessage = async () => {
|
||||
// Upload voice file
|
||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||
|
||||
// Immediately update the local message with file metadata
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
// Create complete message with file metadata
|
||||
const completeMessage = {
|
||||
id: message.id,
|
||||
channel_id: appStore.currentChannelId!,
|
||||
content: message.content,
|
||||
created_at: message.created_at,
|
||||
file_id: uploadedFile.id,
|
||||
fileId: uploadedFile.id,
|
||||
filePath: uploadedFile.file_path,
|
||||
fileType: uploadedFile.file_type,
|
||||
@@ -199,8 +203,8 @@ const sendVoiceMessage = async () => {
|
||||
fileCreatedAt: uploadedFile.created_at
|
||||
}
|
||||
|
||||
// Update the message in the store
|
||||
appStore.updateMessage(message.id, updatedMessage)
|
||||
// Add the complete message to the store (this will trigger immediate UI update)
|
||||
appStore.addMessage(completeMessage)
|
||||
|
||||
toastStore.success('Voice message sent!')
|
||||
clearRecording()
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<template>
|
||||
<div class="channel-list-container">
|
||||
<ul class="channel-list" role="list">
|
||||
<div class="channel-list-container" ref="containerRef">
|
||||
<ul class="channel-list" role="listbox" aria-label="Channels">
|
||||
<ChannelListItem
|
||||
v-for="channel in channels"
|
||||
v-for="(channel, index) in channels"
|
||||
:key="channel.id"
|
||||
:channel="channel"
|
||||
:is-active="channel.id === currentChannelId"
|
||||
: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)"
|
||||
@keydown="handleChannelKeydown"
|
||||
@focus="handleChannelFocus"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import ChannelListItem from './ChannelListItem.vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
@@ -24,12 +30,176 @@ interface Props {
|
||||
unreadCounts: Record<number, number>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
'select-channel': [channelId: number]
|
||||
'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
|
||||
focusChannel(matchingIndices[0])
|
||||
}
|
||||
} else {
|
||||
// New character: jump to first match
|
||||
focusChannel(matchingIndices[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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>
|
||||
|
||||
<style scoped>
|
||||
@@ -37,8 +207,13 @@ defineEmits<{
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
/* iOS-specific scroll optimizations */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-scroll-behavior: smooth;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
.channel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -4,13 +4,19 @@
|
||||
'channel-item',
|
||||
{ 'channel-item--active': isActive }
|
||||
]"
|
||||
:data-channel-index="channelIndex"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="channel-wrapper">
|
||||
<button
|
||||
class="channel-button"
|
||||
@click="$emit('select', channel.id)"
|
||||
:aria-pressed="isActive"
|
||||
:aria-label="`Select channel ${channel.name}`"
|
||||
@focus="handleFocus"
|
||||
role="option"
|
||||
:aria-current="isActive"
|
||||
@keydown="handleKeydown"
|
||||
:tabindex="tabindex"
|
||||
:aria-label="channelAriaLabel"
|
||||
>
|
||||
<span class="channel-name">{{ channel.name }}</span>
|
||||
<span v-if="unreadCount" class="channel-unread">
|
||||
@@ -18,7 +24,7 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button v-if="isActive"
|
||||
class="channel-info-button"
|
||||
@click.stop="$emit('info', channel)"
|
||||
:aria-label="`Channel info for ${channel.name}`"
|
||||
@@ -31,20 +37,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
interface Props {
|
||||
channel: Channel
|
||||
isActive: boolean
|
||||
unreadCount?: number
|
||||
tabindex?: number
|
||||
channelIndex?: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [channelId: number]
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar__header">
|
||||
<h1 class="sidebar__title">Notebrook</h1>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('create-channel')"
|
||||
aria-label="Create new channel"
|
||||
>
|
||||
+
|
||||
</BaseButton>
|
||||
<div class="sidebar__header-left">
|
||||
<h1 class="sidebar__title">Notebrook</h1>
|
||||
</div>
|
||||
<div class="sidebar__header-right">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('create-channel')"
|
||||
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 class="sidebar__content">
|
||||
@@ -53,6 +66,7 @@ defineEmits<{
|
||||
'select-channel': [channelId: number]
|
||||
'channel-info': [channel: Channel]
|
||||
'settings': []
|
||||
'close': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -63,7 +77,7 @@ defineEmits<{
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
@@ -76,6 +90,17 @@ defineEmits<{
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
@@ -99,6 +124,10 @@ defineEmits<{
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__close-button {
|
||||
display: none; /* Hidden by default on desktop */
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar {
|
||||
@@ -129,10 +158,15 @@ defineEmits<{
|
||||
|
||||
.sidebar__header {
|
||||
padding: 1rem;
|
||||
padding-top: calc(1rem + var(--safe-area-inset-top));
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.sidebar__close-button {
|
||||
display: inline-flex; /* Show on mobile */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -25,11 +25,8 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Skip shortcuts when focused on input/textarea elements
|
||||
const target = event.target as HTMLElement
|
||||
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
|
||||
return
|
||||
}
|
||||
const isInInputField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA'
|
||||
|
||||
const config: ShortcutConfig = {
|
||||
key: event.key.toLowerCase(),
|
||||
@@ -44,6 +41,17 @@ export function useKeyboardShortcuts() {
|
||||
const shortcut = shortcuts.value.get(shortcutKey)
|
||||
|
||||
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) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { websocketService } from '@/services/websocket'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
||||
|
||||
export function useWebSocket() {
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { announceMessage } = useAudio()
|
||||
|
||||
@@ -62,6 +64,24 @@ export function useWebSocket() {
|
||||
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) => {
|
||||
// Handle file upload events with flattened format
|
||||
const messageUpdate: Partial<ExtendedMessage> = {
|
||||
@@ -125,6 +145,7 @@ export function useWebSocket() {
|
||||
websocketService.on('message-created', handleMessageCreated)
|
||||
websocketService.on('message-updated', handleMessageUpdated)
|
||||
websocketService.on('message-deleted', handleMessageDeleted)
|
||||
websocketService.on('message-moved', handleMessageMoved)
|
||||
websocketService.on('file-uploaded', handleFileUploaded)
|
||||
websocketService.on('channel-created', handleChannelCreated)
|
||||
websocketService.on('channel-deleted', handleChannelDeleted)
|
||||
@@ -149,6 +170,7 @@ export function useWebSocket() {
|
||||
websocketService.off('message-created', handleMessageCreated)
|
||||
websocketService.off('message-updated', handleMessageUpdated)
|
||||
websocketService.off('message-deleted', handleMessageDeleted)
|
||||
websocketService.off('message-moved', handleMessageMoved)
|
||||
websocketService.off('file-uploaded', handleFileUploaded)
|
||||
websocketService.off('channel-created', handleChannelCreated)
|
||||
websocketService.off('channel-deleted', handleChannelDeleted)
|
||||
@@ -157,6 +179,11 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Set custom server URL if available
|
||||
if (authStore.serverUrl) {
|
||||
websocketService.setServerUrl(authStore.serverUrl)
|
||||
}
|
||||
|
||||
setupEventHandlers()
|
||||
websocketService.connect()
|
||||
})
|
||||
|
||||
@@ -9,6 +9,11 @@ class ApiService {
|
||||
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 {
|
||||
return {
|
||||
'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
|
||||
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
|
||||
const formData = new FormData()
|
||||
@@ -150,4 +169,4 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService()
|
||||
export const apiService = new ApiService()
|
||||
|
||||
@@ -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> {
|
||||
try {
|
||||
@@ -19,55 +23,36 @@ export class SyncService {
|
||||
// Get server messages
|
||||
const serverResponse = await apiService.getMessages(channelId)
|
||||
const serverMessages = serverResponse.messages
|
||||
|
||||
// Get local messages
|
||||
const localMessages = appStore.messages[channelId] || []
|
||||
|
||||
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
|
||||
|
||||
// Merge messages using a simple strategy:
|
||||
// 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>()
|
||||
|
||||
// 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)
|
||||
|
||||
console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
|
||||
|
||||
// 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())
|
||||
|
||||
console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
|
||||
|
||||
// Update local storage with server truth
|
||||
appStore.setMessages(channelId, normalizedServerMessages)
|
||||
await appStore.saveState()
|
||||
|
||||
} 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> {
|
||||
const appStore = this.getAppStore()
|
||||
@@ -86,28 +71,68 @@ export class SyncService {
|
||||
|
||||
for (const unsentMsg of [...unsentMessages]) {
|
||||
try {
|
||||
console.log(`Sending unsent message: ${unsentMsg.content}`)
|
||||
console.log(`Sending unsent ${unsentMsg.messageType || 'text'} message: ${unsentMsg.content}`)
|
||||
|
||||
// Try to send the message
|
||||
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||
console.log(`Successfully sent unsent 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()
|
||||
if (unsentMsg.messageType === 'voice' || unsentMsg.messageType === 'image') {
|
||||
// Handle file message retry
|
||||
if (!unsentMsg.fileData) {
|
||||
console.error(`File message ${unsentMsg.id} missing file data, removing`)
|
||||
appStore.removeUnsentMessage(unsentMsg.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create message and upload file
|
||||
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||
|
||||
// Create file from stored blob data
|
||||
const file = new File([unsentMsg.fileData.blob], unsentMsg.fileData.fileName, {
|
||||
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
|
||||
appStore.addMessage(sentMessage)
|
||||
// Remove from unsent messages
|
||||
appStore.removeUnsentMessage(unsentMsg.id)
|
||||
|
||||
// Save state immediately after successful send to ensure UI updates
|
||||
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}`)
|
||||
|
||||
} catch (error) {
|
||||
@@ -201,6 +226,65 @@ export class SyncService {
|
||||
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()
|
||||
|
||||
@@ -6,20 +6,35 @@ class WebSocketService {
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectInterval = 1000
|
||||
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
||||
private customServerUrl: string | null = null
|
||||
|
||||
setServerUrl(url: string) {
|
||||
this.customServerUrl = url
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
// In development, connect to backend server (port 3000)
|
||||
// In production, use same host as frontend
|
||||
const isDev = import.meta.env.DEV
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = isDev ? 'localhost:3000' : window.location.host
|
||||
const wsUrl = `${protocol}//${host}`
|
||||
// Determine WebSocket URL
|
||||
let wsUrl: string
|
||||
|
||||
if (this.customServerUrl) {
|
||||
// Use custom server URL
|
||||
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 {
|
||||
console.log('Connecting to WebSocket:', wsUrl)
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
this.setupEventListeners()
|
||||
} catch (error) {
|
||||
|
||||
@@ -71,9 +71,22 @@ export const useAppStore = defineStore('app', () => {
|
||||
if (!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]
|
||||
const existingIndex = channelMessages.findIndex(m => m.id === message.id)
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Upsert: update existing to avoid duplicates from WebSocket vs sync
|
||||
channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...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
|
||||
}
|
||||
|
||||
@@ -88,6 +101,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setMessageChecked = (messageId: number, checked: boolean | null) => {
|
||||
updateMessage(messageId, { checked })
|
||||
}
|
||||
|
||||
const removeMessage = (messageId: number) => {
|
||||
for (const channelId in messages.value) {
|
||||
const channelMessages = messages.value[parseInt(channelId)]
|
||||
@@ -99,6 +116,35 @@ 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]
|
||||
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]
|
||||
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) => {
|
||||
unsentMessages.value.push(message)
|
||||
}
|
||||
@@ -168,11 +214,13 @@ export const useAppStore = defineStore('app', () => {
|
||||
setMessages,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
setMessageChecked,
|
||||
removeMessage,
|
||||
moveMessage,
|
||||
addUnsentMessage,
|
||||
removeUnsentMessage,
|
||||
updateSettings,
|
||||
loadState,
|
||||
saveState
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,27 +4,58 @@ import { get, set } from 'idb-keyval'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(null)
|
||||
const serverUrl = ref<string | null>(null)
|
||||
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
|
||||
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 () => {
|
||||
token.value = null
|
||||
serverUrl.value = null
|
||||
isAuthenticated.value = false
|
||||
await set('auth_token', null)
|
||||
await Promise.all([
|
||||
set('auth_token', null),
|
||||
set('server_url', null)
|
||||
])
|
||||
}
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const storedToken = await get('auth_token')
|
||||
const [storedToken, storedServerUrl] = await Promise.all([
|
||||
get('auth_token'),
|
||||
get('server_url')
|
||||
])
|
||||
|
||||
if (storedToken) {
|
||||
// Set server URL or use default
|
||||
const urlToUse = storedServerUrl || getDefaultServerUrl()
|
||||
serverUrl.value = urlToUse
|
||||
|
||||
// Verify token with backend
|
||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
const response = await fetch(`${baseUrl}/check-token`, {
|
||||
const response = await fetch(`${urlToUse}/check-token`, {
|
||||
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 {
|
||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
const response = await fetch(`${baseUrl}/check-token`, {
|
||||
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||
const response = await fetch(`${urlToUse}/check-token`, {
|
||||
headers: { Authorization: authToken }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await setToken(authToken)
|
||||
await setToken(authToken, urlToUse)
|
||||
return true
|
||||
} else {
|
||||
await clearAuth()
|
||||
@@ -65,10 +96,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
token,
|
||||
serverUrl,
|
||||
isAuthenticated,
|
||||
setToken,
|
||||
setServerUrl,
|
||||
clearAuth,
|
||||
checkAuth,
|
||||
authenticate
|
||||
authenticate,
|
||||
getDefaultServerUrl
|
||||
}
|
||||
})
|
||||
@@ -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 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -7,7 +30,7 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
overflow: hidden;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
@@ -16,6 +39,9 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* iOS-specific optimizations */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -26,6 +52,26 @@ body {
|
||||
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 */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Message {
|
||||
content: string
|
||||
created_at: string
|
||||
file_id?: number
|
||||
checked?: boolean | null
|
||||
}
|
||||
|
||||
export interface MessageWithFile extends Message {
|
||||
@@ -72,6 +73,14 @@ export interface UnsentMessage {
|
||||
content: string
|
||||
timestamp: 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 {
|
||||
@@ -84,6 +93,7 @@ export interface AppSettings {
|
||||
selectedVoiceURI: string | null
|
||||
defaultChannelId: number | null
|
||||
theme: 'light' | 'dark' | 'auto'
|
||||
serverUrl?: string | null
|
||||
}
|
||||
|
||||
// Audio Types
|
||||
@@ -121,4 +131,4 @@ export interface UploadProgress {
|
||||
loaded: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
}
|
||||
|
||||
94
frontend-vue/src/utils/time.ts
Normal file
94
frontend-vue/src/utils/time.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleAuth" class="auth-form">
|
||||
<BaseInput
|
||||
v-model="serverUrl"
|
||||
ref="serverInput"
|
||||
type="url"
|
||||
label="Server URL (optional)"
|
||||
:placeholder="defaultServerUrl"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
|
||||
<BaseInput
|
||||
v-model="token"
|
||||
type="password"
|
||||
@@ -47,9 +56,13 @@ const toastStore = useToastStore()
|
||||
const { playSound } = useAudio()
|
||||
|
||||
const token = ref('')
|
||||
const serverUrl = ref('')
|
||||
const error = ref('')
|
||||
const isLoading = ref(false)
|
||||
const tokenInput = ref()
|
||||
const serverInput = ref()
|
||||
// Get default server URL for placeholder
|
||||
const defaultServerUrl = authStore.getDefaultServerUrl()
|
||||
|
||||
const handleAuth = async () => {
|
||||
if (!token.value.trim()) return
|
||||
@@ -58,18 +71,20 @@ const handleAuth = async () => {
|
||||
error.value = ''
|
||||
|
||||
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) {
|
||||
await playSound('login')
|
||||
toastStore.success('Authentication successful!')
|
||||
router.push('/')
|
||||
} else {
|
||||
error.value = 'Invalid authentication token'
|
||||
tokenInput.value?.focus()
|
||||
error.value = 'Invalid authentication token or server URL'
|
||||
serverInput.value?.focus()
|
||||
}
|
||||
} 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)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -77,14 +92,14 @@ const handleAuth = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tokenInput.value?.focus()
|
||||
serverInput.value?.focus()
|
||||
playSound('intro')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-view {
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
@select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
|
||||
@channel-info="handleChannelInfo"
|
||||
@settings="showSettings = true"
|
||||
@close="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -53,6 +54,8 @@
|
||||
:messages="appStore.currentMessages"
|
||||
:unsent-messages="appStore.unsentMessagesForChannel"
|
||||
ref="messagesContainer"
|
||||
@open-message-dialog="handleOpenMessageDialog"
|
||||
@open-message-dialog-edit="handleOpenMessageDialogEdit"
|
||||
/>
|
||||
|
||||
<!-- Message Input -->
|
||||
@@ -61,6 +64,7 @@
|
||||
@file-upload="showFileDialog = true"
|
||||
@camera="showCameraDialog = true"
|
||||
@voice="showVoiceDialog = true"
|
||||
@toggle-check="handleToggleCheckFocused"
|
||||
ref="messageInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,6 +121,19 @@
|
||||
@close="showChannelInfoDialog = false"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +147,7 @@ import { useOfflineSync } from '@/composables/useOfflineSync'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { formatTimestampForScreenReader } from '@/utils/time'
|
||||
import { apiService } from '@/services/api'
|
||||
import { syncService } from '@/services/sync'
|
||||
|
||||
@@ -147,9 +165,10 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
|
||||
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
|
||||
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
||||
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
||||
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
||||
|
||||
// Types
|
||||
import type { ExtendedMessage, Channel } from '@/types'
|
||||
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
@@ -158,10 +177,13 @@ const toastStore = useToastStore()
|
||||
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
||||
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) {
|
||||
apiService.setToken(authStore.token)
|
||||
}
|
||||
if (authStore.serverUrl) {
|
||||
apiService.setBaseUrl(authStore.serverUrl)
|
||||
}
|
||||
|
||||
// Refs
|
||||
const messagesContainer = ref()
|
||||
@@ -174,7 +196,10 @@ const showSettings = ref(false)
|
||||
const showSearchDialog = ref(false)
|
||||
const showFileDialog = ref(false)
|
||||
const showVoiceDialog = ref(false)
|
||||
const showMessageDialog = ref(false)
|
||||
const showCameraDialog = ref(false)
|
||||
const selectedMessage = ref<ExtendedMessage | null>(null)
|
||||
const shouldStartEditing = ref(false)
|
||||
|
||||
// Mobile sidebar state
|
||||
const sidebarOpen = ref(false)
|
||||
@@ -205,11 +230,10 @@ const setupKeyboardShortcuts = () => {
|
||||
handler: () => { showSearchDialog.value = true }
|
||||
})
|
||||
|
||||
// Ctrl+Shift+C - Channel selector focus
|
||||
// Ctrl+K - Channel selector focus
|
||||
addShortcut({
|
||||
key: 'c',
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => {
|
||||
// Focus the first channel in the list
|
||||
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
|
||||
@@ -276,6 +300,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
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
addShortcut({
|
||||
@@ -293,6 +332,19 @@ 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')
|
||||
}
|
||||
}
|
||||
|
||||
const selectChannel = async (channelId: number) => {
|
||||
console.log('Selecting channel:', channelId)
|
||||
await appStore.setCurrentChannel(channelId)
|
||||
@@ -306,6 +358,11 @@ const selectChannel = async (channelId: number) => {
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
// Auto-focus message input when switching channels
|
||||
nextTick(() => {
|
||||
messageInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
@@ -356,9 +413,7 @@ const handleSelectMessage = async (message: ExtendedMessage) => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
// formatTime function removed - now using formatTimestampForScreenReader from utils
|
||||
|
||||
const handleVoiceSent = () => {
|
||||
// Voice message was sent successfully
|
||||
@@ -388,8 +443,8 @@ const announceLastMessage = (position: number) => {
|
||||
}
|
||||
|
||||
const message = messages[messageIndex]
|
||||
const timeStr = formatTime(message.created_at)
|
||||
const announcement = `${message.content}; ${timeStr}`
|
||||
const timeStr = formatTimestampForScreenReader(message.created_at)
|
||||
const announcement = `${message.content}; sent ${timeStr}`
|
||||
|
||||
toastStore.info(announcement)
|
||||
|
||||
@@ -403,6 +458,104 @@ const 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) => {
|
||||
showChannelDialog.value = false
|
||||
await selectChannel(channelId)
|
||||
@@ -417,6 +570,15 @@ const isUnsentMessage = (messageId: string | number): boolean => {
|
||||
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
|
||||
onMounted(async () => {
|
||||
// 1. Load saved state first (offline-first)
|
||||
@@ -449,7 +611,12 @@ onMounted(async () => {
|
||||
await selectChannel(appStore.channels[0].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 () => {
|
||||
if (appStore.unsentMessages.length > 0) {
|
||||
try {
|
||||
@@ -470,7 +637,7 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.main-view {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@@ -512,20 +679,34 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 500; /* Higher than sidebar to prevent conflicts */
|
||||
}
|
||||
|
||||
.mobile-menu-button,
|
||||
.mobile-search-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
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,
|
||||
@@ -555,7 +736,7 @@ onMounted(async () => {
|
||||
@media (max-width: 768px) {
|
||||
.main-view {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
@@ -567,14 +748,16 @@ onMounted(async () => {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
height: var(--vh-dynamic, 100vh);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 300;
|
||||
transition: transform 0.3s ease, visibility 0.3s ease;
|
||||
z-index: 400; /* Lower than mobile header but higher than overlay */
|
||||
visibility: hidden; /* Completely hide when closed */
|
||||
}
|
||||
|
||||
.sidebar.sidebar-open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
@@ -584,6 +767,7 @@ onMounted(async () => {
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-top: var(--header-total-height); /* Account for fixed header height with safe area */
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
@@ -615,4 +799,4 @@ onMounted(async () => {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,8 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
port: 5173,
|
||||
allowedHosts: true
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notebrook</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Theme Color (For Mobile idk) -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<!-- PWA Metadata -->
|
||||
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
|
||||
<meta name="application-name" content="Notebrook" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Notebrook" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
|
||||
|
||||
<style>
|
||||
/* Basic styles for the toasts */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body role="application">
|
||||
<div id="app"></div>
|
||||
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "Notebrook",
|
||||
"short_name": "Notebrook",
|
||||
"description": "Stream of conciousness accessible note taking",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
4949
frontend/package-lock.json
generated
4949
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "notebrook-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,104 +0,0 @@
|
||||
import { IChannel } from "./model/channel";
|
||||
import { IChannelList } from "./model/channel-list";
|
||||
import { IMessage } from "./model/message";
|
||||
import { IUnsentMessage } from "./model/unsent-message";
|
||||
import { state } from "./state";
|
||||
|
||||
|
||||
export const API = {
|
||||
token: "",
|
||||
path: "http://localhost:3000",
|
||||
|
||||
async request(method: string, path: string, body?: any) {
|
||||
if (!API.token) {
|
||||
throw new Error("API token was not set.");
|
||||
}
|
||||
return fetch(`${API.path}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async checkToken() {
|
||||
const response = await API.request("GET", "check-token");
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Invalid token in request");
|
||||
}
|
||||
},
|
||||
|
||||
async getChannels() {
|
||||
const response = await API.request("GET", "channels");
|
||||
const json = await response.json();
|
||||
return json.channels as IChannel[];
|
||||
},
|
||||
|
||||
async getChannel(id: string) {
|
||||
const response = await API.request("GET", `channels/${id}`);
|
||||
const json = await response.json();
|
||||
return json.channel as IChannel;
|
||||
},
|
||||
|
||||
async createChannel(name: string) {
|
||||
const response = await API.request("POST", "channels", { name });
|
||||
const json = await response.json();
|
||||
return json as IChannel;
|
||||
},
|
||||
|
||||
async deleteChannel(id: string) {
|
||||
await API.request("DELETE", `channels/${id}`);
|
||||
},
|
||||
|
||||
async getMessages(channelId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages`);
|
||||
console.log(response)
|
||||
const json = await response.json();
|
||||
return json.messages as IMessage[];
|
||||
},
|
||||
|
||||
async createMessage(channelId: string, content: string) {
|
||||
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
|
||||
const json = await response.json();
|
||||
return json as IMessage;
|
||||
},
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string) {
|
||||
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
|
||||
},
|
||||
|
||||
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
},
|
||||
|
||||
async mergeChannels(channelId: string, targetChannelId: string) {
|
||||
await API.request("PUT", `channels/${channelId}/merge`, { targetChannelId });
|
||||
},
|
||||
|
||||
async search(query: string, channelId?: string) {
|
||||
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
|
||||
const response = await API.request("GET", queryPath);
|
||||
const json = await response.json();
|
||||
return json.results as IMessage[];
|
||||
},
|
||||
|
||||
async getFiles(channelId: string, messageId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
|
||||
const json = await response.json();
|
||||
return json.files as string[];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export class ChunkProcessor<T> {
|
||||
private chunkSize: number;
|
||||
|
||||
constructor(chunkSize: number = 1000) {
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
const totalChunks = Math.ceil(array.length / this.chunkSize);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
|
||||
await this.processChunk(chunk, callback);
|
||||
}
|
||||
}
|
||||
|
||||
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
callback(chunk);
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { IChannel } from "../model/channel";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
import { RemoveDialog } from "./remove-dialog";
|
||||
|
||||
export class ChannelDialog extends Dialog<IChannel | null> {
|
||||
private channel: IChannel;
|
||||
private nameField: TextInput;
|
||||
private idField: TextInput;
|
||||
private makeDefault: Button;
|
||||
private mergeButton: Button;
|
||||
private deleteButton: Button;
|
||||
|
||||
public constructor(channel: IChannel) {
|
||||
super("Channel info for " + channel.name);
|
||||
this.channel = channel;
|
||||
this.nameField = new TextInput("Channel name");
|
||||
this.nameField.setPosition(25, 10, 50, 10);
|
||||
this.nameField.setValue(channel.name);
|
||||
this.idField = new TextInput("Channel ID (for use with API)");
|
||||
this.idField.setPosition(45, 10, 50, 10);
|
||||
this.idField.setReadonly(true);
|
||||
this.idField.setValue(channel.id.toString());
|
||||
|
||||
this.makeDefault = new Button("Make default");
|
||||
this.makeDefault.setPosition(20, 70, 10, 10);
|
||||
this.makeDefault.onClick(() => {
|
||||
state.defaultChannelId = this.channel.id;
|
||||
showToast(`${channel.name} is now the default channel.`);
|
||||
});
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(40, 70, 10, 10);
|
||||
this.mergeButton.onClick(() => {
|
||||
this.mergeChannel();
|
||||
});
|
||||
if (state.channelList.channels.length === 1) {
|
||||
this.mergeButton.setDisabled(true);
|
||||
}
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(60, 70, 10, 10);
|
||||
this.deleteButton.onClick(() => {
|
||||
this.deleteChannel();
|
||||
});
|
||||
this.add(this.nameField);
|
||||
this.add(this.idField);
|
||||
this.add(this.makeDefault);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.deleteButton);
|
||||
this.setOkAction(() => {
|
||||
this.channel.name = this.nameField.getValue();
|
||||
return this.channel;
|
||||
});
|
||||
}
|
||||
|
||||
private async mergeChannel() {
|
||||
const res = await new MergeDialog().open();
|
||||
if (res) {
|
||||
this.choose(this.channel);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteChannel() {
|
||||
const res = await new RemoveDialog(this.channel.id.toString()).open();
|
||||
if (res) {
|
||||
this.choose(null);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { showToast } from "../speech";
|
||||
import { TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class CreateChannelDialog extends Dialog<string> {
|
||||
private nameField: TextInput;
|
||||
|
||||
public constructor() {
|
||||
super("Create new channel");
|
||||
this.nameField = new TextInput("Name of new channel");
|
||||
this.add(this.nameField);
|
||||
this.setOkAction(() => {
|
||||
return this.nameField.getValue();
|
||||
});
|
||||
this.nameField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.choose(this.nameField.getValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { API } from "../api";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class MergeDialog extends Dialog<boolean> {
|
||||
private channelList: Dropdown;
|
||||
private mergeButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Merge channels", false);
|
||||
this.channelList = new Dropdown("Target channel", []);
|
||||
this.channelList.setPosition(10, 10, 80, 20);
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(30, 30, 40, 30);
|
||||
this.mergeButton.onClick(() => this.merge());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.channelList);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.cancelButton);
|
||||
this.setupChannelList();
|
||||
}
|
||||
|
||||
private setupChannelList() {
|
||||
this.channelList.clearOptions();
|
||||
state.channelList.getChannels().forEach((channel) => {
|
||||
if (channel.id !== state.currentChannel!.id) this.channelList.addOption(channel.id.toString(), channel.name);
|
||||
})
|
||||
}
|
||||
private async merge() {
|
||||
const currentChannel = state.currentChannel;
|
||||
const target = this.channelList.getSelectedValue();
|
||||
const targetChannel = state.getChannelById(parseInt(target));
|
||||
console.log(currentChannel, targetChannel);
|
||||
if (!targetChannel || !currentChannel) this.cancel();
|
||||
try {
|
||||
const res = await API.mergeChannels(currentChannel!.id.toString(), target);
|
||||
currentChannel!.messages = [];
|
||||
showToast("Channels were merged.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to merge channels: " + e);
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, Container, TextInput} from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { MultilineInput } from "../ui/multiline-input";
|
||||
import { state } from "../state";
|
||||
export class MessageDialog extends Dialog<IMessage | null> {
|
||||
private message: IMessage;
|
||||
private messageText: MultilineInput;
|
||||
private deleteButton: Button;
|
||||
private fileInfoContainer?: Container;
|
||||
|
||||
public constructor(message: IMessage) {
|
||||
super("Message");
|
||||
this.message = message;
|
||||
this.messageText = new MultilineInput("Message");
|
||||
this.messageText.setValue(message.content);
|
||||
this.messageText.setPosition(10, 10, 80, 20);
|
||||
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(10, 90, 80, 10);
|
||||
this.deleteButton.onClick(async () => {
|
||||
await API.deleteMessage(state.currentChannel!.id.toString(), this.message.id.toString());
|
||||
this.choose(null);
|
||||
});
|
||||
this.add(this.messageText);
|
||||
this.add(this.deleteButton);
|
||||
if (this.message.fileId !== null) {
|
||||
this.fileInfoContainer = new Container("File info");
|
||||
this.fileInfoContainer.setPosition(10, 50, 30, 80);
|
||||
this.add(this.fileInfoContainer);
|
||||
this.handleMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage() {
|
||||
if (this.message?.fileType?.toLowerCase().includes("audio")) {
|
||||
const audio = new Audio(`${API.path}/${this.message.filePath}`);
|
||||
audio.autoplay = true;
|
||||
}
|
||||
|
||||
// display info about files, or the image if it is an image. Also display all metadata.
|
||||
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
|
||||
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
|
||||
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
|
||||
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
|
||||
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Audio } from "../ui/audio";
|
||||
import { AudioRecorder } from "../ui/audio-recorder";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class RecordAudioDialog extends Dialog<Blob> {
|
||||
private audioRecorder: AudioRecorder;
|
||||
private recordButton: Button;
|
||||
private stopButton: Button;
|
||||
private playButton: Button;
|
||||
private saveButton: Button;
|
||||
private discardButton: Button;
|
||||
private audioBlob: Blob | undefined;
|
||||
private audioPlayer?: Audio;
|
||||
|
||||
constructor() {
|
||||
super("Record audio", false);
|
||||
this.audioRecorder = new AudioRecorder("Record from microphone");
|
||||
this.audioRecorder.onRecordingComplete(() => {
|
||||
this.audioBlob = this.audioRecorder.getRecording();
|
||||
this.saveButton.setDisabled(false);
|
||||
});
|
||||
this.recordButton = new Button("Record");
|
||||
this.recordButton.setPosition(30, 30, 40, 30);
|
||||
this.recordButton.onClick(() => this.startRecording());
|
||||
this.stopButton = new Button("Stop");
|
||||
this.stopButton.setPosition(70, 40, 30, 30);
|
||||
this.stopButton.onClick(() => this.stopRecording());
|
||||
this.stopButton.setDisabled(true);
|
||||
this.saveButton = new Button("Save");
|
||||
this.saveButton.setPosition(10, 80, 50, 20);
|
||||
this.saveButton.onClick(() => this.saveRecording());
|
||||
this.saveButton.setDisabled(true);
|
||||
this.playButton = new Button("Play");
|
||||
this.playButton.setPosition(0, 40, 30, 30);
|
||||
this.playButton.onClick(() => {
|
||||
if (this.audioBlob) {
|
||||
this.audioPlayer = new Audio("Recorded audio");
|
||||
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
|
||||
this.audioPlayer.play();
|
||||
}
|
||||
});
|
||||
this.playButton.setDisabled(true);
|
||||
this.discardButton = new Button("Discard");
|
||||
this.discardButton.setPosition(50, 90, 50, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.recordButton);
|
||||
this.add(this.stopButton);
|
||||
this.add(this.playButton);
|
||||
this.add(this.saveButton);
|
||||
this.add(this.discardButton);
|
||||
}
|
||||
|
||||
private startRecording() {
|
||||
this.audioRecorder.startRecording();
|
||||
this.stopButton.setDisabled(false);
|
||||
this.recordButton.setDisabled(true);
|
||||
}
|
||||
|
||||
private stopRecording() {
|
||||
this.audioRecorder.stopRecording();
|
||||
this.recordButton.setDisabled(false);
|
||||
this.stopButton.setDisabled(true);
|
||||
this.playButton.setDisabled(false);
|
||||
}
|
||||
|
||||
private saveRecording() {
|
||||
if (this.audioBlob) {
|
||||
this.choose(this.audioBlob);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class RemoveDialog extends Dialog<boolean> {
|
||||
private content: Text;
|
||||
private confirmButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor(channelId: string) {
|
||||
super("Remove channel", false);
|
||||
this.content = new Text("Are you sure you want to remove this channel?");
|
||||
this.confirmButton = new Button("Remove");
|
||||
this.confirmButton.setPosition(30, 30, 40, 30);
|
||||
this.confirmButton.onClick(() => this.doRemove());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.content);
|
||||
this.add(this.confirmButton);
|
||||
this.add(this.cancelButton);
|
||||
}
|
||||
|
||||
private async doRemove() {
|
||||
try {
|
||||
const res = await API.deleteChannel(state.currentChannel!.id.toString());
|
||||
state.removeChannel(state.currentChannel!);
|
||||
showToast("Channel was removed.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to remove channel: " + e);
|
||||
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, List, ListItem, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
|
||||
private searchField: TextInput;
|
||||
private searchButton: Button;
|
||||
private resultsList: List;
|
||||
private closeButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Search for message", false);
|
||||
this.searchField = new TextInput("Search query");
|
||||
this.searchField.setPosition(5, 5, 80, 20);
|
||||
this.searchField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.searchButton.click();
|
||||
}
|
||||
});
|
||||
this.searchButton = new Button("Search");
|
||||
this.searchButton.setPosition(85, 5, 10, 20);
|
||||
this.searchButton.onClick(async () => {
|
||||
const messages = await API.search(this.searchField.getValue());
|
||||
console.log(messages);
|
||||
this.renderResults(messages);
|
||||
})
|
||||
this.resultsList = new List("Results");
|
||||
this.resultsList.setPosition(5, 20, 90, 70);
|
||||
this.closeButton = new Button("Close");
|
||||
this.closeButton.setPosition(5, 90, 90, 5);
|
||||
this.closeButton.onClick(() => this.cancel());
|
||||
this.add(this.searchField);
|
||||
this.add(this.searchButton);
|
||||
this.add(this.resultsList);
|
||||
this.add(this.closeButton);
|
||||
}
|
||||
|
||||
private renderResults(messages: IMessage[]) {
|
||||
this.resultsList.clear();
|
||||
messages.forEach((message) => {
|
||||
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
|
||||
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
|
||||
this.resultsList.add(itm);
|
||||
});
|
||||
this.resultsList.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { state } from "../state";
|
||||
|
||||
export class SettingsDialog extends Dialog<void> {
|
||||
private resetButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Settings");
|
||||
this.resetButton = new Button("Reset frontend");
|
||||
this.resetButton.setPosition(30, 20, 30, 30);
|
||||
this.resetButton.onClick(() => {
|
||||
this.reset();
|
||||
});
|
||||
this.add(this.resetButton);
|
||||
}
|
||||
|
||||
private reset() {
|
||||
state.clear().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { Button } from "../ui";
|
||||
import { Camera } from "../ui/camera";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class TakePhotoDialog extends Dialog<Blob> {
|
||||
private camera: Camera;
|
||||
private takePhotoButton: Button;
|
||||
private discardButton: Button;
|
||||
|
||||
constructor() {
|
||||
super("Take photo", false);
|
||||
this.camera = new Camera("Photo camera");
|
||||
this.camera.setPosition(10, 15, 80, 75);
|
||||
this.camera.startCamera();
|
||||
this.takePhotoButton = new Button("Take photo");
|
||||
this.takePhotoButton.setPosition(10, 90, 80, 10);
|
||||
this.discardButton = new Button("Cancel");
|
||||
this.discardButton.setPosition(5, 5, 10, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.camera);
|
||||
this.add(this.takePhotoButton);
|
||||
this.add(this.discardButton);
|
||||
this.takePhotoButton.onClick(async () => {
|
||||
const photo = await this.camera.savePhotoToBlob();
|
||||
if (photo) this.choose(photo);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export type MessageCreated = {
|
||||
channelId: string,
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type MessageDeleted = {
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
};
|
||||
|
||||
export type MessageUpdated = {
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type ChannelCreated = {
|
||||
name: string,
|
||||
};
|
||||
|
||||
export type ChannelDeleted = {
|
||||
channelId: string,
|
||||
};
|
||||
|
||||
export type ChannelUpdated = {
|
||||
channelId: string,
|
||||
name: string,
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
export type Message<T> = {
|
||||
type: string,
|
||||
data?: T,
|
||||
};
|
||||
|
||||
export type MessageHandler<T> = (message: Message<T>) => void;
|
||||
|
||||
export class MessagingSystem {
|
||||
private handlers: Record<string, MessageHandler<any>[]> = {};
|
||||
|
||||
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (!this.handlers[type]) {
|
||||
this.handlers[type] = [];
|
||||
}
|
||||
if (!this.handlers[type].includes(handler)) {
|
||||
this.handlers[type].push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (this.handlers[type]) {
|
||||
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
|
||||
}
|
||||
}
|
||||
|
||||
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
|
||||
const wrappedHandler = (message: Message<T>) => {
|
||||
handler(message);
|
||||
this.unregisterHandler(type, wrappedHandler);
|
||||
};
|
||||
this.registerHandler(type, wrappedHandler);
|
||||
}
|
||||
|
||||
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (message: Message<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(message.data!);
|
||||
this.unregisterHandler(type, handler);
|
||||
};
|
||||
|
||||
this.registerHandler(type, handler);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeout) {
|
||||
timer = setTimeout(() => {
|
||||
this.unregisterHandler(type, handler);
|
||||
reject(new Error(`Timeout waiting for message of type '${type}'`));
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendMessage<T>(message: Message<T>): void {
|
||||
const handlers = this.handlers[message.type];
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import './style.css'
|
||||
import { MainView } from "./views/main";
|
||||
import { ViewManager } from './views/view-manager';
|
||||
import { AuthorizeView } from './views/authorize';
|
||||
import { state } from './state';
|
||||
import { API } from './api';
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await state.load();
|
||||
const vm = new ViewManager();
|
||||
setInterval(() => {
|
||||
state.save();
|
||||
}, 10000);
|
||||
|
||||
if (state.token === "" || state.apiUrl === "") {
|
||||
vm.push(new AuthorizeView(vm));
|
||||
} else {
|
||||
vm.push(new MainView(vm));
|
||||
}
|
||||
document.body.appendChild(vm.render() as HTMLElement);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Channel, IChannel } from "./channel";
|
||||
|
||||
export interface IChannelList {
|
||||
channels: IChannel[]
|
||||
}
|
||||
|
||||
export class ChannelList implements IChannelList {
|
||||
channels: Channel[] = [];
|
||||
|
||||
constructor(channels?: IChannelList) {
|
||||
this.channels = channels?.channels?.map((chan) => new Channel(chan)) || [];
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel): void {
|
||||
this.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channelId: number): void {
|
||||
this.channels = this.channels.filter(channel => channel.id !== channelId);
|
||||
}
|
||||
|
||||
public getChannel(channelId: number): Channel|undefined {
|
||||
return this.channels.find(channel => channel.id === channelId);
|
||||
}
|
||||
|
||||
public getChannelByName(channelName: string): IChannel|undefined {
|
||||
return this.channels.find(channel => channel.name === channelName);
|
||||
}
|
||||
|
||||
public getChannels(): Channel[] {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public getChannelIds(): number[] {
|
||||
return this.channels.map(channel => channel.id);
|
||||
}
|
||||
|
||||
public getChannelNames(): string[] {
|
||||
return this.channels.map(channel => channel.name);
|
||||
}
|
||||
|
||||
public getChannelId(channelName: string): number|undefined {
|
||||
const channel = this.getChannelByName(channelName);
|
||||
return channel ? channel.id : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { IMessage, Message } from "./message";
|
||||
|
||||
export interface IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: IMessage[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class Channel implements IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
private messageToIdMap: Map<number, Message>;
|
||||
|
||||
constructor(channel: IChannel) {
|
||||
this.id = channel.id;
|
||||
this.name = channel.name;
|
||||
this.messages = [];
|
||||
this.messageToIdMap = new Map();
|
||||
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
|
||||
this.createdAt = channel.createdAt;
|
||||
}
|
||||
|
||||
public addMessage(message: Message): void {
|
||||
this.messages.push(message);
|
||||
this.messageToIdMap.set(message.id, message);
|
||||
}
|
||||
|
||||
public removeMessage(messageId: number): void {
|
||||
this.messages = this.messages.filter(message => message.id !== messageId);
|
||||
this.messageToIdMap.delete(messageId);
|
||||
}
|
||||
|
||||
public getMessage(messageId: number): Message|undefined {
|
||||
return this.messageToIdMap.get(messageId);
|
||||
}
|
||||
|
||||
public getMessageByContent(content: string): Message|undefined {
|
||||
return this.messages.find(message => message.content === content);
|
||||
}
|
||||
|
||||
public getMessages(): Message[] {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
public getMessageIds(): number[] {
|
||||
return this.messages.map(message => message.id);
|
||||
}
|
||||
|
||||
public getMessageContents(): string[] {
|
||||
return this.messages.map(message => message.content);
|
||||
}
|
||||
|
||||
public getMessageId(content: string): number|undefined {
|
||||
const message = this.getMessageByContent(content);
|
||||
return message ? message.id : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface IMessage {
|
||||
id: number;
|
||||
channelId?: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class Message implements IMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(message: IMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.fileId = message.fileId;
|
||||
this.fileType = message.fileType;
|
||||
this.filePath = message.filePath;
|
||||
this.fileSize = message.fileSize;
|
||||
this.originalName = message.originalName;
|
||||
this.createdAt = message.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { IChannelList } from "./channel-list";
|
||||
import { IUnsentMessage } from "./unsent-message";
|
||||
|
||||
export interface IState {
|
||||
token: string;
|
||||
apiUrl: string;
|
||||
defaultChannelId: number;
|
||||
channelList: IChannelList;
|
||||
unsentMessages: IUnsentMessage[];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
}
|
||||
|
||||
export class UnsentMessage implements IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
|
||||
constructor(message: IUnsentMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.blob = message.blob;
|
||||
this.createdAt = message.createdAt;
|
||||
this.channelId = message.channelId;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
const CACHE_NAME = 'notebrook-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/intro.wav',
|
||||
'/login.wav',
|
||||
'/uploadfail.wav',
|
||||
'/water1.wav',
|
||||
'/water2.wav',
|
||||
'/water3.wav',
|
||||
'/water4.wav',
|
||||
'/water5.wav',
|
||||
'/water6.wav',
|
||||
'/water7.wav',
|
||||
'/water8.wav',
|
||||
'/water9.wav',
|
||||
'/water10.wav',
|
||||
'/sent1.wav',
|
||||
'/sent2.wav',
|
||||
'/sent3.wav',
|
||||
'/sent4.wav',
|
||||
'/sent5.wav',
|
||||
'/sent6.wav',
|
||||
'/vite.svg',
|
||||
'/src/main.ts'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event: any) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: any) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return the cached response if found, otherwise fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event: any) => {
|
||||
const cacheWhitelist = [CACHE_NAME];
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
const soundFiles = {
|
||||
intro: 'intro.wav',
|
||||
login: 'login.wav',
|
||||
copy: 'copy.wav',
|
||||
uploadFailed: 'uploadfail.wav'
|
||||
} as const;
|
||||
|
||||
type SoundName = keyof typeof soundFiles;
|
||||
|
||||
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
|
||||
|
||||
const waterSounds: AudioBuffer[] = [];
|
||||
const sentSounds: AudioBuffer[] = [];
|
||||
|
||||
async function loadSound(url: string): Promise<AudioBuffer> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer);
|
||||
}
|
||||
|
||||
async function loadAllSounds() {
|
||||
for (const key in soundFiles) {
|
||||
const soundName = key as SoundName;
|
||||
sounds[soundName] = await loadSound(soundFiles[soundName]);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`water${i}.wav`);
|
||||
waterSounds.push(buffer);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`sent${i}.wav`);
|
||||
sentSounds.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function playSoundBuffer(buffer: AudioBuffer) {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName) {
|
||||
const buffer = sounds[name];
|
||||
if (buffer) {
|
||||
playSoundBuffer(buffer);
|
||||
} else {
|
||||
console.error(`Sound ${name} not loaded.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function playWater() {
|
||||
if (waterSounds.length > 0) {
|
||||
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Water sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
export function playSent() {
|
||||
if (sentSounds.length > 0) {
|
||||
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Sent sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
loadAllSounds().then(() => {
|
||||
console.log('All sounds loaded and ready to play');
|
||||
}).catch(error => {
|
||||
console.error('Error loading sounds:', error);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Toast } from "./toast";
|
||||
|
||||
export function speak(text: string, interrupt: boolean = false) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
if (interrupt) {
|
||||
speechSynthesis.cancel();
|
||||
}
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export function showToast(message: string, timeout: number = 5000) {
|
||||
const toast = new Toast(timeout);
|
||||
toast.show(message);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { MessagingSystem } from "./events/messaging-system";
|
||||
import { IChannel, Channel } from "./model/channel";
|
||||
import { IChannelList, ChannelList } from "./model/channel-list";
|
||||
import { IState } from "./model/state";
|
||||
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
|
||||
import { get, set, clear } from "idb-keyval";
|
||||
|
||||
|
||||
export class State implements IState {
|
||||
token!: string;
|
||||
apiUrl!: string;
|
||||
channelList!: ChannelList;
|
||||
unsentMessages!: IUnsentMessage[];
|
||||
currentChannel!: Channel | null;
|
||||
defaultChannelId!: number;
|
||||
public events: MessagingSystem;
|
||||
|
||||
constructor() {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.events = new MessagingSystem();
|
||||
}
|
||||
|
||||
public getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
public setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public getChannelList(): IChannelList {
|
||||
return this.channelList;
|
||||
}
|
||||
|
||||
public setChannelList(channelList: ChannelList): void {
|
||||
this.channelList = channelList;
|
||||
}
|
||||
|
||||
public getUnsentMessages(): IUnsentMessage[] {
|
||||
return this.unsentMessages;
|
||||
}
|
||||
|
||||
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
|
||||
this.unsentMessages = unsentMessages;
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
// stringify everything here except the currentChannel object.
|
||||
const { currentChannel, events, ...state } = this;
|
||||
await set("notebrook", state);
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
const saved = await get("notebrook");
|
||||
if (saved) {
|
||||
this.token = saved.token;
|
||||
this.apiUrl = saved.apiUrl;
|
||||
this.channelList = new ChannelList( saved.channelList);
|
||||
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
|
||||
this.defaultChannelId = saved.defaultChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.currentChannel = null;
|
||||
this.defaultChannelId = -1;
|
||||
|
||||
await clear();
|
||||
}
|
||||
|
||||
public getChannelById(id: number) {
|
||||
return this.channelList.getChannel(id);
|
||||
}
|
||||
|
||||
public getChannelByName(name: string) {
|
||||
return this.channelList.getChannelByName(name);
|
||||
}
|
||||
|
||||
public findChannelByQuery(query: string) {
|
||||
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel) {
|
||||
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channel: IChannel) {
|
||||
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
|
||||
}
|
||||
|
||||
public addUnsentMessage(message: UnsentMessage) {
|
||||
this.unsentMessages.push(message);
|
||||
}
|
||||
|
||||
public removeUnsentMessage(message: IUnsentMessage) {
|
||||
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
|
||||
}
|
||||
|
||||
public getChannels() {
|
||||
return this.channelList.channels;
|
||||
}
|
||||
|
||||
public getCurrentChannel() {
|
||||
return this.currentChannel;
|
||||
}
|
||||
|
||||
public setCurrentChannel(channel: Channel) {
|
||||
this.currentChannel = channel;
|
||||
}
|
||||
|
||||
public getDefaultChannelId() {
|
||||
return this.defaultChannelId;
|
||||
}
|
||||
|
||||
public setDefaultChannelId(id: number) {
|
||||
this.defaultChannelId = id;
|
||||
}
|
||||
|
||||
public getApiUrl() {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
public setApiUrl(url: string) {
|
||||
this.apiUrl = url;
|
||||
}
|
||||
|
||||
public getMessageById(id: number) {
|
||||
return this.currentChannel!.getMessage(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new State();
|
||||
@@ -1,96 +0,0 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #3178c6aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export class Toast {
|
||||
private container: HTMLElement;
|
||||
private timeout: number;
|
||||
|
||||
constructor(timeout: number = 3000) {
|
||||
this.container = document.querySelector('.toast-container') as HTMLElement;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public show(message: string): void {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.hide(toast);
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
private hide(toast: HTMLElement): void {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,76 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class AudioRecorder extends UINode {
|
||||
private audioElement: HTMLAudioElement;
|
||||
private mediaRecorder: MediaRecorder | null;
|
||||
private audioChunks: Blob[];
|
||||
private stream: MediaStream | null;
|
||||
private recording?: Blob;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.audioElement = document.createElement("audio");
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.stream = null;
|
||||
|
||||
this.audioElement.setAttribute("controls", "true");
|
||||
this.audioElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.audioElement);
|
||||
|
||||
this.setRole("audio-recorder");
|
||||
}
|
||||
|
||||
public async startRecording() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
this.audioChunks.push(event.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
this.recording = audioBlob;
|
||||
this.audioChunks = [];
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
this.audioElement.src = audioUrl;
|
||||
this.triggerRecordingComplete(audioUrl);
|
||||
};
|
||||
this.mediaRecorder.start();
|
||||
} catch (error) {
|
||||
console.error("Error accessing microphone:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stopRecording() {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public onRecordingComplete(callback: (audioUrl: string) => void) {
|
||||
this.element.addEventListener("recording-complete", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
callback(customEvent.detail.audioUrl);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
protected triggerRecordingComplete(audioUrl: string) {
|
||||
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
|
||||
this.element.dispatchEvent(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
public getRecording() {
|
||||
return this.recording;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Audio extends UINode {
|
||||
private audioElement: HTMLAudioElement;
|
||||
|
||||
public constructor(title: string, src: string | MediaStream = "") {
|
||||
super(title);
|
||||
this.audioElement = document.createElement("audio");
|
||||
if (typeof src === "string") {
|
||||
this.audioElement.src = src; // Set src if it's a string URL
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
|
||||
}
|
||||
this.audioElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.audioElement);
|
||||
this.setRole("audio");
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.audioElement;
|
||||
}
|
||||
|
||||
public setSource(src: string | MediaStream) {
|
||||
if (typeof src === "string") {
|
||||
this.audioElement.src = src;
|
||||
} else if (src instanceof MediaStream) {
|
||||
this.audioElement.srcObject = src;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public play() {
|
||||
this.audioElement.play();
|
||||
return this;
|
||||
}
|
||||
|
||||
public pause() {
|
||||
this.audioElement.pause();
|
||||
return this;
|
||||
}
|
||||
|
||||
public setControls(show: boolean) {
|
||||
this.audioElement.controls = show;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setLoop(loop: boolean) {
|
||||
this.audioElement.loop = loop;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean) {
|
||||
this.audioElement.muted = muted;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAutoplay(autoplay: boolean) {
|
||||
this.audioElement.autoplay = autoplay;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setVolume(volume: number) {
|
||||
this.audioElement.volume = volume;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Button extends UINode {
|
||||
private buttonElement: HTMLButtonElement;
|
||||
public constructor(title: string, hasPopup: boolean = false) {
|
||||
super(title);
|
||||
this.buttonElement = document.createElement("button");
|
||||
this.buttonElement.innerText = title;
|
||||
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
|
||||
this.element.appendChild(this.buttonElement);
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.buttonElement.focus();
|
||||
return this;
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.buttonElement.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.buttonElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.buttonElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
return this;
|
||||
}
|
||||
|
||||
public setDisabled(val: boolean) {
|
||||
this.buttonElement.disabled = val;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Camera extends UINode {
|
||||
private videoElement: HTMLVideoElement;
|
||||
private canvasElement: HTMLCanvasElement;
|
||||
private stream: MediaStream | null;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.videoElement = document.createElement("video");
|
||||
this.canvasElement = document.createElement("canvas");
|
||||
this.stream = null;
|
||||
|
||||
this.videoElement.setAttribute("aria-label", title);
|
||||
this.element.appendChild(this.videoElement);
|
||||
this.element.appendChild(this.canvasElement);
|
||||
|
||||
this.setRole("camera");
|
||||
}
|
||||
|
||||
public async startCamera() {
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
this.videoElement.srcObject = this.stream;
|
||||
this.videoElement.play();
|
||||
} catch (error) {
|
||||
console.error("Error accessing camera:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.videoElement.pause();
|
||||
this.videoElement.srcObject = null;
|
||||
}
|
||||
|
||||
public takePhoto(): HTMLCanvasElement | null {
|
||||
if (this.stream) {
|
||||
const context = this.canvasElement.getContext("2d");
|
||||
if (context) {
|
||||
this.canvasElement.width = this.videoElement.videoWidth;
|
||||
this.canvasElement.height = this.videoElement.videoHeight;
|
||||
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhoto(): string | null {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
return photoCanvas.toDataURL("image/png");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public savePhotoToBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const photoCanvas = this.takePhoto();
|
||||
if (photoCanvas) {
|
||||
photoCanvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Canvas extends UINode {
|
||||
private canvasElement: HTMLCanvasElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.canvasElement = document.createElement("canvas");
|
||||
|
||||
this.canvasElement.setAttribute("tabindex", "-1");
|
||||
this.element.appendChild(this.canvasElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.canvasElement.focus();
|
||||
return this;
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.canvasElement.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.canvasElement;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { UINode } from "./node";
|
||||
|
||||
export class Checkbox extends UINode {
|
||||
private id: string;
|
||||
private titleElement: HTMLLabelElement;
|
||||
private checkboxElement: HTMLInputElement;
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.id = Math.random().toString();
|
||||
this.titleElement = document.createElement("label");
|
||||
this.titleElement.id = `chkbx_title_${this.id}`;
|
||||
this.checkboxElement = document.createElement("input");
|
||||
this.checkboxElement.id = `chkbx_${this.id}`;
|
||||
this.checkboxElement.type = "checkbox";
|
||||
this.titleElement.appendChild(this.checkboxElement);
|
||||
this.titleElement.appendChild(document.createTextNode(this.title));
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.checkboxElement.focus();
|
||||
return this;
|
||||
}
|
||||
|
||||
public click() {
|
||||
this.checkboxElement.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.checkboxElement;
|
||||
}
|
||||
|
||||
public setText(text: string) {
|
||||
this.title = text;
|
||||
this.titleElement.innerText = text;
|
||||
this.element.setAttribute("aria-label", this.title);
|
||||
this.element.setAttribute("aria-roledescription", "checkbox");
|
||||
return this;
|
||||
}
|
||||
|
||||
public isChecked(): boolean {
|
||||
return this.checkboxElement.checked;
|
||||
}
|
||||
|
||||
public setChecked(value: boolean) {
|
||||
this.checkboxElement.checked = value;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Container } from "./container";
|
||||
|
||||
export class CollapsableContainer extends Container {
|
||||
private detailsElement: HTMLDetailsElement;
|
||||
private summaryElement: HTMLElement;
|
||||
private wrapperElement: HTMLDivElement;
|
||||
|
||||
public constructor(title: string) {
|
||||
super(title);
|
||||
this.wrapperElement = document.createElement("div");
|
||||
this.detailsElement = document.createElement("details");
|
||||
this.summaryElement = document.createElement("summary");
|
||||
|
||||
this.summaryElement.innerText = title;
|
||||
this.detailsElement.appendChild(this.summaryElement);
|
||||
this.detailsElement.appendChild(this.containerElement);
|
||||
this.wrapperElement.appendChild(this.detailsElement);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.wrapperElement;
|
||||
}
|
||||
|
||||
public setTitle(text: string) {
|
||||
this.title = text;
|
||||
this.summaryElement.innerText = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public isCollapsed(): boolean {
|
||||
return this.detailsElement.hasAttribute("open");
|
||||
}
|
||||
|
||||
public expand(val: boolean) {
|
||||
if (val) {
|
||||
this.detailsElement.setAttribute("open", "true");
|
||||
} else {
|
||||
this.detailsElement.removeAttribute("open");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user