Initial vue frontend
This commit is contained in:
8
frontend-vue/env.d.ts
vendored
Normal file
8
frontend-vue/env.d.ts
vendored
Normal file
@@ -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
|
||||||
|
}
|
1
frontend-vue/frontend-vue/public/pwa-192x192.png
Normal file
1
frontend-vue/frontend-vue/public/pwa-192x192.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PWA icons would go here - placeholder files
|
1
frontend-vue/frontend-vue/public/pwa-512x512.png
Normal file
1
frontend-vue/frontend-vue/public/pwa-512x512.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PWA icons would go here - placeholder files
|
14
frontend-vue/index.html
Normal file
14
frontend-vue/index.html
Normal file
@@ -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>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8340
frontend-vue/package-lock.json
generated
Normal file
8340
frontend-vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend-vue/package.json
Normal file
38
frontend-vue/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
1
frontend-vue/public/favicon.ico
Normal file
1
frontend-vue/public/favicon.ico
Normal file
@@ -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==
|
BIN
frontend-vue/public/sounds/copy.wav
Normal file
BIN
frontend-vue/public/sounds/copy.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/intro.wav
Normal file
BIN
frontend-vue/public/sounds/intro.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/login.wav
Normal file
BIN
frontend-vue/public/sounds/login.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent1.wav
Normal file
BIN
frontend-vue/public/sounds/sent1.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent2.wav
Normal file
BIN
frontend-vue/public/sounds/sent2.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent3.wav
Normal file
BIN
frontend-vue/public/sounds/sent3.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent4.wav
Normal file
BIN
frontend-vue/public/sounds/sent4.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent5.wav
Normal file
BIN
frontend-vue/public/sounds/sent5.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/sent6.wav
Normal file
BIN
frontend-vue/public/sounds/sent6.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/uploadfail.wav
Normal file
BIN
frontend-vue/public/sounds/uploadfail.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water1.wav
Normal file
BIN
frontend-vue/public/sounds/water1.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water10.wav
Normal file
BIN
frontend-vue/public/sounds/water10.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water2.wav
Normal file
BIN
frontend-vue/public/sounds/water2.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water3.wav
Normal file
BIN
frontend-vue/public/sounds/water3.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water4.wav
Normal file
BIN
frontend-vue/public/sounds/water4.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water5.wav
Normal file
BIN
frontend-vue/public/sounds/water5.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water6.wav
Normal file
BIN
frontend-vue/public/sounds/water6.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water7.wav
Normal file
BIN
frontend-vue/public/sounds/water7.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water8.wav
Normal file
BIN
frontend-vue/public/sounds/water8.wav
Normal file
Binary file not shown.
BIN
frontend-vue/public/sounds/water9.wav
Normal file
BIN
frontend-vue/public/sounds/water9.wav
Normal file
Binary file not shown.
74
frontend-vue/src/App.vue
Normal file
74
frontend-vue/src/App.vue
Normal file
@@ -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>
|
173
frontend-vue/src/components/base/BaseButton.vue
Normal file
173
frontend-vue/src/components/base/BaseButton.vue
Normal file
@@ -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>
|
279
frontend-vue/src/components/base/BaseDialog.vue
Normal file
279
frontend-vue/src/components/base/BaseDialog.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<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-describedby="contentId"
|
||||||
|
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>
|
185
frontend-vue/src/components/base/BaseInput.vue
Normal file
185
frontend-vue/src/components/base/BaseInput.vue
Normal file
@@ -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>
|
228
frontend-vue/src/components/base/BaseTextarea.vue
Normal file
228
frontend-vue/src/components/base/BaseTextarea.vue
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div class="base-textarea">
|
||||||
|
<label v-if="label" :for="textareaId" class="base-textarea__label">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="base-textarea__required">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="base-textarea__wrapper">
|
||||||
|
<textarea
|
||||||
|
:id="textareaId"
|
||||||
|
ref="textareaRef"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="required"
|
||||||
|
:rows="rows"
|
||||||
|
:maxlength="maxlength"
|
||||||
|
:aria-invalid="error ? 'true' : 'false'"
|
||||||
|
:aria-describedby="error ? `${textareaId}-error` : undefined"
|
||||||
|
:class="[
|
||||||
|
'base-textarea__field',
|
||||||
|
{ 'base-textarea__field--error': error }
|
||||||
|
]"
|
||||||
|
@input="handleInput"
|
||||||
|
@blur="$emit('blur', $event)"
|
||||||
|
@focus="$emit('focus', $event)"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@keyup="$emit('keyup', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCharCount && maxlength" class="base-textarea__char-count">
|
||||||
|
{{ modelValue.length }}/{{ maxlength }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="helpText" class="base-textarea__help">
|
||||||
|
{{ helpText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
rows?: number
|
||||||
|
maxlength?: number
|
||||||
|
showCharCount?: boolean
|
||||||
|
error?: string
|
||||||
|
helpText?: string
|
||||||
|
id?: string
|
||||||
|
autoResize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
required: false,
|
||||||
|
rows: 3,
|
||||||
|
showCharCount: false,
|
||||||
|
autoResize: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
blur: [event: FocusEvent]
|
||||||
|
focus: [event: FocusEvent]
|
||||||
|
keydown: [event: KeyboardEvent]
|
||||||
|
keyup: [event: KeyboardEvent]
|
||||||
|
submit: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
|
||||||
|
|
||||||
|
const handleInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLTextAreaElement
|
||||||
|
emit('update:modelValue', target.value)
|
||||||
|
|
||||||
|
if (props.autoResize) {
|
||||||
|
autoResize(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
emit('keydown', event)
|
||||||
|
|
||||||
|
// Submit on Ctrl+Enter or Cmd+Enter
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('submit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoResize = (textarea: HTMLTextAreaElement) => {
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
textareaRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
textareaRef.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
selectAll,
|
||||||
|
textareaRef
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-textarea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #111827;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field:focus {
|
||||||
|
border-color: #646cff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field:disabled {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field:readonly {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
cursor: default;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field--error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field--error:focus {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__help {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.base-textarea__label {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field {
|
||||||
|
background-color: #374151;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
border-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__field:disabled,
|
||||||
|
.base-textarea__field:readonly {
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea__help,
|
||||||
|
.base-textarea__char-count {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
121
frontend-vue/src/components/base/Icon.vue
Normal file
121
frontend-vue/src/components/base/Icon.vue
Normal file
@@ -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>
|
70
frontend-vue/src/components/chat/ChatHeader.vue
Normal file
70
frontend-vue/src/components/chat/ChatHeader.vue
Normal file
@@ -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>
|
106
frontend-vue/src/components/chat/FileAttachment.vue
Normal file
106
frontend-vue/src/components/chat/FileAttachment.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<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(() => {
|
||||||
|
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>
|
458
frontend-vue/src/components/chat/FileMessage.vue
Normal file
458
frontend-vue/src/components/chat/FileMessage.vue
Normal file
@@ -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>
|
256
frontend-vue/src/components/chat/ImageMessage.vue
Normal file
256
frontend-vue/src/components/chat/ImageMessage.vue
Normal file
@@ -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>
|
73
frontend-vue/src/components/chat/InputActions.vue
Normal file
73
frontend-vue/src/components/chat/InputActions.vue
Normal file
@@ -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>
|
97
frontend-vue/src/components/chat/MessageInput.vue
Normal file
97
frontend-vue/src/components/chat/MessageInput.vue
Normal file
@@ -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>
|
277
frontend-vue/src/components/chat/MessageItem.vue
Normal file
277
frontend-vue/src/components/chat/MessageItem.vue
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'message',
|
||||||
|
{ 'message--unsent': isUnsent }
|
||||||
|
]"
|
||||||
|
:data-message-id="message.id"
|
||||||
|
:tabindex="tabindex || 0"
|
||||||
|
:aria-label="messageAriaLabel"
|
||||||
|
role="listitem"
|
||||||
|
@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">
|
||||||
|
{{ formatTime(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 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
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: props.message.fileId!,
|
||||||
|
channel_id: props.message.channel_id,
|
||||||
|
message_id: props.message.id,
|
||||||
|
file_path: props.message.filePath!,
|
||||||
|
file_type: props.message.fileType!,
|
||||||
|
file_size: props.message.fileSize!,
|
||||||
|
original_name: props.message.originalName!,
|
||||||
|
created_at: props.message.fileCreatedAt || props.message.created_at
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string): string => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = formatTime(props.message.created_at)
|
||||||
|
label += `. Sent at ${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 {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover,
|
||||||
|
.message:focus {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--unsent {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--highlighted {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
animation: highlight-fade 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-fade {
|
||||||
|
0% {
|
||||||
|
background: #bfdbfe;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__content {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__files {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.message:hover,
|
||||||
|
.message:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--unsent {
|
||||||
|
background: #451a03;
|
||||||
|
border-color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--highlighted {
|
||||||
|
background: #1e3a8a;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-fade {
|
||||||
|
0% {
|
||||||
|
background: #1e40af;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: #1e3a8a;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__content {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__time {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__status {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
250
frontend-vue/src/components/chat/MessagesContainer.vue
Normal file
250
frontend-vue/src/components/chat/MessagesContainer.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="messages-container"
|
||||||
|
ref="containerRef"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
tabindex="0"
|
||||||
|
role="list"
|
||||||
|
:aria-label="messagesAriaLabel"
|
||||||
|
:aria-description="navigationHint"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
@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, currently focused on message ${current} of ${total}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
318
frontend-vue/src/components/chat/VoiceMessage.vue
Normal file
318
frontend-vue/src/components/chat/VoiceMessage.vue
Normal file
@@ -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>
|
512
frontend-vue/src/components/dialogs/CameraCaptureDialog.vue
Normal file
512
frontend-vue/src/components/dialogs/CameraCaptureDialog.vue
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
<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
|
||||||
|
await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||||
|
|
||||||
|
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>
|
465
frontend-vue/src/components/dialogs/ChannelInfoDialog.vue
Normal file
465
frontend-vue/src/components/dialogs/ChannelInfoDialog.vue
Normal file
@@ -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>
|
96
frontend-vue/src/components/dialogs/CreateChannelDialog.vue
Normal file
96
frontend-vue/src/components/dialogs/CreateChannelDialog.vue
Normal file
@@ -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>
|
349
frontend-vue/src/components/dialogs/FileUploadDialog.vue
Normal file
349
frontend-vue/src/components/dialogs/FileUploadDialog.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<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 {
|
||||||
|
// Create a message first to attach files to
|
||||||
|
const message = await apiService.createMessage(appStore.currentChannelId,
|
||||||
|
`Uploaded ${selectedFiles.value.length} file${selectedFiles.value.length === 1 ? '' : 's'}`)
|
||||||
|
|
||||||
|
// Upload each file
|
||||||
|
for (let i = 0; i < selectedFiles.value.length; i++) {
|
||||||
|
const file = selectedFiles.value[i]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.uploadFile(appStore.currentChannelId, message.id, file)
|
||||||
|
uploadProgress.value[i] = 100
|
||||||
|
} catch (fileError) {
|
||||||
|
console.error(`Failed to upload ${file.name}:`, fileError)
|
||||||
|
toastStore.error(`Failed to upload ${file.name}`)
|
||||||
|
uploadProgress.value[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toastStore.success('Files uploaded successfully!')
|
||||||
|
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>
|
308
frontend-vue/src/components/dialogs/SearchDialog.vue
Normal file
308
frontend-vue/src/components/dialogs/SearchDialog.vue
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<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">
|
||||||
|
{{ formatTime(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 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string): string => {
|
||||||
|
if (!timestamp) return 'Unknown time'
|
||||||
|
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Invalid date'
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
364
frontend-vue/src/components/dialogs/SettingsDialog.vue
Normal file
364
frontend-vue/src/components/dialogs/SettingsDialog.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<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
|
||||||
|
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="form-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
:loading="isSaving"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import type { AppSettings } from '@/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
const { availableVoices, speak, setVoice } = useAudio()
|
||||||
|
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const selectedVoiceURI = ref('')
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Copy current settings to local state
|
||||||
|
Object.assign(localSettings, appStore.settings)
|
||||||
|
|
||||||
|
// Set up voice selection
|
||||||
|
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
|
||||||
|
})
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
465
frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue
Normal file
465
frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<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
|
||||||
|
await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||||
|
|
||||||
|
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>
|
76
frontend-vue/src/components/sidebar/ChannelList.vue
Normal file
76
frontend-vue/src/components/sidebar/ChannelList.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="channel-list-container">
|
||||||
|
<ul class="channel-list" role="list">
|
||||||
|
<ChannelListItem
|
||||||
|
v-for="channel in channels"
|
||||||
|
:key="channel.id"
|
||||||
|
:channel="channel"
|
||||||
|
:is-active="channel.id === currentChannelId"
|
||||||
|
:unread-count="unreadCounts[channel.id]"
|
||||||
|
@select="$emit('select-channel', $event)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ChannelListItem from './ChannelListItem.vue'
|
||||||
|
import type { Channel } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
channels: Channel[]
|
||||||
|
currentChannelId: number | null
|
||||||
|
unreadCounts: Record<number, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'select-channel': [channelId: number]
|
||||||
|
}>()
|
||||||
|
</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>
|
146
frontend-vue/src/components/sidebar/ChannelListItem.vue
Normal file
146
frontend-vue/src/components/sidebar/ChannelListItem.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'channel-item',
|
||||||
|
{ 'channel-item--active': isActive }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="channel-wrapper">
|
||||||
|
<button
|
||||||
|
class="channel-button"
|
||||||
|
@click="$emit('select', channel.id)"
|
||||||
|
:aria-pressed="isActive"
|
||||||
|
:aria-label="`Select channel ${channel.name}`"
|
||||||
|
>
|
||||||
|
<span class="channel-name">{{ channel.name }}</span>
|
||||||
|
<span v-if="unreadCount" class="channel-unread">
|
||||||
|
{{ unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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 type { Channel } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
channel: Channel
|
||||||
|
isActive: boolean
|
||||||
|
unreadCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [channelId: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.channel-item {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
136
frontend-vue/src/components/sidebar/Sidebar.vue
Normal file
136
frontend-vue/src/components/sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
</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]
|
||||||
|
'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>
|
467
frontend-vue/src/composables/useAudio.ts
Normal file
467
frontend-vue/src/composables/useAudio.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
93
frontend-vue/src/composables/useKeyboardShortcuts.ts
Normal file
93
frontend-vue/src/composables/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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) => {
|
||||||
|
// Skip shortcuts when focused on input/textarea elements
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
178
frontend-vue/src/composables/useOfflineSync.ts
Normal file
178
frontend-vue/src/composables/useOfflineSync.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
152
frontend-vue/src/composables/useWebSocket.ts
Normal file
152
frontend-vue/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { websocketService } from '@/services/websocket'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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: { id: string, content: string }) => {
|
||||||
|
appStore.updateMessage(parseInt(data.id), { content: data.content })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageDeleted = (data: { id: string }) => {
|
||||||
|
appStore.removeMessage(parseInt(data.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUploaded = (data: FileAttachment) => {
|
||||||
|
// Find the message and add the file to it
|
||||||
|
const channelMessages = appStore.messages[data.channel_id] || []
|
||||||
|
const messageIndex = channelMessages.findIndex(m => m.id === data.message_id)
|
||||||
|
if (messageIndex !== -1) {
|
||||||
|
const message = channelMessages[messageIndex]
|
||||||
|
const updatedMessage = {
|
||||||
|
...message,
|
||||||
|
files: [...(message.files || []), data]
|
||||||
|
}
|
||||||
|
appStore.updateMessage(message.id, updatedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
setupEventHandlers()
|
||||||
|
websocketService.connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeEventHandlers()
|
||||||
|
websocketService.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect: () => websocketService.connect(),
|
||||||
|
disconnect: () => websocketService.disconnect(),
|
||||||
|
isConnected: () => websocketService.isConnected
|
||||||
|
}
|
||||||
|
}
|
48
frontend-vue/src/main.ts
Normal file
48
frontend-vue/src/main.ts
Normal file
@@ -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')
|
17
frontend-vue/src/router/index.ts
Normal file
17
frontend-vue/src/router/index.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
153
frontend-vue/src/services/api.ts
Normal file
153
frontend-vue/src/services/api.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
206
frontend-vue/src/services/sync.ts
Normal file
206
frontend-vue/src/services/sync.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
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 => {
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
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 message: ${unsentMsg.content}`)
|
||||||
|
|
||||||
|
// Try to send the message
|
||||||
|
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||||
|
console.log(`Successfully sent unsent message, got ID: ${response.id}`)
|
||||||
|
|
||||||
|
// Create the sent message
|
||||||
|
const sentMessage: ExtendedMessage = {
|
||||||
|
id: response.id,
|
||||||
|
channel_id: unsentMsg.channelId,
|
||||||
|
content: unsentMsg.content,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to messages and remove from unsent
|
||||||
|
appStore.addMessage(sentMessage)
|
||||||
|
appStore.removeUnsentMessage(unsentMsg.id)
|
||||||
|
|
||||||
|
// Save state immediately after successful send to ensure UI updates
|
||||||
|
await appStore.saveState()
|
||||||
|
|
||||||
|
console.log(`Moved unsent message ${unsentMsg.id} to sent messages with ID ${response.id}`)
|
||||||
|
console.log(`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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncService = new SyncService()
|
134
frontend-vue/src/services/websocket.ts
Normal file
134
frontend-vue/src/services/websocket.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In development, connect to backend server (port 3000)
|
||||||
|
// In production, use same host as frontend
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = isDev ? 'localhost:3000' : window.location.host
|
||||||
|
const wsUrl = `${protocol}//${host}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
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()
|
178
frontend-vue/src/stores/app.ts
Normal file
178
frontend-vue/src/stores/app.ts
Normal file
@@ -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', 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
|
||||||
|
}
|
||||||
|
})
|
74
frontend-vue/src/stores/auth.ts
Normal file
74
frontend-vue/src/stores/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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 isAuthenticated = ref(false)
|
||||||
|
|
||||||
|
const setToken = async (newToken: string) => {
|
||||||
|
token.value = newToken
|
||||||
|
isAuthenticated.value = true
|
||||||
|
await set('auth_token', newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAuth = async () => {
|
||||||
|
token.value = null
|
||||||
|
isAuthenticated.value = false
|
||||||
|
await set('auth_token', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const storedToken = await get('auth_token')
|
||||||
|
if (storedToken) {
|
||||||
|
// Verify token with backend
|
||||||
|
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||||
|
const response = await fetch(`${baseUrl}/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): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||||
|
const response = await fetch(`${baseUrl}/check-token`, {
|
||||||
|
headers: { Authorization: authToken }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await setToken(authToken)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
await clearAuth()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication failed:', error)
|
||||||
|
await clearAuth()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
isAuthenticated,
|
||||||
|
setToken,
|
||||||
|
clearAuth,
|
||||||
|
checkAuth,
|
||||||
|
authenticate
|
||||||
|
}
|
||||||
|
})
|
52
frontend-vue/src/stores/toast.ts
Normal file
52
frontend-vue/src/stores/toast.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
})
|
125
frontend-vue/src/style.css
Normal file
125
frontend-vue/src/style.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid #646cff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styles */
|
||||||
|
input, textarea {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: #646cff;
|
||||||
|
outline: 2px solid #646cff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List styles */
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus indicators */
|
||||||
|
.focus-visible {
|
||||||
|
outline: 2px solid #646cff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
124
frontend-vue/src/types/index.ts
Normal file
124
frontend-vue/src/types/index.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
146
frontend-vue/src/views/AuthView.vue
Normal file
146
frontend-vue/src/views/AuthView.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<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="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 error = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const tokenInput = ref()
|
||||||
|
|
||||||
|
const handleAuth = async () => {
|
||||||
|
if (!token.value.trim()) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await authStore.authenticate(token.value.trim())
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await playSound('login')
|
||||||
|
toastStore.success('Authentication successful!')
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error.value = 'Invalid authentication token'
|
||||||
|
tokenInput.value?.focus()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Authentication failed. Please try again.'
|
||||||
|
console.error('Auth error:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
tokenInput.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>
|
465
frontend-vue/src/views/MainView.vue
Normal file
465
frontend-vue/src/views/MainView.vue
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main-view">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<Sidebar
|
||||||
|
:channels="appStore.channels"
|
||||||
|
:current-channel-id="appStore.currentChannelId"
|
||||||
|
:unread-counts="unreadCounts"
|
||||||
|
@create-channel="showChannelDialog = true"
|
||||||
|
@select-channel="selectChannel"
|
||||||
|
@settings="showSettings = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<div v-if="appStore.currentChannel" class="chat-container">
|
||||||
|
<!-- Chat Header -->
|
||||||
|
<ChatHeader
|
||||||
|
: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>
|
||||||
|
</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 { apiService } from '@/services/api'
|
||||||
|
import { syncService } from '@/services/sync'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import BaseDialog from '@/components/base/BaseDialog.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'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { ExtendedMessage } 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 is properly set
|
||||||
|
if (authStore.token) {
|
||||||
|
apiService.setToken(authStore.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const messagesContainer = ref()
|
||||||
|
const messageInput = ref()
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const showChannelDialog = ref(false)
|
||||||
|
const showSettings = ref(false)
|
||||||
|
const showSearchDialog = ref(false)
|
||||||
|
const showFileDialog = ref(false)
|
||||||
|
const showVoiceDialog = ref(false)
|
||||||
|
const showCameraDialog = ref(false)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string): string => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = formatTime(message.created_at)
|
||||||
|
const announcement = `${message.content}; ${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 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. 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-view {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
13
frontend-vue/tsconfig.json
Normal file
13
frontend-vue/tsconfig.json
Normal file
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
frontend-vue/vite.config.ts
Normal file
48
frontend-vue/vite.config.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user