Compare commits
30
Commits
main
..
cf15a0f9c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf15a0f9c2 | ||
|
|
452192d0a9 | ||
|
|
0d50359dae | ||
|
|
420ff46f05 | ||
|
|
b312065d3d | ||
|
|
b07916309e | ||
|
|
6585ec2abb | ||
|
|
4dcacd0d73 | ||
|
|
f2ac7d7209 | ||
|
|
fa1cbdf97e | ||
|
|
8c0f8c6b44 | ||
|
|
864f0a5a45 | ||
|
|
8aeef63886 | ||
|
|
9f0943748f | ||
|
|
3edab7fbd7 | ||
|
|
58e0c10b4e | ||
|
|
64e50027ca | ||
|
|
57f4499c26 | ||
|
|
7b036e54a6 | ||
|
|
3dfbb8e6f6 | ||
|
|
dc0db147de | ||
|
|
18ebd0a6b8 | ||
|
|
f8d0aee84e | ||
|
|
4ec5b1b9ce | ||
|
|
8d87f196cd | ||
|
|
950a528b2b | ||
|
|
49c73a3d76 | ||
|
|
9fa656ed5e | ||
|
|
adb6be0006 | ||
|
|
d61233e731 |
@@ -4,3 +4,4 @@ backend/db.sqlite
|
||||
backend/uploads/
|
||||
.DS_Store
|
||||
frontend/dist/
|
||||
frontend-vue/dist
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Talon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@ export const router = Router({mergeParams: true});
|
||||
|
||||
router.post('/', authenticate, ChannelController.createChannel);
|
||||
router.get('/', authenticate, ChannelController.getChannels);
|
||||
router.put('/:channelId', authenticate, ChannelController.updateChannel);
|
||||
router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
|
||||
router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+3
-3
@@ -4,21 +4,21 @@ WORKDIR /usr/src/app
|
||||
FROM base AS install
|
||||
|
||||
COPY backend/package.json backend/package-lock.json /temp/dev/backend/
|
||||
COPY frontend/package.json frontend/package-lock.json /temp/dev/frontend/
|
||||
COPY frontend-vue/package.json frontend-vue/package-lock.json /temp/dev/frontend/
|
||||
|
||||
RUN cd /temp/dev/backend && npm install
|
||||
RUN cd /temp/dev/frontend && npm install
|
||||
|
||||
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
|
||||
COPY backend/package.json backend/package-lock.json /temp/prod/backend/
|
||||
COPY frontend/package.json frontend/package-lock.json /temp/prod/frontend/
|
||||
COPY frontend-vue/package.json frontend-vue/package-lock.json /temp/prod/frontend/
|
||||
|
||||
RUN cd /temp/prod/backend && npm install --production
|
||||
RUN cd /temp/prod/frontend && npm install --production
|
||||
|
||||
FROM install AS build-frontend
|
||||
WORKDIR /usr/src/app/frontend
|
||||
COPY frontend/ .
|
||||
COPY frontend-vue/ .
|
||||
COPY --from=install /temp/dev/frontend/node_modules node_modules
|
||||
RUN npm run build
|
||||
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
PWA icons would go here - placeholder files
|
||||
@@ -0,0 +1 @@
|
||||
PWA icons would go here - placeholder files
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Notebrook</title>
|
||||
<meta name="description" content="Light note taking app in messenger style">
|
||||
</head>
|
||||
<body role="application">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+8340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "notebrook-frontend-vue",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.3.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"@vueuse/sound": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"@vue/eslint-config-typescript": "^14.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVFiFtZddaBxVFMd/587M7iabNE1TbdI2VsWCVixYH/xAfRBBfBBBQcEH8UVBfPBBRcEXwQcfxAcRfLAP4oMPIr74oFgQC1qwYKHggyK2aZu0SZM0yWYzOzNz78/HmZ1Ndjeb3ewcmLkz95z/+Z977jl3wDmH/4NSSoUQoj9JknW+73vW2oy1NhVCmBDiEjAAbAImhBDHjTEfN5vNkw8//PBs/xfKOVf3f9M0zXe995cBjDHGOWdLpVIhhBgHtgArgBHgdeAW4CpgDaDAJmvtF8aYhzqdzrfOuer8OLquA6RSqd1a61XGmHettVprDYBzLhFCWGPMJuDncrn8erVavbNQKFyz2Wye6XQ6RwF836dt2581m81HnHNXd133jRBiddcCY8wW7/1w13WTUqnkOOdahULBSSm9EGKllHKVUmqllHKFUqovhOgTQvQJIXqFED1SyjYwC3Qcxzifw2az+WGhUBhpNpudtbW1x7TWnzrnXhJCbFtqQZIkh5Ik+dQY87ExZtwYM+m9P2mtPQWc6nQ6p1ut1ulu+1prbTtlNpqmabZarVONRuNU13XfOecOWmtPCCGOAkeNMd855+aXLbfAGDNhjPl69+7dW7XWu4wx9xhj7gZuB24E1gJVoAyUgBJgbLFYrBljjo2NjX2xvb39/aNHj06sXr06tm17DhgZGRnZIaW8p1KpbO3MdAY7M1kHGoTw+sDAQH9/f//qcrm8ptls/t1oNE6fP3/+1wMHDkxNTU1NlUqlcrVabf3www8AcO+991JZt25dvVGvfzs7Ozs+Ozt7dnp6+sza2tqZqampk1NTU5OTk5MT3vvJ+vr6ifvuu+8TAOcchw4dYmhoyAjhZ2sAbW3VarXJycnJqenp6dOzs7Ozs7Ozk9PT0xPT09MT9Xr9XLfdr+nKqampyenp6cnZ2dnJvr6+yfHx8S0AkiQxzjmstVq11qIsZvmm/dFrdDo/bthubr+q02ht7na7OT58+DAXL16k0Wjwl4ZCofCElPKxarW6O06cLGYJ07RAGOe9nxkfHz+2adOmN4aHh68HhqSUryilHlNKPeG9X9tFJKV8Ukp5u5TyHinl3c65ByuVygMLOgC2b9/OsqUUCGELhcJOKeU+KeXjUsrHpZRPSSn3SCl3Sil3SCm3Sym3Sym3LblAKTUipbxVSrlFSrlZSrlJSrlRSrlBSrleKaWWF2Tt+2Y5kZqX5hXjjn/TF9tGm7qCRAAAAABJRU5ErkJggg==
|
||||
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.
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container" v-if="toastStore.toasts.length > 0">
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
// Authentication is now handled by the router guard in main.ts
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'base-button',
|
||||
`base-button--${variant}`,
|
||||
`base-button--${size}`,
|
||||
{ 'base-button--loading': loading }
|
||||
]"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
@click="$emit('click', $event)"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
|
||||
<span :class="{ 'base-button__content--hidden': loading }">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
ariaLabel?: string
|
||||
ariaDescribedby?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
type: 'button',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
emit('click', event as any)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.base-button:focus-visible {
|
||||
outline: 2px solid #646cff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.base-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.base-button--sm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.base-button--md {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.base-button--lg {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.base-button--primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--primary:hover:not(:disabled) {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #f9f9f9;
|
||||
color: #213547;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.base-button--secondary:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-button--danger {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--danger:hover:not(:disabled) {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.base-button--ghost {
|
||||
background-color: transparent;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.base-button--ghost:hover:not(:disabled) {
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.base-button--loading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.base-button__spinner {
|
||||
position: absolute;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.base-button__content--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base-button--secondary {
|
||||
background-color: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.base-button--secondary:hover:not(:disabled) {
|
||||
background-color: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog">
|
||||
<div
|
||||
v-if="show"
|
||||
class="dialog-overlay"
|
||||
@click="handleOverlayClick"
|
||||
@keydown.esc="handleClose"
|
||||
role="dialog"
|
||||
:aria-labelledby="titleId"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
:class="[
|
||||
'dialog',
|
||||
`dialog--${size}`
|
||||
]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dialog__header" v-if="$slots.header || title">
|
||||
<h2 :id="titleId" class="dialog__title">
|
||||
<slot name="header">{{ title }}</slot>
|
||||
</h2>
|
||||
<button
|
||||
v-if="closable"
|
||||
class="dialog__close"
|
||||
@click="handleClose"
|
||||
aria-label="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :id="contentId" class="dialog__content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="dialog__footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title?: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
closable?: boolean
|
||||
closeOnOverlay?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
closable: true,
|
||||
closeOnOverlay: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'update:show': [value: boolean]
|
||||
}>()
|
||||
|
||||
const dialogRef = ref<HTMLDivElement>()
|
||||
const titleId = computed(() => `dialog-title-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const contentId = computed(() => `dialog-content-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (props.closeOnOverlay) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus management
|
||||
let lastFocusedElement: HTMLElement | null = null
|
||||
|
||||
const trapFocus = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return
|
||||
|
||||
const focusableElements = dialogRef.value?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
) as NodeListOf<HTMLElement>
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
lastFocusedElement = document.activeElement as HTMLElement
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.addEventListener('keydown', trapFocus)
|
||||
|
||||
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
|
||||
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus()
|
||||
} else {
|
||||
dialogRef.value?.focus()
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', trapFocus)
|
||||
|
||||
// Restore focus to the element that was focused before the dialog opened
|
||||
if (lastFocusedElement) {
|
||||
lastFocusedElement.focus()
|
||||
lastFocusedElement = null
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog--sm {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.dialog--md {
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.dialog--lg {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.dialog--xl {
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.dialog__header {
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dialog__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog__close:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dialog__close:focus-visible {
|
||||
outline: 2px solid #646cff;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dialog__content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog__footer {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-enter-active .dialog,
|
||||
.dialog-leave-active .dialog {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from .dialog,
|
||||
.dialog-leave-to .dialog {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dialog {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.dialog__title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.dialog__close {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dialog__close:hover {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<label v-if="label" :for="inputId" class="base-input__label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="base-input__required">*</span>
|
||||
</label>
|
||||
|
||||
<div class="base-input__wrapper">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:autocomplete="autocomplete"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? `${inputId}-error` : undefined"
|
||||
:class="[
|
||||
'base-input__field',
|
||||
{ 'base-input__field--error': error }
|
||||
]"
|
||||
@input="handleInput"
|
||||
@blur="$emit('blur', $event)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
@keyup="$emit('keyup', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" :id="`${inputId}-error`" class="base-input__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="helpText" class="base-input__help">
|
||||
{{ helpText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number
|
||||
type?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
autocomplete?: string
|
||||
error?: string
|
||||
helpText?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
blur: [event: FocusEvent]
|
||||
focus: [event: FocusEvent]
|
||||
keydown: [event: KeyboardEvent]
|
||||
keyup: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = props.type === 'number' ? parseFloat(target.value) || 0 : target.value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
inputRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.base-input__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.base-input__required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.base-input__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;
|
||||
}
|
||||
|
||||
.base-input__field:focus {
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.base-input__field:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.base-input__field:readonly {
|
||||
background-color: #f9fafb;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.base-input__field--error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__field--error:focus {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.base-input__error {
|
||||
font-size: 0.875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__help {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base-input__label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.base-input__field {
|
||||
background-color: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.base-input__field:disabled,
|
||||
.base-input__field:readonly {
|
||||
background-color: #1f2937;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-input__help {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<span class="icon" :class="[`icon-${name}`, sizeClass]">
|
||||
<!-- Microphone -->
|
||||
<svg v-if="name === 'microphone'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C13.1 2 14 2.9 14 4V10C14 11.1 13.1 12 12 12S10 11.1 10 10V4C10 2.9 10.9 2 12 2M19 10V12C19 15.3 16.3 18 13 18V22H11V18C7.7 18 5 15.3 5 12V10H7V12C7 14.2 8.8 16 11 16H13C15.2 16 17 14.2 17 12V10H19Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Stop -->
|
||||
<svg v-else-if="name === 'stop'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18,18H6V6H18V18Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Play -->
|
||||
<svg v-else-if="name === 'play'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Send -->
|
||||
<svg v-else-if="name === 'send'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Trash -->
|
||||
<svg v-else-if="name === 'trash'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Warning -->
|
||||
<svg v-else-if="name === 'warning'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Info -->
|
||||
<svg v-else-if="name === 'info'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Camera -->
|
||||
<svg v-else-if="name === 'camera'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4,4H7L9,2H15L17,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Upload -->
|
||||
<svg v-else-if="name === 'upload'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Close -->
|
||||
<svg v-else-if="name === 'close'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Search -->
|
||||
<svg v-else-if="name === 'search'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Plus -->
|
||||
<svg v-else-if="name === 'plus'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Settings -->
|
||||
<svg v-else-if="name === 'settings'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Default fallback -->
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md'
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => `icon-${props.size}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<header class="chat-header">
|
||||
<h2 class="chat-title">{{ channelName }}</h2>
|
||||
<div class="chat-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('search')"
|
||||
aria-label="Search messages"
|
||||
>
|
||||
🔍
|
||||
</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-header {
|
||||
background: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="file-attachment">
|
||||
<!-- Image files -->
|
||||
<ImageMessage
|
||||
v-if="isImageFile"
|
||||
:file="file"
|
||||
/>
|
||||
|
||||
<!-- Audio/voice files -->
|
||||
<VoiceMessage
|
||||
v-else-if="isAudioFile"
|
||||
:file="file"
|
||||
/>
|
||||
|
||||
<!-- Other files -->
|
||||
<FileMessage
|
||||
v-else
|
||||
:file="file"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ImageMessage from './ImageMessage.vue'
|
||||
import VoiceMessage from './VoiceMessage.vue'
|
||||
import FileMessage from './FileMessage.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (!props.file.original_name) return ''
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const isImageFile = computed(() => {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
return imageExtensions.includes(fileExtension.value)
|
||||
})
|
||||
|
||||
const isAudioFile = computed(() => {
|
||||
const audioExtensions = ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a']
|
||||
return audioExtensions.includes(fileExtension.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-attachment {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-link:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-link {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-link:hover {
|
||||
background: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div
|
||||
class="file-container"
|
||||
@click="handleFileClick"
|
||||
:class="{ 'clickable': isPreviewable }"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<Icon :name="fileIcon" size="md" />
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.original_name }}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
<span class="file-type">{{ file.file_type }}</span>
|
||||
</div>
|
||||
<div v-if="isPreviewable" class="preview-hint">
|
||||
Click to preview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click.stop="downloadFile"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File preview modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="file-modal"
|
||||
@click="showPreview = false"
|
||||
@keydown.escape="showPreview = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ file.original_name }}</h3>
|
||||
<button @click="showPreview = false" class="close-button">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<!-- Text preview -->
|
||||
<div v-if="isTextFile && previewContent" class="text-preview">
|
||||
<pre>{{ previewContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdfFile"
|
||||
:src="fileUrl"
|
||||
class="pdf-preview"
|
||||
></iframe>
|
||||
|
||||
<!-- Generic file info -->
|
||||
<div v-else class="file-details">
|
||||
<Icon :name="fileIcon" size="xl" />
|
||||
<p>Cannot preview this file type</p>
|
||||
<button @click="downloadFile" class="download-file-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewContent = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const ext = fileExtension.value
|
||||
|
||||
if (['pdf'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'table'
|
||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
||||
return 'archive'
|
||||
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
})
|
||||
|
||||
const isTextFile = computed(() => {
|
||||
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
|
||||
return textExtensions.includes(fileExtension.value)
|
||||
})
|
||||
|
||||
const isPdfFile = computed(() => {
|
||||
return fileExtension.value === 'pdf'
|
||||
})
|
||||
|
||||
const isPreviewable = computed(() => {
|
||||
return isTextFile.value || isPdfFile.value
|
||||
})
|
||||
|
||||
const handleFileClick = async () => {
|
||||
if (!isPreviewable.value) {
|
||||
downloadFile()
|
||||
return
|
||||
}
|
||||
|
||||
if (isTextFile.value && !previewContent.value) {
|
||||
await loadTextPreview()
|
||||
}
|
||||
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const loadTextPreview = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch(fileUrl.value)
|
||||
const text = await response.text()
|
||||
|
||||
// Limit preview size to prevent UI issues
|
||||
if (text.length > 50000) {
|
||||
previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
|
||||
} else {
|
||||
previewContent.value = text
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file preview:', error)
|
||||
previewContent.value = 'Error loading file preview'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
const response = await fetch(fileUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.file-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-container.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.file-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pdf-preview {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-file-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.download-file-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-container {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #4b5563;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-details {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="image-message">
|
||||
<div
|
||||
class="image-thumbnail"
|
||||
@click="showFullSize = true"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="thumbnail"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<Icon name="search" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<span class="image-name">{{ file.original_name }}</span>
|
||||
<span class="image-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Full-size image modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showFullSize"
|
||||
class="image-modal"
|
||||
@click="showFullSize = false"
|
||||
@keydown.escape="showFullSize = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="full-image"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button @click="downloadImage" class="action-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download
|
||||
</button>
|
||||
<button @click="showFullSize = false" class="action-button">
|
||||
<Icon name="x" size="sm" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showFullSize = ref(false)
|
||||
const imageError = ref(false)
|
||||
|
||||
const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imageUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = imageUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && showFullSize.value) {
|
||||
showFullSize.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-thumbnail:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: #6b7280;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.full-image {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.image-thumbnail {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="input-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('file-upload')"
|
||||
aria-label="Upload file"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📎
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('camera')"
|
||||
aria-label="Take photo"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📷
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('voice')"
|
||||
aria-label="Record voice message"
|
||||
:disabled="disabled"
|
||||
>
|
||||
🎤
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || disabled"
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
canSend?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
canSend: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
'send': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="message-input-container">
|
||||
<div class="message-input">
|
||||
<BaseTextarea
|
||||
v-model="messageText"
|
||||
placeholder="Type a message..."
|
||||
:rows="1"
|
||||
auto-resize
|
||||
@keydown="handleInputKeydown"
|
||||
@submit="handleSubmit"
|
||||
ref="textareaRef"
|
||||
/>
|
||||
|
||||
<InputActions
|
||||
:disabled="isDisabled"
|
||||
:can-send="canSend"
|
||||
@file-upload="$emit('file-upload')"
|
||||
@camera="$emit('camera')"
|
||||
@voice="$emit('voice')"
|
||||
@send="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import InputActions from './InputActions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'send-message': [content: string]
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { playWater, playSent } = useAudio()
|
||||
|
||||
const messageText = ref('')
|
||||
const textareaRef = ref()
|
||||
|
||||
const currentChannelId = computed(() => appStore.currentChannelId)
|
||||
const isDisabled = computed(() => !currentChannelId.value)
|
||||
const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSend.value) return
|
||||
|
||||
const content = messageText.value.trim()
|
||||
messageText.value = ''
|
||||
|
||||
playWater()
|
||||
emit('send-message', content)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input-container {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message-input-container {
|
||||
background: #1f2937;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'message',
|
||||
{ 'message--unsent': isUnsent }
|
||||
]"
|
||||
:data-message-id="message.id"
|
||||
:tabindex="tabindex || -1"
|
||||
:aria-label="messageAriaLabel"
|
||||
role="option"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="message__content">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
|
||||
<!-- File Attachment -->
|
||||
<div v-if="hasFileAttachment && fileAttachment" class="message__files">
|
||||
<FileAttachment :file="fileAttachment" />
|
||||
</div>
|
||||
|
||||
<div class="message__meta">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
|
||||
import FileAttachment from './FileAttachment.vue'
|
||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
|
||||
|
||||
interface Props {
|
||||
message: ExtendedMessage | UnsentMessage
|
||||
isUnsent?: boolean
|
||||
tabindex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isUnsent: false
|
||||
})
|
||||
|
||||
// Debug message structure (removed for production)
|
||||
|
||||
const { speak, playSound } = useAudio()
|
||||
const toastStore = useToastStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Check if message has a file attachment
|
||||
const hasFileAttachment = computed(() => {
|
||||
return 'fileId' in props.message && !!props.message.fileId
|
||||
})
|
||||
|
||||
// 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 || 'application/octet-stream',
|
||||
file_size: props.message.fileSize || 0,
|
||||
original_name: props.message.originalName!,
|
||||
created_at: props.message.fileCreatedAt || props.message.created_at
|
||||
}
|
||||
})
|
||||
|
||||
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||
|
||||
// Create comprehensive aria-label for screen readers
|
||||
const messageAriaLabel = computed(() => {
|
||||
let label = ''
|
||||
|
||||
// Add message content
|
||||
if (props.message.content) {
|
||||
label += props.message.content
|
||||
}
|
||||
|
||||
// Add file attachment info if present
|
||||
if (hasFileAttachment.value && fileAttachment.value) {
|
||||
const file = fileAttachment.value
|
||||
const fileType = getFileType(file.original_name)
|
||||
label += `. Has ${fileType} attachment: ${file.original_name}`
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
if ('created_at' in props.message && props.message.created_at) {
|
||||
const time = formatTimestampForScreenReader(props.message.created_at)
|
||||
label += `. Sent ${time}`
|
||||
}
|
||||
|
||||
// Add status for unsent messages
|
||||
if (props.isUnsent) {
|
||||
label += '. Message is sending'
|
||||
}
|
||||
|
||||
return label
|
||||
})
|
||||
|
||||
// Helper to determine file type for better description
|
||||
const getFileType = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'file'
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)) {
|
||||
return 'voice'
|
||||
} else if (['pdf'].includes(ext)) {
|
||||
return 'PDF document'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'Word document'
|
||||
} else if (['txt', 'md'].includes(ext)) {
|
||||
return 'text document'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
}
|
||||
|
||||
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 === '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 === 'r') {
|
||||
// Read message aloud (only when no modifiers are pressed)
|
||||
if (appStore.settings.ttsEnabled) {
|
||||
speak(props.message.content)
|
||||
toastStore.info('Reading message')
|
||||
} else {
|
||||
toastStore.info('Text-to-speech is disabled')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: #f1f3f4;
|
||||
}
|
||||
|
||||
.message:focus {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message--unsent {
|
||||
background: #fff3e0;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.message--highlighted {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.message__content {
|
||||
color: #212529;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message__files {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message__meta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message__time {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message__status {
|
||||
color: #ff9800;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.message__content {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.message__time {
|
||||
color: #a0aec0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" tabindex="0" 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"
|
||||
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
||||
@focus="focusedMessageIndex = index" />
|
||||
|
||||
<!-- 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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import type { ExtendedMessage, UnsentMessage } from '@/types'
|
||||
|
||||
interface Props {
|
||||
messages: ExtendedMessage[]
|
||||
unsentMessages: UnsentMessage[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const focusedMessageIndex = ref(0)
|
||||
|
||||
// Combined messages array for easier navigation
|
||||
const allMessages = computed(() => [...props.messages, ...props.unsentMessages])
|
||||
const totalMessages = computed(() => allMessages.value.length)
|
||||
|
||||
// ARIA labels for screen readers
|
||||
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`
|
||||
}
|
||||
})
|
||||
|
||||
const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 messages, Home/End for first/last, Enter to select'
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (totalMessages.value === 0) return
|
||||
|
||||
let newIndex = focusedMessageIndex.value
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 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 focusMessage = (index: number) => {
|
||||
focusedMessageIndex.value = index
|
||||
nextTick(() => {
|
||||
const messageElement = containerRef.value?.querySelector(`[data-message-index="${index}"]`) as HTMLElement
|
||||
if (messageElement) {
|
||||
messageElement.focus()
|
||||
messageElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectCurrentMessage = () => {
|
||||
const currentMessage = allMessages.value[focusedMessageIndex.value]
|
||||
if (currentMessage) {
|
||||
emit('message-selected', currentMessage, focusedMessageIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Method to focus a specific message (for external use, like search results)
|
||||
const focusMessageById = (messageId: string | number) => {
|
||||
const index = allMessages.value.findIndex(msg => msg.id === messageId)
|
||||
if (index !== -1) {
|
||||
focusMessage(index)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = containerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
// Reset focus when messages change significantly
|
||||
watch(() => totalMessages.value, (newTotal) => {
|
||||
if (focusedMessageIndex.value >= newTotal) {
|
||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
// Focus the last message on mount
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
focusMessageById
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.messages-container:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.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>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="voice-message">
|
||||
<div class="voice-player">
|
||||
<button
|
||||
@click="togglePlayback"
|
||||
class="play-button"
|
||||
:disabled="loading"
|
||||
>
|
||||
<Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
|
||||
</button>
|
||||
|
||||
<div class="voice-info">
|
||||
<div class="voice-waveform">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="voice-meta" aria-live="off">
|
||||
<span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="downloadVoice"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="voice-filename">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
let audio: HTMLAudioElement | null = null
|
||||
|
||||
const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const progress = computed(() => {
|
||||
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
|
||||
})
|
||||
|
||||
const togglePlayback = async () => {
|
||||
if (!audio) {
|
||||
await initAudio()
|
||||
}
|
||||
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
await audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
const initAudio = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
audio = new Audio(audioUrl.value)
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const audioDuration = audio!.duration
|
||||
duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
|
||||
})
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio!.currentTime
|
||||
})
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
})
|
||||
|
||||
await audio.load()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load audio:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!isFinite(seconds) || isNaN(seconds)) {
|
||||
return '0:00'
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadVoice = async () => {
|
||||
try {
|
||||
const response = await fetch(audioUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download voice message:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = audioUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on component unmount
|
||||
onUnmounted(() => {
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audio = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voice-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.voice-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-button:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.play-button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voice-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.voice-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-player {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<div class="camera-capture-dialog">
|
||||
<div class="camera-container">
|
||||
<!-- Camera Feed -->
|
||||
<div class="camera-feed" v-if="!capturedImage">
|
||||
<video
|
||||
ref="videoElement"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
:class="{ 'mirrored': isFrontCamera }"
|
||||
></video>
|
||||
|
||||
<!-- Camera Controls Overlay -->
|
||||
<div class="camera-overlay">
|
||||
<div class="camera-info">
|
||||
<div class="camera-status" :class="{ 'active': isStreaming }">
|
||||
<Icon name="camera" />
|
||||
<span v-if="isStreaming">Camera Active</span>
|
||||
<span v-else>Camera Inactive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Switch Camera Button -->
|
||||
<BaseButton
|
||||
v-if="availableCameras.length > 1"
|
||||
@click="switchCamera"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="switch-camera-btn"
|
||||
:disabled="!isStreaming"
|
||||
>
|
||||
<Icon name="camera" />
|
||||
Switch
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Captured Image Preview -->
|
||||
<div class="image-preview" v-if="capturedImage">
|
||||
<img
|
||||
:src="capturedImage"
|
||||
alt="Captured photo"
|
||||
class="captured-photo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="error-message" v-if="errorMessage">
|
||||
<Icon name="warning" />
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Camera Permission Info -->
|
||||
<div class="permission-info" v-if="!hasPermission && !errorMessage">
|
||||
<Icon name="info" />
|
||||
<p>Camera access is required to take photos. Please grant permission when prompted.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capture Controls -->
|
||||
<div class="capture-controls">
|
||||
<div class="capture-buttons" v-if="!capturedImage">
|
||||
<BaseButton
|
||||
@click="capturePhoto"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!isStreaming"
|
||||
class="capture-btn"
|
||||
>
|
||||
<Icon name="camera" />
|
||||
Take Photo
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="review-buttons" v-if="capturedImage">
|
||||
<BaseButton
|
||||
@click="retakePhoto"
|
||||
variant="secondary"
|
||||
>
|
||||
<Icon name="camera" />
|
||||
Retake
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="sendPhoto"
|
||||
variant="primary"
|
||||
:disabled="isSending"
|
||||
:loading="isSending"
|
||||
>
|
||||
<Icon name="send" />
|
||||
Send Photo
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Actions -->
|
||||
<div class="dialog-actions">
|
||||
<BaseButton
|
||||
@click="closeDialog"
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
sent: []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
// Refs
|
||||
const videoElement = ref<HTMLVideoElement>()
|
||||
const capturedImage = ref<string>()
|
||||
const isStreaming = ref(false)
|
||||
const hasPermission = ref(false)
|
||||
const isSending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const availableCameras = ref<MediaDeviceInfo[]>([])
|
||||
const currentCameraIndex = ref(0)
|
||||
const isFrontCamera = ref(true)
|
||||
|
||||
// Stream management
|
||||
let currentStream: MediaStream | null = null
|
||||
|
||||
// Methods
|
||||
const initializeCamera = async () => {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
|
||||
// Get available cameras
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
availableCameras.value = devices.filter(device => device.kind === 'videoinput')
|
||||
|
||||
if (availableCameras.value.length === 0) {
|
||||
throw new Error('No cameras found')
|
||||
}
|
||||
|
||||
// Start with front camera if available
|
||||
const frontCamera = availableCameras.value.find(camera =>
|
||||
camera.label.toLowerCase().includes('front') ||
|
||||
camera.label.toLowerCase().includes('user')
|
||||
)
|
||||
|
||||
if (frontCamera) {
|
||||
currentCameraIndex.value = availableCameras.value.indexOf(frontCamera)
|
||||
isFrontCamera.value = true
|
||||
} else {
|
||||
currentCameraIndex.value = 0
|
||||
isFrontCamera.value = false
|
||||
}
|
||||
|
||||
await startCamera()
|
||||
hasPermission.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize camera:', error)
|
||||
errorMessage.value = 'Failed to access camera. Please check permissions and try again.'
|
||||
hasPermission.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
// Stop current stream if exists
|
||||
if (currentStream) {
|
||||
currentStream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
deviceId: availableCameras.value[currentCameraIndex.value]?.deviceId,
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: isFrontCamera.value ? 'user' : 'environment'
|
||||
}
|
||||
}
|
||||
|
||||
currentStream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
|
||||
if (videoElement.value) {
|
||||
videoElement.value.srcObject = currentStream
|
||||
isStreaming.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start camera:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const switchCamera = async () => {
|
||||
if (availableCameras.value.length <= 1) return
|
||||
|
||||
currentCameraIndex.value = (currentCameraIndex.value + 1) % availableCameras.value.length
|
||||
|
||||
// Determine if this is likely a front camera
|
||||
const currentCamera = availableCameras.value[currentCameraIndex.value]
|
||||
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') ||
|
||||
currentCamera.label.toLowerCase().includes('user')
|
||||
|
||||
try {
|
||||
await startCamera()
|
||||
} catch (error) {
|
||||
console.error('Failed to switch camera:', error)
|
||||
toastStore.error('Failed to switch camera')
|
||||
}
|
||||
}
|
||||
|
||||
const capturePhoto = () => {
|
||||
if (!videoElement.value || !isStreaming.value) return
|
||||
|
||||
try {
|
||||
// Create canvas to capture frame
|
||||
const canvas = document.createElement('canvas')
|
||||
const video = videoElement.value
|
||||
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Failed to get canvas context')
|
||||
|
||||
// Flip horizontally for front camera
|
||||
if (isFrontCamera.value) {
|
||||
ctx.scale(-1, 1)
|
||||
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height)
|
||||
} else {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
// Convert to data URL
|
||||
capturedImage.value = canvas.toDataURL('image/jpeg', 0.8)
|
||||
|
||||
// Stop camera stream
|
||||
stopCamera()
|
||||
|
||||
toastStore.success('Photo captured!')
|
||||
} catch (error) {
|
||||
console.error('Failed to capture photo:', error)
|
||||
toastStore.error('Failed to capture photo')
|
||||
}
|
||||
}
|
||||
|
||||
const retakePhoto = () => {
|
||||
capturedImage.value = undefined
|
||||
initializeCamera()
|
||||
}
|
||||
|
||||
const sendPhoto = async () => {
|
||||
if (!capturedImage.value) return
|
||||
|
||||
isSending.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Create a message first to attach the photo to
|
||||
const message = await apiService.createMessage(appStore.currentChannelId!, 'Photo')
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(capturedImage.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
// Create file from blob
|
||||
const file = new File([blob], `photo-${Date.now()}.jpg`, {
|
||||
type: 'image/jpeg'
|
||||
})
|
||||
|
||||
// Upload photo
|
||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||
|
||||
// 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,
|
||||
fileSize: uploadedFile.file_size,
|
||||
originalName: uploadedFile.original_name,
|
||||
fileCreatedAt: uploadedFile.created_at
|
||||
}
|
||||
|
||||
// Add the complete message to the store (this will trigger immediate UI update)
|
||||
appStore.addMessage(completeMessage)
|
||||
|
||||
toastStore.success('Photo sent!')
|
||||
emit('sent')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to send photo:', error)
|
||||
errorMessage.value = 'Failed to send photo. Please try again.'
|
||||
toastStore.error('Failed to send photo')
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
if (currentStream) {
|
||||
currentStream.getTracks().forEach(track => track.stop())
|
||||
currentStream = null
|
||||
}
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
stopCamera()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
initializeCamera()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCamera()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-capture-dialog {
|
||||
padding: 1rem 0;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.camera-feed {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.camera-feed video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-feed video.mirrored {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.camera-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.3), transparent);
|
||||
}
|
||||
|
||||
.camera-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.camera-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.camera-status.active {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.switch-camera-btn {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.captured-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
color: #0369a1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.permission-info p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.capture-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.capture-buttons, .review-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.error-message {
|
||||
background: #7f1d1d;
|
||||
border-color: #991b1b;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
background: #1e3a8a;
|
||||
border-color: #3b82f6;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 640px) {
|
||||
.camera-capture-dialog {
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.camera-feed, .image-preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.camera-overlay {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.capture-buttons, .review-buttons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="channel-info-dialog">
|
||||
<div class="info-section">
|
||||
<BaseInput
|
||||
v-model="channelName"
|
||||
label="Channel name"
|
||||
placeholder="Enter channel name"
|
||||
ref="nameInput"
|
||||
/>
|
||||
|
||||
<BaseInput
|
||||
v-model="channelIdDisplay"
|
||||
label="Channel ID (for API use)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<div class="action-group">
|
||||
<h3>Channel Actions</h3>
|
||||
|
||||
<BaseButton
|
||||
@click="makeDefault"
|
||||
variant="secondary"
|
||||
:disabled="isDefault"
|
||||
>
|
||||
{{ isDefault ? 'Already Default' : 'Make Default Channel' }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="showMergeDialog = true"
|
||||
variant="secondary"
|
||||
:disabled="availableChannels.length === 0"
|
||||
>
|
||||
Merge Channel
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="showDeleteConfirm = true"
|
||||
variant="danger"
|
||||
>
|
||||
Delete Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<BaseButton @click="cancel" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton @click="save" :loading="saving">
|
||||
Save Changes
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Merge Channel Dialog -->
|
||||
<BaseDialog v-model:show="showMergeDialog" title="Merge Channel" size="md">
|
||||
<div class="merge-dialog">
|
||||
<p class="merge-warning">
|
||||
This will move all messages from "{{ channel.name }}" into the selected target channel,
|
||||
then delete this channel. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="merge-form">
|
||||
<label for="target-channel">Merge into:</label>
|
||||
<select
|
||||
id="target-channel"
|
||||
v-model="selectedTargetChannel"
|
||||
class="target-select"
|
||||
>
|
||||
<option value="">Select target channel...</option>
|
||||
<option
|
||||
v-for="ch in availableChannels"
|
||||
:key="ch.id"
|
||||
:value="ch.id"
|
||||
>
|
||||
{{ ch.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<BaseButton @click="showMergeDialog = false" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="performMerge"
|
||||
variant="danger"
|
||||
:disabled="!selectedTargetChannel"
|
||||
:loading="merging"
|
||||
>
|
||||
Merge Channels
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<BaseDialog v-model:show="showDeleteConfirm" title="Delete Channel" size="md">
|
||||
<div class="delete-dialog">
|
||||
<p class="delete-warning">
|
||||
Are you sure you want to delete "{{ channel.name }}"?
|
||||
This will permanently delete all messages in this channel.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="delete-actions">
|
||||
<BaseButton @click="showDeleteConfirm = false" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="performDelete"
|
||||
variant="danger"
|
||||
:loading="deleting"
|
||||
>
|
||||
Delete Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import { syncService } from '@/services/sync'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseDialog from '@/components/base/BaseDialog.vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
interface Props {
|
||||
channel: Channel
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'channel-updated': [channel: Channel]
|
||||
'channel-deleted': [channelId: number]
|
||||
'channel-merged': [sourceId: number, targetId: number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
// Form state
|
||||
const channelName = ref(props.channel.name)
|
||||
const channelIdDisplay = ref(props.channel.id.toString())
|
||||
const saving = ref(false)
|
||||
|
||||
// Dialog states
|
||||
const showMergeDialog = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const selectedTargetChannel = ref<number | null>(null)
|
||||
const merging = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Input ref for focus
|
||||
const nameInput = ref()
|
||||
|
||||
// Computed properties
|
||||
const isDefault = computed(() =>
|
||||
appStore.settings.defaultChannelId === props.channel.id
|
||||
)
|
||||
|
||||
const availableChannels = computed(() =>
|
||||
appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
const makeDefault = async () => {
|
||||
try {
|
||||
await appStore.updateSettings({ defaultChannelId: props.channel.id })
|
||||
toastStore.success(`${props.channel.name} is now the default channel`)
|
||||
} catch (error) {
|
||||
console.error('Failed to set default channel:', error)
|
||||
toastStore.error('Failed to set default channel')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!channelName.value.trim()) {
|
||||
toastStore.error('Channel name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// Try online update first
|
||||
try {
|
||||
await apiService.updateChannel(props.channel.id, channelName.value.trim())
|
||||
// Update local store
|
||||
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
|
||||
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
|
||||
if (channelIndex !== -1) {
|
||||
appStore.channels[channelIndex] = updatedChannel
|
||||
await appStore.saveState()
|
||||
}
|
||||
emit('channel-updated', updatedChannel)
|
||||
toastStore.success('Channel updated successfully')
|
||||
} catch (error) {
|
||||
// Offline fallback - update locally only
|
||||
console.log('Offline mode: updating channel locally')
|
||||
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
|
||||
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
|
||||
if (channelIndex !== -1) {
|
||||
appStore.channels[channelIndex] = updatedChannel
|
||||
await appStore.saveState()
|
||||
}
|
||||
emit('channel-updated', updatedChannel)
|
||||
toastStore.success('Channel updated locally (will sync when online)')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to update channel:', error)
|
||||
toastStore.error('Failed to update channel')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performMerge = async () => {
|
||||
if (!selectedTargetChannel.value) return
|
||||
|
||||
try {
|
||||
merging.value = true
|
||||
|
||||
// Try online merge first
|
||||
try {
|
||||
await apiService.mergeChannels(props.channel.id, selectedTargetChannel.value)
|
||||
// Remove source channel from local store
|
||||
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
// Clear messages for the merged channel
|
||||
delete appStore.messages[props.channel.id]
|
||||
await appStore.saveState()
|
||||
|
||||
emit('channel-merged', props.channel.id, selectedTargetChannel.value)
|
||||
toastStore.success('Channels merged successfully')
|
||||
|
||||
// Switch to target channel if we were on the source channel
|
||||
if (appStore.currentChannelId === props.channel.id) {
|
||||
await appStore.setCurrentChannel(selectedTargetChannel.value)
|
||||
}
|
||||
} catch (error) {
|
||||
// For merge, we can't do offline fallback easily since it affects multiple channels
|
||||
console.error('Failed to merge channels:', error)
|
||||
toastStore.error('Failed to merge channels - this requires an internet connection')
|
||||
}
|
||||
|
||||
showMergeDialog.value = false
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to merge channels:', error)
|
||||
toastStore.error('Failed to merge channels')
|
||||
} finally {
|
||||
merging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performDelete = async () => {
|
||||
try {
|
||||
deleting.value = true
|
||||
|
||||
// Try online delete first
|
||||
try {
|
||||
await apiService.deleteChannel(props.channel.id)
|
||||
// Remove from local store
|
||||
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
delete appStore.messages[props.channel.id]
|
||||
await appStore.saveState()
|
||||
|
||||
emit('channel-deleted', props.channel.id)
|
||||
toastStore.success('Channel deleted successfully')
|
||||
|
||||
// Switch to first available channel if we were on the deleted channel
|
||||
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
|
||||
await appStore.setCurrentChannel(appStore.channels[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
// For delete, we can't do offline fallback easily since it affects server state
|
||||
console.error('Failed to delete channel:', error)
|
||||
toastStore.error('Failed to delete channel - this requires an internet connection')
|
||||
}
|
||||
|
||||
showDeleteConfirm.value = false
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error)
|
||||
toastStore.error('Failed to delete channel')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.channel-info-dialog {
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.action-group h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Merge Dialog Styles */
|
||||
.merge-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.merge-warning {
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.merge-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.merge-form label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.target-select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.target-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.merge-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Delete Dialog Styles */
|
||||
.delete-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 6px;
|
||||
color: #dc2626;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.actions-section {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.action-group h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.merge-warning {
|
||||
background: #451a03;
|
||||
border-color: #92400e;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
background: #450a0a;
|
||||
border-color: #dc2626;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.merge-form label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.target-select {
|
||||
background: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.target-select:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="create-channel-dialog">
|
||||
<form @submit.prevent="handleSubmit" class="channel-form">
|
||||
<BaseInput
|
||||
v-model="channelName"
|
||||
label="Channel Name"
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
:error="error"
|
||||
:disabled="isLoading"
|
||||
ref="nameInput"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isLoading"
|
||||
:disabled="!channelName.trim()"
|
||||
>
|
||||
Create Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
created: [channelId: number]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const channelName = ref('')
|
||||
const error = ref('')
|
||||
const isLoading = ref(false)
|
||||
const nameInput = ref()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!channelName.value.trim()) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const newChannel = await apiService.createChannel(channelName.value.trim())
|
||||
appStore.addChannel(newChannel)
|
||||
toastStore.success(`Channel "${newChannel.name}" created successfully!`)
|
||||
emit('created', newChannel.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel:', err)
|
||||
error.value = 'Failed to create channel. Please try again.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-channel-dialog {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="file-upload-dialog">
|
||||
<div class="upload-area"
|
||||
:class="{ 'upload-area--dragging': isDragging }"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop">
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="handleFileSelect"
|
||||
class="file-input"
|
||||
:disabled="isUploading"
|
||||
multiple
|
||||
/>
|
||||
|
||||
<div v-if="!selectedFiles.length" class="upload-prompt">
|
||||
<div class="upload-icon">📎</div>
|
||||
<p>Click to select files or drag and drop</p>
|
||||
<p class="upload-hint">All file types supported</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="selected-files">
|
||||
<h4>Selected Files:</h4>
|
||||
<div class="file-list">
|
||||
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||
<button
|
||||
@click="removeFile(index)"
|
||||
class="remove-file"
|
||||
:disabled="isUploading"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadProgress.length > 0" class="upload-progress">
|
||||
<div v-for="(progress, index) in uploadProgress" :key="index" class="progress-item">
|
||||
<div class="progress-label">{{ selectedFiles[index]?.name }}</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progress }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="uploadFiles"
|
||||
:loading="isUploading"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
>
|
||||
Upload {{ selectedFiles.length }} file{{ selectedFiles.length === 1 ? '' : 's' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadProgress = ref<number[]>([])
|
||||
const isDragging = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleDragOver = () => {
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false
|
||||
const files = Array.from(event.dataTransfer?.files || [])
|
||||
addFiles(files)
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || [])
|
||||
addFiles(files)
|
||||
}
|
||||
|
||||
const addFiles = (files: File[]) => {
|
||||
selectedFiles.value.push(...files)
|
||||
uploadProgress.value = new Array(selectedFiles.value.length).fill(0)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
uploadProgress.value.splice(index, 1)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
const uploadFiles = async () => {
|
||||
if (!appStore.currentChannelId || selectedFiles.value.length === 0) return
|
||||
|
||||
isUploading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// For single file, use the filename as message content
|
||||
// For multiple files, show count
|
||||
const messageContent = selectedFiles.value.length === 1
|
||||
? selectedFiles.value[0].name
|
||||
: `Uploaded ${selectedFiles.value.length} files`
|
||||
|
||||
// Create a message first to attach files to
|
||||
const message = await apiService.createMessage(appStore.currentChannelId, messageContent)
|
||||
|
||||
// Upload the first file (backend uses single file per message)
|
||||
const file = selectedFiles.value[0]
|
||||
|
||||
try {
|
||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file)
|
||||
uploadProgress.value[0] = 100
|
||||
|
||||
// Immediately update the local message with file metadata
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
fileId: uploadedFile.id,
|
||||
filePath: uploadedFile.file_path,
|
||||
fileType: uploadedFile.file_type,
|
||||
fileSize: uploadedFile.file_size,
|
||||
originalName: uploadedFile.original_name,
|
||||
fileCreatedAt: uploadedFile.created_at
|
||||
}
|
||||
|
||||
// Update the message in the store
|
||||
appStore.updateMessage(message.id, updatedMessage)
|
||||
|
||||
toastStore.success('File uploaded successfully!')
|
||||
|
||||
} catch (fileError) {
|
||||
console.error(`Failed to upload ${file.name}:`, fileError)
|
||||
toastStore.error(`Failed to upload ${file.name}`)
|
||||
uploadProgress.value[0] = 0
|
||||
}
|
||||
|
||||
emit('uploaded')
|
||||
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
error.value = 'Upload failed. Please try again.'
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-dialog {
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area--dragging {
|
||||
border-color: #646cff;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selected-files h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.remove-file {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove-file:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 2;
|
||||
height: 0.5rem;
|
||||
background: #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #646cff;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.upload-area {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area--dragging {
|
||||
border-color: #646cff;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #422006;
|
||||
border-color: #92400e;
|
||||
color: #fbbf24;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="search-dialog">
|
||||
<div class="search-form">
|
||||
<BaseInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search messages..."
|
||||
@keydown.enter="performSearch"
|
||||
ref="searchInput"
|
||||
/>
|
||||
|
||||
<div class="search-filters">
|
||||
<select
|
||||
v-model="selectedChannelId"
|
||||
class="channel-filter"
|
||||
>
|
||||
<option :value="null">All channels</option>
|
||||
<option
|
||||
v-for="channel in appStore.channels"
|
||||
:key="channel.id"
|
||||
:value="channel.id"
|
||||
>
|
||||
{{ channel.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<BaseButton
|
||||
@click="performSearch"
|
||||
:loading="isSearching"
|
||||
:disabled="!searchQuery.trim()"
|
||||
>
|
||||
Search
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isSearching" class="search-loading">
|
||||
Searching...
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0" class="search-results">
|
||||
<div class="results-header">
|
||||
Found {{ searchResults.length }} result{{ searchResults.length === 1 ? '' : 's' }}
|
||||
</div>
|
||||
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="result in searchResults"
|
||||
:key="`${result.channel_id}-${result.id}`"
|
||||
class="result-item"
|
||||
@click="goToMessage(result)"
|
||||
tabindex="0"
|
||||
@keydown.enter="goToMessage(result)"
|
||||
>
|
||||
<div class="result-channel">
|
||||
{{ getChannelName(result.channel_id) }}
|
||||
</div>
|
||||
<div class="result-content">
|
||||
{{ result.content }}
|
||||
</div>
|
||||
<div class="result-time">
|
||||
{{ formatSmartTimestamp(result.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasSearched && searchResults.length === 0" class="no-results">
|
||||
No messages found for "{{ searchQuery }}"
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="search-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'select-message': [message: ExtendedMessage]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedChannelId = ref<number | null>(null)
|
||||
const searchResults = ref<ExtendedMessage[]>([])
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const error = ref('')
|
||||
const searchInput = ref()
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return
|
||||
|
||||
isSearching.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await apiService.search(
|
||||
searchQuery.value.trim(),
|
||||
selectedChannelId.value || undefined
|
||||
)
|
||||
|
||||
// Transform search results to match expected format
|
||||
searchResults.value = response.results.map((result: any) => ({
|
||||
...result,
|
||||
channel_id: result.channelId || result.channel_id,
|
||||
created_at: result.createdAt || result.created_at
|
||||
})) as ExtendedMessage[]
|
||||
|
||||
console.log('Search results transformed:', searchResults.value)
|
||||
hasSearched.value = true
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err)
|
||||
error.value = 'Search failed. Please try again.'
|
||||
toastStore.error('Search failed')
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToMessage = (message: ExtendedMessage) => {
|
||||
emit('select-message', message)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const getChannelName = (channelId: number): string => {
|
||||
if (!channelId) return 'Unknown Channel'
|
||||
const channel = appStore.channels.find(c => c.id === channelId)
|
||||
return channel?.name || `Channel ${channelId}`
|
||||
}
|
||||
|
||||
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-dialog {
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.channel-filter {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.channel-filter:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.result-item:hover,
|
||||
.result-item:focus {
|
||||
background: #f9fafb;
|
||||
border-color: #646cff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.result-channel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #646cff;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search-error {
|
||||
padding: 0.75rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.channel-filter {
|
||||
background: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.result-item:hover,
|
||||
.result-item:focus {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.search-error {
|
||||
background: #422006;
|
||||
border-color: #92400e;
|
||||
color: #fbbf24;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,547 @@
|
||||
<template>
|
||||
<div class="settings-dialog">
|
||||
<form @submit.prevent="handleSave" class="settings-form">
|
||||
<div class="setting-group">
|
||||
<h3>Audio Settings</h3>
|
||||
|
||||
<label class="setting-item">
|
||||
<input
|
||||
ref="soundInput"
|
||||
type="checkbox"
|
||||
v-model="localSettings.soundEnabled"
|
||||
class="checkbox"
|
||||
/>
|
||||
<span>Enable sound effects</span>
|
||||
</label>
|
||||
|
||||
<label class="setting-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="localSettings.speechEnabled"
|
||||
class="checkbox"
|
||||
/>
|
||||
<span>Enable speech synthesis (deprecated)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Text-to-Speech</h3>
|
||||
|
||||
<label class="setting-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="localSettings.ttsEnabled"
|
||||
class="checkbox"
|
||||
/>
|
||||
<span>Enable text-to-speech announcements</span>
|
||||
</label>
|
||||
|
||||
<div class="setting-item" v-if="localSettings.ttsEnabled">
|
||||
<label for="voice-select">Voice</label>
|
||||
<select
|
||||
id="voice-select"
|
||||
v-model="selectedVoiceURI"
|
||||
class="select"
|
||||
@change="handleVoiceChange"
|
||||
>
|
||||
<option value="" disabled>Select a voice...</option>
|
||||
<option
|
||||
v-for="voice in availableVoices"
|
||||
:key="voice.voiceURI"
|
||||
:value="voice.voiceURI"
|
||||
>
|
||||
{{ voice.name }} ({{ voice.lang }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" v-if="localSettings.ttsEnabled">
|
||||
<label for="rate-slider">Speech Rate: {{ localSettings.ttsRate.toFixed(1) }}</label>
|
||||
<input
|
||||
id="rate-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
v-model.number="localSettings.ttsRate"
|
||||
class="slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" v-if="localSettings.ttsEnabled">
|
||||
<label for="pitch-slider">Speech Pitch: {{ localSettings.ttsPitch.toFixed(1) }}</label>
|
||||
<input
|
||||
id="pitch-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
v-model.number="localSettings.ttsPitch"
|
||||
class="slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" v-if="localSettings.ttsEnabled">
|
||||
<label for="volume-slider">Speech Volume: {{ localSettings.ttsVolume.toFixed(1) }}</label>
|
||||
<input
|
||||
id="volume-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
v-model.number="localSettings.ttsVolume"
|
||||
class="slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" v-if="localSettings.ttsEnabled">
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="testSpeech"
|
||||
:disabled="!selectedVoiceURI"
|
||||
>
|
||||
Test Speech
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>Appearance</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="theme-select">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
v-model="localSettings.theme"
|
||||
class="select"
|
||||
>
|
||||
<option value="auto">Auto (System)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group" v-if="appStore.channels.length > 0">
|
||||
<h3>Default Channel</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="default-channel-select">Default Channel</label>
|
||||
<select
|
||||
id="default-channel-select"
|
||||
v-model="localSettings.defaultChannelId"
|
||||
class="select"
|
||||
>
|
||||
<option :value="null">None</option>
|
||||
<option
|
||||
v-for="channel in appStore.channels"
|
||||
:key="channel.id"
|
||||
:value="channel.id"
|
||||
>
|
||||
{{ channel.name }}
|
||||
</option>
|
||||
</select>
|
||||
</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"
|
||||
variant="secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Save Settings
|
||||
</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, 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'
|
||||
|
||||
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,
|
||||
ttsEnabled: true,
|
||||
ttsRate: 1,
|
||||
ttsPitch: 1,
|
||||
ttsVolume: 1,
|
||||
selectedVoiceURI: null,
|
||||
defaultChannelId: null,
|
||||
theme: 'auto'
|
||||
})
|
||||
|
||||
const handleVoiceChange = () => {
|
||||
const voice = availableVoices.value.find(v => v.voiceURI === selectedVoiceURI.value)
|
||||
if (voice) {
|
||||
setVoice(voice)
|
||||
localSettings.selectedVoiceURI = voice.voiceURI
|
||||
}
|
||||
}
|
||||
|
||||
const testSpeech = async () => {
|
||||
try {
|
||||
await speak('This is a test of the text-to-speech system.', {
|
||||
rate: localSettings.ttsRate,
|
||||
pitch: localSettings.ttsPitch,
|
||||
volume: localSettings.ttsVolume
|
||||
})
|
||||
} catch (error) {
|
||||
toastStore.error('Speech test failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await appStore.updateSettings(localSettings)
|
||||
toastStore.success('Settings saved successfully!')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error)
|
||||
toastStore.error('Failed to save settings')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.settings-dialog {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-group h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
accent-color: #646cff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #e5e7eb;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #646cff;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #646cff;
|
||||
cursor: pointer;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
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 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.select {
|
||||
background: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<div class="voice-recording-dialog">
|
||||
<div class="recording-container">
|
||||
<!-- Recording Status -->
|
||||
<div class="recording-status">
|
||||
<div class="status-indicator" :class="{ 'recording': recording.isRecording, 'has-recording': recording.blob }">
|
||||
<div class="pulse" v-if="recording.isRecording"></div>
|
||||
<Icon name="microphone" />
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<h3 v-if="recording.isRecording">Recording...</h3>
|
||||
<h3 v-else-if="recording.blob">Recording Complete</h3>
|
||||
<h3 v-else>Ready to Record</h3>
|
||||
<p class="duration">{{ recordingDurationFormatted }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waveform Visualization (placeholder) -->
|
||||
<div class="waveform" v-if="recording.isRecording">
|
||||
<div class="wave-bar" v-for="i in 20" :key="i" :style="{ height: getWaveHeight(i) + 'px' }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Playback Controls -->
|
||||
<div class="playback-controls" v-if="recording.blob">
|
||||
<div class="progress-bar">
|
||||
<div class="progress" :style="{ width: playbackProgress + '%' }"></div>
|
||||
</div>
|
||||
<div class="playback-time">
|
||||
{{ formatTime(recording.currentTime) }} / {{ formatTime(recording.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="controls">
|
||||
<BaseButton
|
||||
v-if="!recording.isRecording && !recording.blob"
|
||||
@click="startRecording"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!canRecord"
|
||||
class="record-btn"
|
||||
>
|
||||
<Icon name="microphone" />
|
||||
Start Recording
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="recording.isRecording"
|
||||
@click="stopRecording"
|
||||
variant="danger"
|
||||
size="lg"
|
||||
class="stop-btn"
|
||||
>
|
||||
<Icon name="stop" />
|
||||
Stop Recording
|
||||
</BaseButton>
|
||||
|
||||
<div class="playback-buttons" v-if="recording.blob && !recording.isRecording">
|
||||
<BaseButton
|
||||
@click="playRecording"
|
||||
variant="secondary"
|
||||
:disabled="recording.isPlaying"
|
||||
>
|
||||
<Icon name="play" />
|
||||
Play
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="clearRecording"
|
||||
variant="secondary"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
Clear
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="startRecording"
|
||||
variant="secondary"
|
||||
>
|
||||
<Icon name="microphone" />
|
||||
Re-record
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="error-message" v-if="errorMessage">
|
||||
<Icon name="warning" />
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Microphone Permission Info -->
|
||||
<div class="permission-info" v-if="!canRecord">
|
||||
<Icon name="info" />
|
||||
<p>Microphone access is required for voice recording. Please grant permission when prompted.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Actions -->
|
||||
<div class="dialog-actions">
|
||||
<BaseButton
|
||||
@click="$emit('close')"
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="sendVoiceMessage"
|
||||
variant="primary"
|
||||
:disabled="!recording.blob || isSending"
|
||||
:loading="isSending"
|
||||
>
|
||||
<Icon name="send" />
|
||||
Send Voice Message
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
sent: []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
const {
|
||||
recording,
|
||||
canRecord,
|
||||
recordingDurationFormatted,
|
||||
startRecording: startAudioRecording,
|
||||
stopRecording: stopAudioRecording,
|
||||
playRecording,
|
||||
clearRecording
|
||||
} = useAudio()
|
||||
|
||||
const isSending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const waveAnimation = ref<number[]>([])
|
||||
|
||||
// Computed
|
||||
const playbackProgress = computed(() => {
|
||||
if (!recording.value.duration) return 0
|
||||
return (recording.value.currentTime / recording.value.duration) * 100
|
||||
})
|
||||
|
||||
// Methods
|
||||
const startRecording = async () => {
|
||||
errorMessage.value = ''
|
||||
const success = await startAudioRecording()
|
||||
if (!success) {
|
||||
errorMessage.value = 'Failed to start recording. Please check microphone permissions.'
|
||||
} else {
|
||||
startWaveAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
stopAudioRecording()
|
||||
stopWaveAnimation()
|
||||
}
|
||||
|
||||
const sendVoiceMessage = async () => {
|
||||
if (!recording.value.blob) return
|
||||
|
||||
isSending.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Create a message first to attach the voice file to
|
||||
const message = await apiService.createMessage(appStore.currentChannelId!, 'Voice message')
|
||||
|
||||
// Create file from blob
|
||||
const file = new File([recording.value.blob!], `voice-${Date.now()}.webm`, {
|
||||
type: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
// Upload voice file
|
||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||
|
||||
// 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,
|
||||
fileSize: uploadedFile.file_size,
|
||||
originalName: uploadedFile.original_name,
|
||||
fileCreatedAt: uploadedFile.created_at
|
||||
}
|
||||
|
||||
// Add the complete message to the store (this will trigger immediate UI update)
|
||||
appStore.addMessage(completeMessage)
|
||||
|
||||
toastStore.success('Voice message sent!')
|
||||
clearRecording()
|
||||
emit('sent')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to send voice message:', error)
|
||||
errorMessage.value = 'Failed to send voice message. Please try again.'
|
||||
toastStore.error('Failed to send voice message')
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Waveform animation
|
||||
let animationInterval: number | null = null
|
||||
|
||||
const startWaveAnimation = () => {
|
||||
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
|
||||
animationInterval = setInterval(() => {
|
||||
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const stopWaveAnimation = () => {
|
||||
if (animationInterval) {
|
||||
clearInterval(animationInterval)
|
||||
animationInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const getWaveHeight = (index: number): number => {
|
||||
return waveAnimation.value[index] || 20
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
stopWaveAnimation()
|
||||
})
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
// Clear any existing recording when dialog opens
|
||||
if (recording.value.blob) {
|
||||
clearRecording()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voice-recording-dialog {
|
||||
padding: 1rem 0;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.recording-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.recording-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: #6b7280;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-indicator.recording {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator.has-recording {
|
||||
background: #059669;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #dc2626;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-text h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.duration {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 3px;
|
||||
height: 60px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 4px;
|
||||
background: linear-gradient(to top, #dc2626, #f87171);
|
||||
border-radius: 2px;
|
||||
transition: height 0.1s ease;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #059669;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.playback-time {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playback-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.permission-info p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.status-text h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.playback-time {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #7f1d1d;
|
||||
border-color: #991b1b;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
background: #1e3a8a;
|
||||
border-color: #3b82f6;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="channel-list-container" ref="containerRef">
|
||||
<ul class="channel-list" role="listbox" aria-label="Channels">
|
||||
<ChannelListItem
|
||||
v-for="(channel, index) in channels"
|
||||
:key="channel.id"
|
||||
:channel="channel"
|
||||
:is-active="channel.id === currentChannelId"
|
||||
:unread-count="unreadCounts[channel.id]"
|
||||
: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'
|
||||
|
||||
interface Props {
|
||||
channels: Channel[]
|
||||
currentChannelId: number | null
|
||||
unreadCounts: Record<number, number>
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-channel': [channelId: number]
|
||||
'channel-info': [channel: Channel]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const focusedChannelIndex = ref(0)
|
||||
|
||||
// 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:
|
||||
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' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 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>
|
||||
.channel-list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
.channel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.channel-list-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.channel-list-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-list-container::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.channel-list-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.channel-list-container::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.channel-list-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'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)"
|
||||
@focus="handleFocus"
|
||||
role="option"
|
||||
:aria-current="isActive"
|
||||
aria-selected="true"
|
||||
@keydown="handleKeydown"
|
||||
:tabindex="tabindex"
|
||||
:aria-label="channelAriaLabel"
|
||||
>
|
||||
<span class="channel-name">{{ channel.name }}</span>
|
||||
<span v-if="unreadCount" class="channel-unread">
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button v-if="isActive"
|
||||
class="channel-info-button"
|
||||
@click.stop="$emit('info', channel)"
|
||||
:aria-label="`Channel info for ${channel.name}`"
|
||||
title="Channel info"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</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
|
||||
}
|
||||
|
||||
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>
|
||||
.channel-item {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channel-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.channel-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
margin: 0 0.5rem 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.channel-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.channel-button:focus {
|
||||
outline: none;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.channel-item--active .channel-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-item--active .channel-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-unread {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-item--active .channel-unread {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.channel-info-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-info-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.channel-info-button:focus {
|
||||
outline: none;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.channel-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.channel-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.channel-button:focus {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: #60a5fa;
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.channel-item--active .channel-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-item--active .channel-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.channel-info-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.channel-info-button:focus {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<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>
|
||||
|
||||
<div class="sidebar__content">
|
||||
<ChannelList
|
||||
:channels="channels"
|
||||
:current-channel-id="currentChannelId"
|
||||
:unread-counts="unreadCounts"
|
||||
@select-channel="$emit('select-channel', $event)"
|
||||
@channel-info="$emit('channel-info', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('settings')"
|
||||
aria-label="Open settings"
|
||||
>
|
||||
⚙️
|
||||
</BaseButton>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ChannelList from './ChannelList.vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
interface Props {
|
||||
channels: Channel[]
|
||||
currentChannelId: number | null
|
||||
unreadCounts: Record<number, number>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'create-channel': []
|
||||
'select-channel': [channelId: number]
|
||||
'channel-info': [channel: Channel]
|
||||
'settings': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #f9fafb;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.sidebar__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar {
|
||||
background: #1f2937;
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
background: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
background: #1f2937;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface AudioRecording {
|
||||
blob: Blob | null
|
||||
duration: number
|
||||
isRecording: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
// Global audio state to ensure singleton behavior
|
||||
let audioSystemInitialized = false
|
||||
let soundsLoaded = false
|
||||
let globalAudioContext: AudioContext | null = null
|
||||
let globalSoundBuffers = new Map<string, AudioBuffer>()
|
||||
let globalWaterSounds: AudioBuffer[] = []
|
||||
let globalSentSounds: AudioBuffer[] = []
|
||||
|
||||
export function useAudio() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Audio Context (use global instance)
|
||||
const audioContext = ref<AudioContext | null>(globalAudioContext)
|
||||
|
||||
// Sound buffers (use global arrays)
|
||||
const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
|
||||
const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
|
||||
const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
|
||||
|
||||
// Recording state
|
||||
const recording = ref<AudioRecording>({
|
||||
blob: null,
|
||||
duration: 0,
|
||||
isRecording: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
})
|
||||
|
||||
// Media recorder
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let recordingChunks: Blob[] = []
|
||||
let recordingStartTime: number = 0
|
||||
let recordingInterval: number | null = null
|
||||
|
||||
// Text-to-speech state
|
||||
const isSpeaking = ref(false)
|
||||
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
||||
const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
|
||||
|
||||
// Initialize audio context
|
||||
const initAudioContext = async () => {
|
||||
if (!globalAudioContext) {
|
||||
globalAudioContext = new AudioContext()
|
||||
audioContext.value = globalAudioContext
|
||||
}
|
||||
|
||||
if (globalAudioContext.state === 'suspended') {
|
||||
try {
|
||||
await globalAudioContext.resume()
|
||||
} catch (error) {
|
||||
console.warn('AudioContext resume failed, user interaction required:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load a single sound file
|
||||
const loadSound = async (url: string): Promise<AudioBuffer | null> => {
|
||||
try {
|
||||
if (!audioContext.value) {
|
||||
await initAudioContext()
|
||||
}
|
||||
|
||||
if (!audioContext.value) {
|
||||
// AudioContext creation failed (probably no user interaction yet)
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
return await audioContext.value.decodeAudioData(arrayBuffer)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load sound ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Load all sound files
|
||||
const loadAllSounds = async () => {
|
||||
if (soundsLoaded) {
|
||||
console.log('Sounds already loaded, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting to load all sounds...')
|
||||
soundsLoaded = true
|
||||
|
||||
// Load basic sounds
|
||||
const basicSounds = {
|
||||
intro: '/sounds/intro.wav',
|
||||
login: '/sounds/login.wav',
|
||||
copy: '/sounds/copy.wav',
|
||||
uploadFailed: '/sounds/uploadfail.wav'
|
||||
}
|
||||
|
||||
for (const [name, url] of Object.entries(basicSounds)) {
|
||||
const buffer = await loadSound(url)
|
||||
if (buffer) {
|
||||
globalSoundBuffers.set(name, buffer)
|
||||
soundBuffers.value.set(name, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
// Load water sounds (1-10)
|
||||
console.log('Loading water sounds...')
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`/sounds/water${i}.wav`)
|
||||
if (buffer) {
|
||||
globalWaterSounds.push(buffer)
|
||||
waterSounds.value.push(buffer)
|
||||
console.log(`Loaded water sound ${i}`)
|
||||
} else {
|
||||
console.warn(`Failed to load water sound ${i}`)
|
||||
}
|
||||
}
|
||||
console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
|
||||
|
||||
// Load sent sounds (1-6)
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`/sounds/sent${i}.wav`)
|
||||
if (buffer) {
|
||||
globalSentSounds.push(buffer)
|
||||
sentSounds.value.push(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All sounds loaded and ready to play')
|
||||
} catch (error) {
|
||||
console.error('Error loading sounds:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play a sound buffer
|
||||
const playSoundBuffer = async (buffer: AudioBuffer) => {
|
||||
if (!appStore.settings.soundEnabled) return
|
||||
|
||||
try {
|
||||
await initAudioContext()
|
||||
if (!globalAudioContext) {
|
||||
console.error('AudioContext not initialized')
|
||||
return
|
||||
}
|
||||
const source = globalAudioContext.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(globalAudioContext.destination)
|
||||
source.start(0)
|
||||
} catch (error) {
|
||||
console.error('Error playing sound:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play specific sounds
|
||||
const playSound = async (name: string) => {
|
||||
const buffer = globalSoundBuffers.get(name)
|
||||
if (buffer) {
|
||||
await playSoundBuffer(buffer)
|
||||
} else {
|
||||
console.warn(`Sound ${name} not loaded`)
|
||||
}
|
||||
}
|
||||
|
||||
const playWater = async () => {
|
||||
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Water sounds not loaded - trying to load them now')
|
||||
if (globalAudioContext) {
|
||||
await loadAllSounds()
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playSent = async () => {
|
||||
if (globalSentSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
|
||||
await playSoundBuffer(globalSentSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Sent sounds not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
// Voice recording
|
||||
const startRecording = async (): Promise<boolean> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
recordingChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordingChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
|
||||
recording.value.blob = blob
|
||||
recording.value.isRecording = false
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval)
|
||||
recordingInterval = null
|
||||
}
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
recording.value.isRecording = true
|
||||
recording.value.duration = 0
|
||||
recordingStartTime = Date.now()
|
||||
|
||||
// Update duration every 100ms
|
||||
recordingInterval = setInterval(() => {
|
||||
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
||||
}, 100)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error)
|
||||
recording.value.isRecording = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder && recording.value.isRecording) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = async () => {
|
||||
if (!recording.value.blob) return false
|
||||
|
||||
try {
|
||||
const audio = new Audio(URL.createObjectURL(recording.value.blob))
|
||||
|
||||
recording.value.isPlaying = true
|
||||
recording.value.currentTime = 0
|
||||
|
||||
audio.ontimeupdate = () => {
|
||||
recording.value.currentTime = audio.currentTime
|
||||
}
|
||||
|
||||
audio.onended = () => {
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
URL.revokeObjectURL(audio.src)
|
||||
}
|
||||
|
||||
await audio.play()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to play recording:', error)
|
||||
recording.value.isPlaying = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const clearRecording = () => {
|
||||
if (recording.value.blob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
|
||||
}
|
||||
recording.value.blob = null
|
||||
recording.value.duration = 0
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
}
|
||||
|
||||
// Text-to-speech functions
|
||||
const loadVoices = () => {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
availableVoices.value = voices
|
||||
|
||||
// Select default voice (prefer English voices)
|
||||
if (!selectedVoice.value && voices.length > 0) {
|
||||
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
|
||||
selectedVoice.value = englishVoice || voices[0]
|
||||
}
|
||||
}
|
||||
|
||||
const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
|
||||
if (!appStore.settings.ttsEnabled) return Promise.resolve()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Stop any current speech
|
||||
speechSynthesis.cancel()
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
// Set voice if available
|
||||
if (selectedVoice.value) {
|
||||
utterance.voice = selectedVoice.value
|
||||
}
|
||||
|
||||
// Apply options
|
||||
utterance.rate = options.rate || appStore.settings.ttsRate || 1
|
||||
utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
|
||||
utterance.volume = options.volume || appStore.settings.ttsVolume || 1
|
||||
|
||||
utterance.onstart = () => {
|
||||
isSpeaking.value = true
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
isSpeaking.value = false
|
||||
resolve()
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
isSpeaking.value = false
|
||||
console.error('Speech synthesis error:', event.error)
|
||||
reject(new Error(`Speech synthesis failed: ${event.error}`))
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
} else {
|
||||
reject(new Error('Speech synthesis not supported'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopSpeaking = () => {
|
||||
if ('speechSynthesis' in window) {
|
||||
speechSynthesis.cancel()
|
||||
isSpeaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setVoice = (voice: SpeechSynthesisVoice) => {
|
||||
selectedVoice.value = voice
|
||||
appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
|
||||
}
|
||||
|
||||
// Announce message for accessibility
|
||||
const announceMessage = async (content: string, channel?: string) => {
|
||||
if (!appStore.settings.ttsEnabled) return
|
||||
|
||||
let textToSpeak = content
|
||||
if (channel) {
|
||||
textToSpeak = `New message in ${channel}: ${content}`
|
||||
}
|
||||
|
||||
try {
|
||||
await speak(textToSpeak)
|
||||
} catch (error) {
|
||||
console.error('Failed to announce message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed
|
||||
const canRecord = computed(() => {
|
||||
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
})
|
||||
|
||||
const recordingDurationFormatted = computed(() => {
|
||||
const duration = recording.value.duration
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
// Initialize audio on first user interaction
|
||||
const initAudioOnUserGesture = async () => {
|
||||
if (!audioContext.value || audioContext.value.state === 'suspended') {
|
||||
await initAudioContext()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system (only once)
|
||||
const initializeAudioSystem = () => {
|
||||
if (!audioSystemInitialized) {
|
||||
audioSystemInitialized = true
|
||||
|
||||
// Set up user gesture listeners to initialize audio and load sounds
|
||||
const initializeAudio = async () => {
|
||||
console.log('User interaction detected, initializing audio system...')
|
||||
await initAudioOnUserGesture()
|
||||
await loadAllSounds() // Load sounds after user interaction
|
||||
console.log('Audio system initialized')
|
||||
document.removeEventListener('click', initializeAudio)
|
||||
document.removeEventListener('keydown', initializeAudio)
|
||||
}
|
||||
|
||||
document.addEventListener('click', initializeAudio, { once: true })
|
||||
document.addEventListener('keydown', initializeAudio, { once: true })
|
||||
|
||||
// Initialize voices for speech synthesis
|
||||
if ('speechSynthesis' in window) {
|
||||
loadVoices()
|
||||
// Voices may not be immediately available
|
||||
speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||
|
||||
// Restore selected voice from settings
|
||||
if (appStore.settings.selectedVoiceURI) {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
|
||||
if (savedVoice) {
|
||||
selectedVoice.value = savedVoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system when composable is first used
|
||||
initializeAudioSystem()
|
||||
|
||||
return {
|
||||
// State
|
||||
recording,
|
||||
canRecord,
|
||||
recordingDurationFormatted,
|
||||
isSpeaking: readonly(isSpeaking),
|
||||
availableVoices: readonly(availableVoices),
|
||||
selectedVoice: readonly(selectedVoice),
|
||||
|
||||
// Audio playback
|
||||
playSound,
|
||||
playWater,
|
||||
playSent,
|
||||
|
||||
// Voice recording
|
||||
startRecording,
|
||||
stopRecording,
|
||||
playRecording,
|
||||
clearRecording,
|
||||
|
||||
// Text-to-speech
|
||||
speak,
|
||||
stopSpeaking,
|
||||
setVoice,
|
||||
announceMessage,
|
||||
|
||||
// Audio context
|
||||
initAudioContext
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { onMounted, onUnmounted, ref, readonly } from 'vue'
|
||||
|
||||
interface ShortcutConfig {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
handler: () => void
|
||||
preventDefault?: boolean
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const shortcuts = ref<Map<string, ShortcutConfig>>(new Map())
|
||||
const isListening = ref(false)
|
||||
|
||||
const getShortcutKey = (config: ShortcutConfig): string => {
|
||||
const parts = []
|
||||
if (config.ctrlKey) parts.push('ctrl')
|
||||
if (config.shiftKey) parts.push('shift')
|
||||
if (config.altKey) parts.push('alt')
|
||||
if (config.metaKey) parts.push('meta')
|
||||
parts.push(config.key.toLowerCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const isInInputField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA'
|
||||
|
||||
const config: ShortcutConfig = {
|
||||
key: event.key.toLowerCase(),
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
handler: () => {}
|
||||
}
|
||||
|
||||
const shortcutKey = getShortcutKey(config)
|
||||
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'
|
||||
|
||||
// Skip shortcuts that shouldn't work in input fields
|
||||
if (isInInputField && !isGlobalShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcut.preventDefault !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
shortcut.handler()
|
||||
}
|
||||
}
|
||||
|
||||
const addShortcut = (config: ShortcutConfig) => {
|
||||
const key = getShortcutKey(config)
|
||||
shortcuts.value.set(key, config)
|
||||
}
|
||||
|
||||
const removeShortcut = (config: Omit<ShortcutConfig, 'handler'>) => {
|
||||
const key = getShortcutKey(config as ShortcutConfig)
|
||||
shortcuts.value.delete(key)
|
||||
}
|
||||
|
||||
const startListening = () => {
|
||||
if (!isListening.value) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
isListening.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const stopListening = () => {
|
||||
if (isListening.value) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startListening()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopListening()
|
||||
})
|
||||
|
||||
return {
|
||||
addShortcut,
|
||||
removeShortcut,
|
||||
startListening,
|
||||
stopListening,
|
||||
isListening
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ref, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import type { UnsentMessage } from '@/types'
|
||||
|
||||
export function useOfflineSync() {
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const isSyncing = ref(false)
|
||||
let syncInterval: number | null = null
|
||||
|
||||
// Monitor online status
|
||||
const updateOnlineStatus = () => {
|
||||
const wasOnline = isOnline.value
|
||||
isOnline.value = navigator.onLine
|
||||
|
||||
if (!wasOnline && isOnline.value) {
|
||||
toastStore.success('Back online - syncing data...')
|
||||
syncUnsentMessages()
|
||||
} else if (wasOnline && !isOnline.value) {
|
||||
toastStore.info('You are offline - messages will be queued')
|
||||
}
|
||||
}
|
||||
|
||||
// Sync unsent messages when online
|
||||
const syncUnsentMessages = async () => {
|
||||
if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing.value = true
|
||||
const failedMessages: UnsentMessage[] = []
|
||||
|
||||
for (const unsentMessage of appStore.unsentMessages) {
|
||||
try {
|
||||
const response = await apiService.createMessage(
|
||||
unsentMessage.channelId,
|
||||
unsentMessage.content
|
||||
)
|
||||
|
||||
// Message sent successfully - remove from unsent queue
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
|
||||
// Add to messages (will be handled by WebSocket event too)
|
||||
appStore.addMessage(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync message:', error)
|
||||
|
||||
// Increment retry count (create mutable copy)
|
||||
const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
|
||||
|
||||
// If too many retries, give up
|
||||
if (mutableMessage.retries >= 3) {
|
||||
toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
} else {
|
||||
failedMessages.push(mutableMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update unsent messages with failed ones
|
||||
if (failedMessages.length > 0) {
|
||||
toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
|
||||
} else if (appStore.unsentMessages.length > 0) {
|
||||
toastStore.success('All offline messages synced!')
|
||||
}
|
||||
|
||||
isSyncing.value = false
|
||||
await appStore.saveState()
|
||||
}
|
||||
|
||||
// Queue message for sending when offline
|
||||
const queueMessage = async (channelId: number, content: string): Promise<string> => {
|
||||
const unsentMessage: UnsentMessage = {
|
||||
id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
channelId,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
}
|
||||
|
||||
appStore.addUnsentMessage(unsentMessage)
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to send immediately if online
|
||||
if (isOnline.value) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
|
||||
return unsentMessage.id
|
||||
}
|
||||
|
||||
// Send message (online or queue for offline)
|
||||
const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
|
||||
if (isOnline.value) {
|
||||
try {
|
||||
const response = await apiService.createMessage(channelId, content)
|
||||
appStore.addMessage(response)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send message online:', error)
|
||||
// Fall back to queuing
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.error('Failed to send message - queued for later')
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.info('Message queued for sending when online')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save state periodically
|
||||
const startAutoSave = () => {
|
||||
if (syncInterval) clearInterval(syncInterval)
|
||||
|
||||
syncInterval = setInterval(async () => {
|
||||
try {
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to sync unsent messages if online
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error)
|
||||
}
|
||||
}, 10000) // Save every 10 seconds
|
||||
}
|
||||
|
||||
const stopAutoSave = () => {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle beforeunload to save state
|
||||
const handleBeforeUnload = () => {
|
||||
appStore.saveState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Add event listeners
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
// Start auto-save
|
||||
startAutoSave()
|
||||
|
||||
// Initial sync if online and has unsent messages
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
stopAutoSave()
|
||||
})
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
sendMessage,
|
||||
syncUnsentMessages,
|
||||
queueMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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()
|
||||
|
||||
const handleMessageCreated = (data: any) => {
|
||||
console.log('WebSocket: Message created event received:', data)
|
||||
console.log('Original content:', JSON.stringify(data.content))
|
||||
// Transform the data to match our expected format
|
||||
const message: ExtendedMessage = {
|
||||
id: data.id,
|
||||
channel_id: parseInt(data.channelId), // Convert channelId string to channel_id number
|
||||
content: data.content,
|
||||
created_at: data.createdAt || new Date().toISOString(),
|
||||
file_id: data.fileId,
|
||||
// Handle flattened file fields
|
||||
fileId: data.fileId,
|
||||
filePath: data.filePath,
|
||||
fileType: data.fileType,
|
||||
fileSize: data.fileSize,
|
||||
originalName: data.originalName,
|
||||
fileCreatedAt: data.fileCreatedAt
|
||||
}
|
||||
console.log('WebSocket: Transformed message:', message)
|
||||
console.log('Transformed content:', JSON.stringify(message.content))
|
||||
appStore.addMessage(message)
|
||||
|
||||
// Announce new message for accessibility
|
||||
const channel = appStore.channels.find(c => c.id === message.channel_id)
|
||||
if (channel && appStore.settings.ttsEnabled) {
|
||||
announceMessage(message.content, channel.name)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageUpdated = (data: any) => {
|
||||
// Handle full message updates including file metadata
|
||||
const messageUpdate: Partial<ExtendedMessage> = {
|
||||
content: data.content
|
||||
}
|
||||
|
||||
// Handle flattened file fields from server
|
||||
if (data.fileId) {
|
||||
messageUpdate.fileId = data.fileId
|
||||
messageUpdate.filePath = data.filePath
|
||||
messageUpdate.fileType = data.fileType
|
||||
messageUpdate.fileSize = data.fileSize
|
||||
messageUpdate.originalName = data.originalName
|
||||
messageUpdate.fileCreatedAt = data.fileCreatedAt
|
||||
}
|
||||
|
||||
appStore.updateMessage(parseInt(data.id), messageUpdate)
|
||||
}
|
||||
|
||||
const handleMessageDeleted = (data: { id: string }) => {
|
||||
appStore.removeMessage(parseInt(data.id))
|
||||
}
|
||||
|
||||
const handleFileUploaded = (data: any) => {
|
||||
// Handle file upload events with flattened format
|
||||
const messageUpdate: Partial<ExtendedMessage> = {
|
||||
fileId: data.fileId,
|
||||
filePath: data.filePath,
|
||||
fileType: data.fileType,
|
||||
fileSize: data.fileSize,
|
||||
originalName: data.originalName,
|
||||
fileCreatedAt: data.fileCreatedAt
|
||||
}
|
||||
|
||||
appStore.updateMessage(data.message_id, messageUpdate)
|
||||
}
|
||||
|
||||
const handleChannelCreated = (data: { channel: Channel }) => {
|
||||
appStore.addChannel(data.channel)
|
||||
toastStore.success(`Channel "${data.channel.name}" created`)
|
||||
}
|
||||
|
||||
const handleChannelDeleted = (data: { id: string }) => {
|
||||
const channelId = parseInt(data.id)
|
||||
const channel = appStore.channels.find(c => c.id === channelId)
|
||||
appStore.removeChannel(channelId)
|
||||
if (channel) {
|
||||
toastStore.info(`Channel "${channel.name}" was deleted`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChannelMerged = (data: { channelId: string, targetChannelId: string }) => {
|
||||
const sourceChannelId = parseInt(data.channelId)
|
||||
const targetChannelId = parseInt(data.targetChannelId)
|
||||
|
||||
const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
|
||||
const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
|
||||
|
||||
if (sourceChannel && targetChannel) {
|
||||
// Merge messages from source to target
|
||||
const sourceMessages = [...(appStore.messages[sourceChannelId] || [])]
|
||||
const targetMessages = [...(appStore.messages[targetChannelId] || [])]
|
||||
appStore.setMessages(targetChannelId, [...targetMessages, ...sourceMessages])
|
||||
|
||||
// Remove source channel
|
||||
appStore.removeChannel(sourceChannelId)
|
||||
|
||||
toastStore.success(`Channel "${sourceChannel.name}" merged into "${targetChannel.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChannelUpdated = (data: { id: string, name: string }) => {
|
||||
// Update channel in store (if we implement channel renaming)
|
||||
const channelId = parseInt(data.id)
|
||||
const channels = [...appStore.channels]
|
||||
const channelIndex = channels.findIndex(c => c.id === channelId)
|
||||
if (channelIndex !== -1) {
|
||||
channels[channelIndex] = { ...channels[channelIndex], name: data.name }
|
||||
appStore.setChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
const setupEventHandlers = () => {
|
||||
websocketService.on('message-created', handleMessageCreated)
|
||||
websocketService.on('message-updated', handleMessageUpdated)
|
||||
websocketService.on('message-deleted', handleMessageDeleted)
|
||||
websocketService.on('file-uploaded', handleFileUploaded)
|
||||
websocketService.on('channel-created', handleChannelCreated)
|
||||
websocketService.on('channel-deleted', handleChannelDeleted)
|
||||
websocketService.on('channel-merged', handleChannelMerged)
|
||||
websocketService.on('channel-updated', handleChannelUpdated)
|
||||
|
||||
websocketService.on('connected', () => {
|
||||
console.log('WebSocket connected successfully')
|
||||
toastStore.success('Connected to server')
|
||||
})
|
||||
|
||||
websocketService.on('disconnected', () => {
|
||||
toastStore.error('Disconnected from server')
|
||||
})
|
||||
|
||||
websocketService.on('error', () => {
|
||||
toastStore.error('WebSocket connection error')
|
||||
})
|
||||
}
|
||||
|
||||
const removeEventHandlers = () => {
|
||||
websocketService.off('message-created', handleMessageCreated)
|
||||
websocketService.off('message-updated', handleMessageUpdated)
|
||||
websocketService.off('message-deleted', handleMessageDeleted)
|
||||
websocketService.off('file-uploaded', handleFileUploaded)
|
||||
websocketService.off('channel-created', handleChannelCreated)
|
||||
websocketService.off('channel-deleted', handleChannelDeleted)
|
||||
websocketService.off('channel-merged', handleChannelMerged)
|
||||
websocketService.off('channel-updated', handleChannelUpdated)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Set custom server URL if available
|
||||
if (authStore.serverUrl) {
|
||||
websocketService.setServerUrl(authStore.serverUrl)
|
||||
}
|
||||
|
||||
setupEventHandlers()
|
||||
websocketService.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeEventHandlers()
|
||||
websocketService.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => websocketService.connect(),
|
||||
disconnect: () => websocketService.disconnect(),
|
||||
isConnected: () => websocketService.isConnected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { apiService } from './services/api'
|
||||
|
||||
// Import routes
|
||||
import { routes } from './router/index'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Router guard to ensure API service has proper token
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { useAuthStore } = await import('./stores/auth')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check authentication first
|
||||
await authStore.checkAuth()
|
||||
|
||||
// Check if going to protected route
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
// If authenticated but going to auth page, redirect to main
|
||||
if (authStore.isAuthenticated && to.name === 'auth') {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Set token for API service if authenticated
|
||||
if (authStore.isAuthenticated && authStore.token) {
|
||||
apiService.setToken(authStore.token)
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import MainView from '@/views/MainView.vue'
|
||||
import AuthView from '@/views/AuthView.vue'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
name: 'auth',
|
||||
component: AuthView
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Channel, Message, ExtendedMessage, FileAttachment } from '@/types'
|
||||
|
||||
class ApiService {
|
||||
private baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
private token = ''
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token
|
||||
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,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
private getFormHeaders(): HeadersInit {
|
||||
return {
|
||||
'Authorization': this.token
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const headers = {
|
||||
...this.getHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
|
||||
console.log('Making API request to:', url, 'with headers:', headers)
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('API request failed:', response.status, response.statusText)
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Authentication
|
||||
async checkToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/check-token`, {
|
||||
headers: { Authorization: this.token }
|
||||
})
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Channels
|
||||
async getChannels(): Promise<{ channels: Channel[] }> {
|
||||
return this.request('/channels')
|
||||
}
|
||||
|
||||
async createChannel(name: string): Promise<Channel> {
|
||||
return this.request('/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
}
|
||||
|
||||
async updateChannel(channelId: number, name: string): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
}
|
||||
|
||||
async deleteChannel(channelId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
async mergeChannels(sourceChannelId: number, targetChannelId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${sourceChannelId}/merge`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ targetChannelId: targetChannelId.toString() })
|
||||
})
|
||||
}
|
||||
|
||||
// Messages
|
||||
async getMessages(channelId: number): Promise<{ messages: ExtendedMessage[] }> {
|
||||
return this.request(`/channels/${channelId}/messages`)
|
||||
}
|
||||
|
||||
async createMessage(channelId: number, content: string): Promise<Message> {
|
||||
return this.request(`/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
})
|
||||
}
|
||||
|
||||
async updateMessage(channelId: number, messageId: number, content: string): Promise<{ id: string, content: string }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content })
|
||||
})
|
||||
}
|
||||
|
||||
async deleteMessage(channelId: number, messageId: number): Promise<{ message: string }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Files
|
||||
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/channels/${channelId}/messages/${messageId}/files`, {
|
||||
method: 'POST',
|
||||
headers: this.getFormHeaders(),
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File upload failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getFiles(channelId: number, messageId: number): Promise<{ files: FileAttachment[] }> {
|
||||
return this.request(`/channels/${channelId}/messages/${messageId}/files`)
|
||||
}
|
||||
|
||||
// Search
|
||||
async search(query: string, channelId?: number): Promise<{ results: Message[] }> {
|
||||
const params = new URLSearchParams({ query })
|
||||
if (channelId) {
|
||||
params.append('channelId', channelId.toString())
|
||||
}
|
||||
return this.request(`/search?${params.toString()}`)
|
||||
}
|
||||
|
||||
// File URL helper
|
||||
getFileUrl(filePath: string): string {
|
||||
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService()
|
||||
@@ -0,0 +1,305 @@
|
||||
import { apiService } from './api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { ExtendedMessage, UnsentMessage } from '@/types'
|
||||
|
||||
export class SyncService {
|
||||
private getAppStore() {
|
||||
return useAppStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync messages for a channel: merge server data with local data
|
||||
*/
|
||||
async syncChannelMessages(channelId: number): Promise<void> {
|
||||
try {
|
||||
console.log(`Syncing messages for channel ${channelId}`)
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
|
||||
// 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)
|
||||
await appStore.saveState()
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync messages for channel ${channelId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to send all unsent messages (text and file messages)
|
||||
*/
|
||||
async retryUnsentMessages(): Promise<void> {
|
||||
const appStore = this.getAppStore()
|
||||
const unsentMessages = appStore.unsentMessages
|
||||
console.log(`Attempting to send ${unsentMessages.length} unsent messages`)
|
||||
|
||||
for (const unsentMsg of [...unsentMessages]) {
|
||||
try {
|
||||
console.log(`Sending unsent ${unsentMsg.messageType || 'text'} message: ${unsentMsg.content}`)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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`)
|
||||
console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`)
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to send unsent message ${unsentMsg.id}:`, error)
|
||||
|
||||
// Increment retry count
|
||||
unsentMsg.retries = (unsentMsg.retries || 0) + 1
|
||||
|
||||
// Remove if too many retries (optional)
|
||||
if (unsentMsg.retries >= 5) {
|
||||
console.log(`Giving up on unsent message ${unsentMsg.id} after ${unsentMsg.retries} retries`)
|
||||
appStore.removeUnsentMessage(unsentMsg.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save state after processing
|
||||
await appStore.saveState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: channels and messages
|
||||
*/
|
||||
async fullSync(): Promise<void> {
|
||||
try {
|
||||
console.log('Starting full sync...')
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
|
||||
// 1. Sync channels
|
||||
const channelsResponse = await apiService.getChannels()
|
||||
appStore.setChannels(channelsResponse.channels)
|
||||
|
||||
// 2. Retry unsent messages first
|
||||
await this.retryUnsentMessages()
|
||||
|
||||
// 3. Sync messages for current channel
|
||||
if (appStore.currentChannelId) {
|
||||
await this.syncChannelMessages(appStore.currentChannelId)
|
||||
}
|
||||
|
||||
// 4. Save everything
|
||||
await appStore.saveState()
|
||||
|
||||
console.log('Full sync completed')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Full sync failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic message sending with automatic sync
|
||||
*/
|
||||
async sendMessage(channelId: number, content: string): Promise<void> {
|
||||
try {
|
||||
console.log(`Optimistically sending message: ${content}`)
|
||||
|
||||
// Try to send immediately
|
||||
const response = await apiService.createMessage(channelId, content)
|
||||
|
||||
// Success - add to local messages
|
||||
const message: ExtendedMessage = {
|
||||
id: response.id,
|
||||
channel_id: channelId,
|
||||
content: content,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
appStore.addMessage(message)
|
||||
console.log(`Message sent successfully with ID: ${response.id}`)
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to send message immediately, queuing for later:', error)
|
||||
|
||||
// Failed - add to unsent messages
|
||||
const unsentMessage: UnsentMessage = {
|
||||
id: `unsent_${Date.now()}_${Math.random()}`,
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
}
|
||||
|
||||
const appStore = this.getAppStore()
|
||||
appStore.addUnsentMessage(unsentMessage)
|
||||
await appStore.saveState()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { WebSocketEvent } from '@/types'
|
||||
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectAttempts = 0
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error('WebSocket connection failed:', error)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.ws) return
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connected', null)
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: WebSocketEvent = JSON.parse(event.data)
|
||||
console.log('WebSocket raw message received:', event.data)
|
||||
console.log('Parsed WebSocket data:', data)
|
||||
this.emit(data.type, data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, 'Raw data:', event.data)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('WebSocket disconnected:', event.code, event.reason)
|
||||
this.emit('disconnected', { code: event.code, reason: event.reason })
|
||||
|
||||
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1)
|
||||
|
||||
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
on(event: string, handler: (data: any) => void) {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, [])
|
||||
}
|
||||
this.eventHandlers.get(event)!.push(handler)
|
||||
}
|
||||
|
||||
off(event: string, handler: (data: any) => void) {
|
||||
const handlers = this.eventHandlers.get(event)
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler)
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: string, data: any) {
|
||||
const handlers = this.eventHandlers.get(event)
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in WebSocket event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
send(message: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} else {
|
||||
console.warn('WebSocket not connected, cannot send message')
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect')
|
||||
this.ws = null
|
||||
}
|
||||
this.eventHandlers.clear()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
export const websocketService = new WebSocketService()
|
||||
@@ -0,0 +1,178 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { get, set } from 'idb-keyval'
|
||||
import type { Channel, ExtendedMessage, UnsentMessage, AppSettings } from '@/types'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// State
|
||||
const channels = ref<Channel[]>([])
|
||||
const currentChannelId = ref<number | null>(null)
|
||||
const messages = ref<Record<number, ExtendedMessage[]>>({})
|
||||
const unsentMessages = ref<UnsentMessage[]>([])
|
||||
const settings = ref<AppSettings>({
|
||||
soundEnabled: true,
|
||||
speechEnabled: true,
|
||||
ttsEnabled: true,
|
||||
ttsRate: 1,
|
||||
ttsPitch: 1,
|
||||
ttsVolume: 1,
|
||||
selectedVoiceURI: null,
|
||||
defaultChannelId: null,
|
||||
theme: 'auto'
|
||||
})
|
||||
|
||||
// Computed
|
||||
const currentChannel = computed(() =>
|
||||
channels.value.find(c => c.id === currentChannelId.value) || null
|
||||
)
|
||||
|
||||
const currentMessages = computed(() => {
|
||||
const channelId = currentChannelId.value
|
||||
const channelMessages = channelId ? messages.value[channelId] || [] : []
|
||||
return channelMessages
|
||||
})
|
||||
|
||||
const unsentMessagesForChannel = computed(() =>
|
||||
currentChannelId.value
|
||||
? unsentMessages.value.filter(msg => msg.channelId === currentChannelId.value)
|
||||
: []
|
||||
)
|
||||
|
||||
// Actions
|
||||
const setChannels = (newChannels: Channel[]) => {
|
||||
channels.value = newChannels
|
||||
}
|
||||
|
||||
const addChannel = (channel: Channel) => {
|
||||
channels.value.push(channel)
|
||||
messages.value[channel.id] = []
|
||||
}
|
||||
|
||||
const removeChannel = (channelId: number) => {
|
||||
channels.value = channels.value.filter(c => c.id !== channelId)
|
||||
delete messages.value[channelId]
|
||||
if (currentChannelId.value === channelId) {
|
||||
currentChannelId.value = channels.value[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentChannel = async (channelId: number | null) => {
|
||||
currentChannelId.value = channelId
|
||||
await set('current_channel_id', channelId)
|
||||
}
|
||||
|
||||
const setMessages = (channelId: number, channelMessages: ExtendedMessage[]) => {
|
||||
console.log('Store: Setting messages for channel', channelId, ':', channelMessages.length, 'messages')
|
||||
messages.value[channelId] = channelMessages
|
||||
}
|
||||
|
||||
const addMessage = (message: ExtendedMessage) => {
|
||||
console.log('Store: Adding message to channel', message.channel_id, ':', message)
|
||||
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')
|
||||
|
||||
// Note: Auto-save is now handled by the sync service to avoid excessive I/O
|
||||
}
|
||||
|
||||
const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
|
||||
for (const channelId in messages.value) {
|
||||
const channelMessages = messages.value[parseInt(channelId)]
|
||||
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
||||
if (messageIndex !== -1) {
|
||||
channelMessages[messageIndex] = { ...channelMessages[messageIndex], ...updates }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeMessage = (messageId: number) => {
|
||||
for (const channelId in messages.value) {
|
||||
const channelMessages = messages.value[parseInt(channelId)]
|
||||
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
|
||||
if (messageIndex !== -1) {
|
||||
channelMessages.splice(messageIndex, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addUnsentMessage = (message: UnsentMessage) => {
|
||||
unsentMessages.value.push(message)
|
||||
}
|
||||
|
||||
const removeUnsentMessage = (messageId: string) => {
|
||||
const index = unsentMessages.value.findIndex(m => m.id === messageId)
|
||||
if (index !== -1) {
|
||||
unsentMessages.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSettings = async (newSettings: Partial<AppSettings>) => {
|
||||
settings.value = { ...settings.value, ...newSettings }
|
||||
await set('app_settings', JSON.parse(JSON.stringify(settings.value)))
|
||||
}
|
||||
|
||||
const loadState = async () => {
|
||||
try {
|
||||
const [storedChannelId, storedMessages, storedUnsentMessages, storedSettings] = await Promise.all([
|
||||
get('current_channel_id'),
|
||||
get('messages'),
|
||||
get('unsent_messages'),
|
||||
get('app_settings')
|
||||
])
|
||||
|
||||
if (storedChannelId) currentChannelId.value = storedChannelId
|
||||
if (storedMessages) messages.value = storedMessages
|
||||
if (storedUnsentMessages) unsentMessages.value = storedUnsentMessages
|
||||
if (storedSettings) settings.value = { ...settings.value, ...storedSettings }
|
||||
} catch (error) {
|
||||
console.error('Failed to load state from storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveState = async () => {
|
||||
try {
|
||||
// Convert reactive objects to plain objects for IndexedDB
|
||||
await Promise.all([
|
||||
set('current_channel_id', currentChannelId.value),
|
||||
set('messages', JSON.parse(JSON.stringify(messages.value))),
|
||||
set('unsent_messages', JSON.parse(JSON.stringify(unsentMessages.value))),
|
||||
set('app_settings', JSON.parse(JSON.stringify(settings.value)))
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to save state to storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
channels,
|
||||
currentChannelId,
|
||||
messages,
|
||||
unsentMessages,
|
||||
settings,
|
||||
|
||||
// Computed
|
||||
currentChannel,
|
||||
currentMessages,
|
||||
unsentMessagesForChannel,
|
||||
|
||||
// Actions
|
||||
setChannels,
|
||||
addChannel,
|
||||
removeChannel,
|
||||
setCurrentChannel,
|
||||
setMessages,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
addUnsentMessage,
|
||||
removeUnsentMessage,
|
||||
updateSettings,
|
||||
loadState,
|
||||
saveState
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, readonly } from 'vue'
|
||||
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)
|
||||
|
||||
// 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
|
||||
|
||||
// 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 Promise.all([
|
||||
set('auth_token', null),
|
||||
set('server_url', null)
|
||||
])
|
||||
}
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
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 response = await fetch(`${urlToUse}/check-token`, {
|
||||
headers: { Authorization: storedToken }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
token.value = storedToken
|
||||
isAuthenticated.value = true
|
||||
} else {
|
||||
console.warn('Stored token is invalid, clearing auth')
|
||||
await clearAuth()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
await clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
const authenticate = async (authToken: string, customServerUrl?: string): Promise<boolean> => {
|
||||
try {
|
||||
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||
const response = await fetch(`${urlToUse}/check-token`, {
|
||||
headers: { Authorization: authToken }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await setToken(authToken, urlToUse)
|
||||
return true
|
||||
} else {
|
||||
await clearAuth()
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error)
|
||||
await clearAuth()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
serverUrl,
|
||||
isAuthenticated,
|
||||
setToken,
|
||||
setServerUrl,
|
||||
clearAuth,
|
||||
checkAuth,
|
||||
authenticate,
|
||||
getDefaultServerUrl
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { ToastMessage } from '@/types'
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<ToastMessage[]>([])
|
||||
|
||||
const addToast = (message: string, type: ToastMessage['type'] = 'info', duration = 3000) => {
|
||||
const id = Date.now().toString()
|
||||
const toast: ToastMessage = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const clearToasts = () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
const success = (message: string, duration?: number) => addToast(message, 'success', duration)
|
||||
const error = (message: string, duration?: number) => addToast(message, 'error', duration)
|
||||
const info = (message: string, duration?: number) => addToast(message, 'info', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
clearToasts,
|
||||
success,
|
||||
error,
|
||||
info
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Minimal reset styles only */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Accessibility helpers */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// API Types matching backend schema
|
||||
export interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
channel_id: number
|
||||
content: string
|
||||
created_at: string
|
||||
file_id?: number
|
||||
}
|
||||
|
||||
export interface MessageWithFile extends Message {
|
||||
fileId?: number
|
||||
filePath?: string
|
||||
fileType?: string
|
||||
fileSize?: number
|
||||
originalName?: string
|
||||
fileCreatedAt?: string
|
||||
}
|
||||
|
||||
export interface FileAttachment {
|
||||
id: number
|
||||
channel_id: number
|
||||
message_id: number
|
||||
file_path: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
original_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// For compatibility, ExtendedMessage now represents the flattened structure from backend
|
||||
export interface ExtendedMessage extends MessageWithFile {
|
||||
files?: FileAttachment[] // Keep for backward compatibility but won't be used
|
||||
}
|
||||
|
||||
// Mutable versions for store operations
|
||||
export interface MutableMessage {
|
||||
id: number
|
||||
channel_id: number
|
||||
content: string
|
||||
created_at: string
|
||||
file_id?: number
|
||||
files?: FileAttachment[]
|
||||
}
|
||||
|
||||
// WebSocket Event Types
|
||||
export interface WebSocketEvent {
|
||||
type: 'message-created' | 'message-updated' | 'message-deleted' |
|
||||
'file-uploaded' | 'channel-created' | 'channel-deleted' |
|
||||
'channel-merged' | 'channel-updated'
|
||||
data: any
|
||||
}
|
||||
|
||||
// Frontend State Types
|
||||
export interface AppState {
|
||||
isAuthenticated: boolean
|
||||
currentChannelId: number | null
|
||||
channels: Channel[]
|
||||
messages: Record<number, ExtendedMessage[]>
|
||||
unsentMessages: UnsentMessage[]
|
||||
settings: AppSettings
|
||||
}
|
||||
|
||||
export interface UnsentMessage {
|
||||
id: string
|
||||
channelId: number
|
||||
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 {
|
||||
soundEnabled: boolean
|
||||
speechEnabled: boolean
|
||||
ttsEnabled: boolean
|
||||
ttsRate: number
|
||||
ttsPitch: number
|
||||
ttsVolume: number
|
||||
selectedVoiceURI: string | null
|
||||
defaultChannelId: number | null
|
||||
theme: 'light' | 'dark' | 'auto'
|
||||
serverUrl?: string | null
|
||||
}
|
||||
|
||||
// Audio Types
|
||||
export interface AudioState {
|
||||
isRecording: boolean
|
||||
recordingTime: number
|
||||
audioBlob: Blob | null
|
||||
isPlaying: boolean
|
||||
playbackTime: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
// UI State Types
|
||||
export interface ToastMessage {
|
||||
id: string
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface DialogState {
|
||||
isOpen: boolean
|
||||
component: string | null
|
||||
props: Record<string, any>
|
||||
}
|
||||
|
||||
// Search Types
|
||||
export interface SearchResult {
|
||||
message: ExtendedMessage
|
||||
channel: Channel
|
||||
}
|
||||
|
||||
// File Upload Types
|
||||
export interface UploadProgress {
|
||||
loaded: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="auth-view">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__header">
|
||||
<h1 class="auth-card__title">Welcome to Notebrook</h1>
|
||||
<p class="auth-card__subtitle">Enter your authentication token to continue</p>
|
||||
</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"
|
||||
label="Authentication Token"
|
||||
placeholder="Enter your token"
|
||||
required
|
||||
:error="error"
|
||||
:disabled="isLoading"
|
||||
@keydown.enter="handleAuth"
|
||||
ref="tokenInput"
|
||||
/>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isLoading"
|
||||
:disabled="!token.trim()"
|
||||
class="auth-form__submit"
|
||||
>
|
||||
{{ isLoading ? 'Authenticating...' : 'Sign In' }}
|
||||
</BaseButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
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
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 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 or server URL'
|
||||
serverInput.value?.focus()
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Authentication failed. Please check your token and server URL.'
|
||||
console.error('Auth error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
serverInput.value?.focus()
|
||||
playSound('intro')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-card__header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-card__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.auth-card__subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-form__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.auth-card {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.auth-card__title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.auth-card__subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div class="main-view">
|
||||
<!-- Mobile Header -->
|
||||
<header class="mobile-header">
|
||||
<button
|
||||
class="mobile-menu-button"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<h1 class="mobile-title">{{ appStore.currentChannel?.name || 'Notebrook' }}</h1>
|
||||
<button
|
||||
class="mobile-search-button"
|
||||
@click="showSearchDialog = true"
|
||||
aria-label="Search messages"
|
||||
>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar Overlay -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="sidebarOpen = false"
|
||||
></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<Sidebar
|
||||
:class="{ 'sidebar-open': sidebarOpen }"
|
||||
:channels="appStore.channels"
|
||||
:current-channel-id="appStore.currentChannelId"
|
||||
:unread-counts="unreadCounts"
|
||||
@create-channel="showChannelDialog = true"
|
||||
@select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
|
||||
@channel-info="handleChannelInfo"
|
||||
@settings="showSettings = true"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<div v-if="appStore.currentChannel" class="chat-container">
|
||||
<!-- Chat Header (Desktop only) -->
|
||||
<ChatHeader
|
||||
class="desktop-header"
|
||||
:channel-name="appStore.currentChannel.name"
|
||||
@search="showSearchDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Messages -->
|
||||
<MessagesContainer
|
||||
:messages="appStore.currentMessages"
|
||||
:unsent-messages="appStore.unsentMessagesForChannel"
|
||||
ref="messagesContainer"
|
||||
/>
|
||||
|
||||
<!-- Message Input -->
|
||||
<MessageInput
|
||||
@send-message="handleSendMessage"
|
||||
@file-upload="showFileDialog = true"
|
||||
@camera="showCameraDialog = true"
|
||||
@voice="showVoiceDialog = true"
|
||||
ref="messageInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-channel">
|
||||
<p>Select a channel to start chatting</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<BaseDialog v-model:show="showChannelDialog" title="Create Channel">
|
||||
<CreateChannelDialog
|
||||
@cancel="showChannelDialog = false"
|
||||
@created="handleChannelCreated"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showSettings" title="Settings">
|
||||
<SettingsDialog @close="showSettings = false" />
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showSearchDialog" title="Search Messages" size="lg">
|
||||
<SearchDialog
|
||||
@close="showSearchDialog = false"
|
||||
@select-message="handleSelectMessage"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showFileDialog" title="Upload Files" size="lg">
|
||||
<FileUploadDialog
|
||||
@cancel="showFileDialog = false"
|
||||
@uploaded="showFileDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showVoiceDialog" title="Record Voice Message">
|
||||
<VoiceRecordingDialog
|
||||
@close="showVoiceDialog = false"
|
||||
@sent="handleVoiceSent"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showCameraDialog" title="Take Photo">
|
||||
<CameraCaptureDialog
|
||||
@close="showCameraDialog = false"
|
||||
@sent="handleCameraSent"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-model:show="showChannelInfoDialog" title="Channel Settings">
|
||||
<ChannelInfoDialog
|
||||
v-if="selectedChannelForInfo"
|
||||
:channel="selectedChannelForInfo"
|
||||
@close="showChannelInfoDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
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'
|
||||
|
||||
// Components
|
||||
import BaseDialog from '@/components/base/BaseDialog.vue'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
import ChatHeader from '@/components/chat/ChatHeader.vue'
|
||||
import MessagesContainer from '@/components/chat/MessagesContainer.vue'
|
||||
import MessageInput from '@/components/chat/MessageInput.vue'
|
||||
import CreateChannelDialog from '@/components/dialogs/CreateChannelDialog.vue'
|
||||
import SettingsDialog from '@/components/dialogs/SettingsDialog.vue'
|
||||
import SearchDialog from '@/components/dialogs/SearchDialog.vue'
|
||||
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'
|
||||
|
||||
// Types
|
||||
import type { ExtendedMessage, Channel } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
||||
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
|
||||
|
||||
// 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()
|
||||
const messageInput = ref()
|
||||
|
||||
// Dialog states
|
||||
const showChannelDialog = ref(false)
|
||||
const showChannelInfoDialog = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showSearchDialog = ref(false)
|
||||
const showFileDialog = ref(false)
|
||||
const showVoiceDialog = ref(false)
|
||||
const showCameraDialog = ref(false)
|
||||
|
||||
// Mobile sidebar state
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
// Channel info state
|
||||
const selectedChannelForInfo = ref<Channel | null>(null)
|
||||
|
||||
// Mock unread counts (implement real logic later)
|
||||
const unreadCounts = ref<Record<number, number>>({})
|
||||
|
||||
// Set up keyboard shortcuts
|
||||
const { addShortcut } = useKeyboardShortcuts()
|
||||
|
||||
const setupKeyboardShortcuts = () => {
|
||||
// Ctrl+Shift+S - Settings
|
||||
addShortcut({
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => { showSettings.value = true }
|
||||
})
|
||||
|
||||
// Ctrl+Shift+F - Search
|
||||
addShortcut({
|
||||
key: 'f',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => { showSearchDialog.value = true }
|
||||
})
|
||||
|
||||
// Ctrl+Shift+C - Channel selector focus
|
||||
addShortcut({
|
||||
key: 'c',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => {
|
||||
// Focus the first channel in the list
|
||||
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
|
||||
if (firstChannelButton) {
|
||||
firstChannelButton.focus()
|
||||
toastStore.info('Channel selector focused')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl+Shift+X - Channel info
|
||||
addShortcut({
|
||||
key: 'x',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => {
|
||||
if (appStore.currentChannel) {
|
||||
toastStore.info(`Channel: ${appStore.currentChannel.name} (${appStore.currentMessages.length} messages)`)
|
||||
} else {
|
||||
toastStore.info('No channel selected')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl+Shift+V - Voice message
|
||||
addShortcut({
|
||||
key: 'v',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => {
|
||||
if (appStore.currentChannelId) {
|
||||
showVoiceDialog.value = true
|
||||
} else {
|
||||
toastStore.info('Select a channel first')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Space - Focus message input
|
||||
addShortcut({
|
||||
key: ' ',
|
||||
handler: () => { messageInput.value?.focus() }
|
||||
})
|
||||
|
||||
// Ctrl+Shift+T - Toggle TTS
|
||||
addShortcut({
|
||||
key: 't',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
handler: () => {
|
||||
appStore.updateSettings({ ttsEnabled: !appStore.settings.ttsEnabled })
|
||||
toastStore.info(`TTS ${appStore.settings.ttsEnabled ? 'enabled' : 'disabled'}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Escape - Stop speaking
|
||||
addShortcut({
|
||||
key: 'escape',
|
||||
handler: () => {
|
||||
if (isSpeaking.value) {
|
||||
stopSpeaking()
|
||||
toastStore.info('Speech stopped')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Alt+Numbers - Announce last N messages
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
addShortcut({
|
||||
key: i.toString(),
|
||||
altKey: true,
|
||||
handler: () => announceLastMessage(i)
|
||||
})
|
||||
}
|
||||
|
||||
// Alt+0 - Announce last 10 messages
|
||||
addShortcut({
|
||||
key: '0',
|
||||
altKey: true,
|
||||
handler: () => announceLastMessage(10)
|
||||
})
|
||||
}
|
||||
|
||||
const selectChannel = async (channelId: number) => {
|
||||
console.log('Selecting channel:', channelId)
|
||||
await appStore.setCurrentChannel(channelId)
|
||||
|
||||
// Try to sync messages for this channel
|
||||
try {
|
||||
await syncService.syncChannelMessages(channelId)
|
||||
console.log('Channel messages synced')
|
||||
} catch (error) {
|
||||
console.log('Failed to sync channel messages, using local cache')
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
// Auto-focus message input when switching channels
|
||||
nextTick(() => {
|
||||
messageInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!appStore.currentChannelId) return
|
||||
|
||||
console.log('Sending message:', content, 'to channel:', appStore.currentChannelId)
|
||||
|
||||
try {
|
||||
await syncService.sendMessage(appStore.currentChannelId, content)
|
||||
playSent()
|
||||
scrollToBottom()
|
||||
toastStore.success('Message sent')
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
playWater() // Still play sound for queued message
|
||||
scrollToBottom()
|
||||
toastStore.error('Message queued for sending when online')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMessage = async (message: ExtendedMessage) => {
|
||||
showSearchDialog.value = false
|
||||
|
||||
// Switch to the correct channel if needed
|
||||
if (message.channel_id !== appStore.currentChannelId) {
|
||||
await selectChannel(message.channel_id)
|
||||
}
|
||||
|
||||
// Wait for the DOM to update, then focus the specific message
|
||||
await nextTick()
|
||||
|
||||
// Use the MessagesContainer's focusMessageById method for proper roving tabindex
|
||||
if (messagesContainer.value?.focusMessageById) {
|
||||
messagesContainer.value.focusMessageById(message.id)
|
||||
|
||||
// Add visual highlight
|
||||
await nextTick()
|
||||
const messageElement = document.querySelector(`[data-message-id="${message.id}"]`)
|
||||
if (messageElement) {
|
||||
messageElement.classList.add('message--highlighted')
|
||||
setTimeout(() => {
|
||||
messageElement.classList.remove('message--highlighted')
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
// Fallback to scrolling to bottom if method not available
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
// formatTime function removed - now using formatTimestampForScreenReader from utils
|
||||
|
||||
const handleVoiceSent = () => {
|
||||
// Voice message was sent successfully
|
||||
showVoiceDialog.value = false
|
||||
scrollToBottom()
|
||||
playSent()
|
||||
}
|
||||
|
||||
const handleCameraSent = () => {
|
||||
// Photo was sent successfully
|
||||
showCameraDialog.value = false
|
||||
scrollToBottom()
|
||||
playSent()
|
||||
}
|
||||
|
||||
const announceLastMessage = (position: number) => {
|
||||
const messages = appStore.currentMessages
|
||||
if (!messages || messages.length === 0) {
|
||||
toastStore.info('There are no messages in this channel right now')
|
||||
return
|
||||
}
|
||||
|
||||
const messageIndex = messages.length - position
|
||||
if (messageIndex < 0) {
|
||||
toastStore.info('No message is available in this position')
|
||||
return
|
||||
}
|
||||
|
||||
const message = messages[messageIndex]
|
||||
const timeStr = formatTimestampForScreenReader(message.created_at)
|
||||
const announcement = `${message.content}; sent ${timeStr}`
|
||||
|
||||
toastStore.info(announcement)
|
||||
|
||||
// Also speak if TTS is enabled
|
||||
if (appStore.settings.ttsEnabled) {
|
||||
speak(announcement)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesContainer.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
const handleChannelCreated = async (channelId: number) => {
|
||||
showChannelDialog.value = false
|
||||
await selectChannel(channelId)
|
||||
}
|
||||
|
||||
const handleChannelInfo = (channel: Channel) => {
|
||||
selectedChannelForInfo.value = channel
|
||||
showChannelInfoDialog.value = true
|
||||
}
|
||||
|
||||
const isUnsentMessage = (messageId: string | number): boolean => {
|
||||
return typeof messageId === 'string' && messageId.startsWith('unsent_')
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
// 1. Load saved state first (offline-first)
|
||||
console.log('Loading local state...')
|
||||
await appStore.loadState()
|
||||
console.log('Local state loaded. Channels:', appStore.channels.length, 'Current channel:', appStore.currentChannelId, 'Unsent messages:', appStore.unsentMessages.length)
|
||||
|
||||
// 2. Try to sync with server (when online)
|
||||
try {
|
||||
console.log('Syncing with server...')
|
||||
await syncService.fullSync()
|
||||
toastStore.success('Synced with server')
|
||||
} catch (error) {
|
||||
console.log('Failed to sync with server, working offline with cached data')
|
||||
if (appStore.channels.length === 0) {
|
||||
toastStore.error('No internet connection and no cached data available')
|
||||
} else {
|
||||
toastStore.info('Working offline with cached data')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. WebSocket connection (will gracefully fail if offline)
|
||||
useWebSocket()
|
||||
|
||||
// 4. Set up keyboard shortcuts
|
||||
setupKeyboardShortcuts()
|
||||
|
||||
// 5. Auto-select first channel if none selected and we have channels
|
||||
if (!appStore.currentChannelId && appStore.channels.length > 0) {
|
||||
await selectChannel(appStore.channels[0].id)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
console.log(`Attempting to sync ${appStore.unsentMessages.length} unsent messages`)
|
||||
await syncService.retryUnsentMessages()
|
||||
} catch (error) {
|
||||
console.log('Background sync failed, will try again later')
|
||||
}
|
||||
}
|
||||
}, 30000) // Every 30 seconds
|
||||
|
||||
// Cleanup interval on unmount
|
||||
const cleanup = () => clearInterval(syncInterval)
|
||||
window.addEventListener('beforeunload', cleanup)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-view {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #6b7280;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.main-view {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.no-channel {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mobile-menu-button,
|
||||
.mobile-search-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mobile-menu-button:hover,
|
||||
.mobile-search-button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.main-view {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.sidebar.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desktop-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mobile-header {
|
||||
background: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.mobile-menu-button,
|
||||
.mobile-search-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.mobile-menu-button:hover,
|
||||
.mobile-search-button:hover {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,wav,mp3}']
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'sounds/*.wav'],
|
||||
manifest: {
|
||||
name: 'Notebrook',
|
||||
short_name: 'Notebrook',
|
||||
description: 'Light note taking app in messenger style',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
Binary file not shown.
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -19,6 +20,11 @@ export class ChannelDialog extends Dialog<IChannel | null> {
|
||||
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(() => {
|
||||
@@ -39,6 +45,7 @@ export class ChannelDialog extends Dialog<IChannel | null> {
|
||||
this.deleteChannel();
|
||||
});
|
||||
this.add(this.nameField);
|
||||
this.add(this.idField);
|
||||
this.add(this.makeDefault);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.deleteButton);
|
||||
|
||||
@@ -3,6 +3,7 @@ const audioContext = new AudioContext();
|
||||
const soundFiles = {
|
||||
intro: 'intro.wav',
|
||||
login: 'login.wav',
|
||||
copy: 'copy.wav',
|
||||
uploadFailed: 'uploadfail.wav'
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export class MultilineInput extends UINode {
|
||||
this.titleElement.id = `txtarea_title_${this.id}`;
|
||||
this.textareaElement = document.createElement("textarea");
|
||||
this.textareaElement.id = `txtarea_${this.id}`;
|
||||
this.textareaElement.style.whiteSpace = 'pre'; // Prevent text wrapping and preserve
|
||||
this.textareaElement.style.overflow = 'auto'; // Enable scrolling if content overflows
|
||||
|
||||
this.titleElement.appendChild(this.textareaElement);
|
||||
this.element.appendChild(this.titleElement);
|
||||
}
|
||||
|
||||
@@ -45,4 +45,9 @@ export class TextInput extends UINode {
|
||||
this.inputElement.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setReadonly(readonly: boolean) {
|
||||
this.inputElement.readOnly = readonly;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,11 @@ export class MainView extends View {
|
||||
this.openMessageDialog(message);
|
||||
})
|
||||
itm.onKeyDown(async (key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
|
||||
if (key === "c") {
|
||||
navigator.clipboard.writeText(message.content.trim());
|
||||
playSound("copy");
|
||||
}
|
||||
|
||||
if (key === "Delete") {
|
||||
await this.removeMessage(message.id);
|
||||
if (this.messageList.children.length === 0) {
|
||||
@@ -609,6 +614,24 @@ export class MainView extends View {
|
||||
}
|
||||
|
||||
private handleHotkey(e: KeyboardEvent) {
|
||||
let index = 10;
|
||||
if ((e.key.match(/[1-9]/) || e.key === '0') && e.altKey) {
|
||||
e.preventDefault();
|
||||
if (e.key === '0') index = 10;
|
||||
index = parseInt(e.key);
|
||||
const messages = state.currentChannel?.messages;
|
||||
if (messages && messages.length > 0) {
|
||||
const msg = messages[messages.length - index];
|
||||
if (msg) {
|
||||
showToast(`${msg.content}; ${this.convertIsoTimeStringToFriendly(msg.createdAt)}`, 200);
|
||||
} else {
|
||||
showToast('No message is available in this position', 200);
|
||||
}
|
||||
} else {
|
||||
showToast('There are no messages in this channel right now', 200)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const action = this.hotkeyMap.get(e.key.toLowerCase());
|
||||
if (action) {
|
||||
|
||||
Reference in New Issue
Block a user