Initial vue frontend
This commit is contained in:
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
PWA icons would go here - placeholder files
|
||||
@@ -0,0 +1 @@
|
||||
PWA icons would go here - placeholder files
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Notebrook</title>
|
||||
<meta name="description" content="Light note taking app in messenger style">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+8340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "notebrook-frontend-vue",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.3.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"@vueuse/sound": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"@vue/eslint-config-typescript": "^14.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVFiFtZddaBxVFMd/587M7iabNE1TbdI2VsWCVixYH/xAfRBBfBBBQcEH8UVBfPBBRcEXwQcfxAcRfLAP4oMPIr74oFgQC1qwYKHggyK2aZu0SZM0yWYzOzNz78/HmZ1Ndjeb3ewcmLkz95z/+Z977jl3wDmH/4NSSoUQoj9JknW+73vW2oy1NhVCmBDiEjAAbAImhBDHjTEfN5vNkw8//PBs/xfKOVf3f9M0zXe995cBjDHGOWdLpVIhhBgHtgArgBHgdeAW4CpgDaDAJmvtF8aYhzqdzrfOuer8OLquA6RSqd1a61XGmHettVprDYBzLhFCWGPMJuDncrn8erVavbNQKFyz2Wye6XQ6RwF836dt2581m81HnHNXd133jRBiddcCY8wW7/1w13WTUqnkOOdahULBSSm9EGKllHKVUmqllHKFUqovhOgTQvQJIXqFED1SyjYwC3Qcxzifw2az+WGhUBhpNpudtbW1x7TWnzrnXhJCbFtqQZIkh5Ik+dQY87ExZtwYM+m9P2mtPQWc6nQ6p1ut1ulu+1prbTtlNpqmabZarVONRuNU13XfOecOWmtPCCGOAkeNMd855+aXLbfAGDNhjPl69+7dW7XWu4wx9xhj7gZuB24E1gJVoAyUgBJgbLFYrBljjo2NjX2xvb39/aNHj06sXr06tm17DhgZGRnZIaW8p1KpbO3MdAY7M1kHGoTw+sDAQH9/f//qcrm8ptls/t1oNE6fP3/+1wMHDkxNTU1NlUqlcrVabf3www8AcO+991JZt25dvVGvfzs7Ozs+Ozt7dnp6+sza2tqZqampk1NTU5OTk5MT3vvJ+vr6ifvuu+8TAOcchw4dYmhoyAjhZ2sAbW3VarXJycnJqenp6dOzs7Ozs7Ozk9PT0xPT09MT9Xr9XLfdr+nKqampyenp6cnZ2dnJvr6+yfHx8S0AkiQxzjmstVq11qIsZvmm/dFrdDo/bthubr+q02ht7na7OT58+DAXL16k0Wjwl4ZCofCElPKxarW6O06cLGYJ07RAGOe9nxkfHz+2adOmN4aHh68HhqSUryilHlNKPeG9X9tFJKV8Ukp5u5TyHinl3c65ByuVygMLOgC2b9/OsqUUCGELhcJOKeU+KeXjUsrHpZRPSSn3SCl3Sil3SCm3Sym3Sym3LblAKTUipbxVSrlFSrlZSrlJSrlRSrlBSrleKaWWF2Tt+2Y5kZqX5hXjjn/TF9tGm7qCRAAAAABJRU5ErkJggg==
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container" v-if="toastStore.toasts.length > 0">
|
||||
<div
|
||||
v-for="toast in toastStore.toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
// Authentication is now handled by the router guard in main.ts
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'base-button',
|
||||
`base-button--${variant}`,
|
||||
`base-button--${size}`,
|
||||
{ 'base-button--loading': loading }
|
||||
]"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
@click="$emit('click', $event)"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
|
||||
<span :class="{ 'base-button__content--hidden': loading }">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
ariaLabel?: string
|
||||
ariaDescribedby?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
type: 'button',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
emit('click', event as any)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.base-button:focus-visible {
|
||||
outline: 2px solid #646cff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.base-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.base-button--sm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.base-button--md {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.base-button--lg {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.base-button--primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--primary:hover:not(:disabled) {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #f9f9f9;
|
||||
color: #213547;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.base-button--secondary:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-button--danger {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--danger:hover:not(:disabled) {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.base-button--ghost {
|
||||
background-color: transparent;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.base-button--ghost:hover:not(:disabled) {
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.base-button--loading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.base-button__spinner {
|
||||
position: absolute;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.base-button__content--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base-button--secondary {
|
||||
background-color: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.base-button--secondary:hover:not(:disabled) {
|
||||
background-color: #4b5563;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<label v-if="label" :for="inputId" class="base-input__label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="base-input__required">*</span>
|
||||
</label>
|
||||
|
||||
<div class="base-input__wrapper">
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:autocomplete="autocomplete"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? `${inputId}-error` : undefined"
|
||||
:class="[
|
||||
'base-input__field',
|
||||
{ 'base-input__field--error': error }
|
||||
]"
|
||||
@input="handleInput"
|
||||
@blur="$emit('blur', $event)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
@keyup="$emit('keyup', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" :id="`${inputId}-error`" class="base-input__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="helpText" class="base-input__help">
|
||||
{{ helpText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number
|
||||
type?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
autocomplete?: string
|
||||
error?: string
|
||||
helpText?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
blur: [event: FocusEvent]
|
||||
focus: [event: FocusEvent]
|
||||
keydown: [event: KeyboardEvent]
|
||||
keyup: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = props.type === 'number' ? parseFloat(target.value) || 0 : target.value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
inputRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.base-input__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.base-input__required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.base-input__field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background-color: #ffffff;
|
||||
color: #111827;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.base-input__field:focus {
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.base-input__field:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.base-input__field:readonly {
|
||||
background-color: #f9fafb;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.base-input__field--error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__field--error:focus {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.base-input__error {
|
||||
font-size: 0.875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-input__help {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base-input__label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.base-input__field {
|
||||
background-color: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.base-input__field:disabled,
|
||||
.base-input__field:readonly {
|
||||
background-color: #1f2937;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-input__help {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="base-textarea">
|
||||
<label v-if="label" :for="textareaId" class="base-textarea__label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="base-textarea__required">*</span>
|
||||
</label>
|
||||
|
||||
<div class="base-textarea__wrapper">
|
||||
<textarea
|
||||
:id="textareaId"
|
||||
ref="textareaRef"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? `${textareaId}-error` : undefined"
|
||||
:class="[
|
||||
'base-textarea__field',
|
||||
{ 'base-textarea__field--error': error }
|
||||
]"
|
||||
@input="handleInput"
|
||||
@blur="$emit('blur', $event)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@keydown="handleKeydown"
|
||||
@keyup="$emit('keyup', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showCharCount && maxlength" class="base-textarea__char-count">
|
||||
{{ modelValue.length }}/{{ maxlength }}
|
||||
</div>
|
||||
|
||||
<div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="helpText" class="base-textarea__help">
|
||||
{{ helpText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
rows?: number
|
||||
maxlength?: number
|
||||
showCharCount?: boolean
|
||||
error?: string
|
||||
helpText?: string
|
||||
id?: string
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
rows: 3,
|
||||
showCharCount: false,
|
||||
autoResize: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
blur: [event: FocusEvent]
|
||||
focus: [event: FocusEvent]
|
||||
keydown: [event: KeyboardEvent]
|
||||
keyup: [event: KeyboardEvent]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
|
||||
if (props.autoResize) {
|
||||
autoResize(target)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
emit('keydown', event)
|
||||
|
||||
// Submit on Ctrl+Enter or Cmd+Enter
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
emit('submit')
|
||||
}
|
||||
}
|
||||
|
||||
const autoResize = (textarea: HTMLTextAreaElement) => {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
textareaRef.value?.select()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
selectAll,
|
||||
textareaRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-textarea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.base-textarea__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.base-textarea__required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-textarea__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.base-textarea__field {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background-color: #ffffff;
|
||||
color: #111827;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.base-textarea__field:focus {
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.base-textarea__field:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.base-textarea__field:readonly {
|
||||
background-color: #f9fafb;
|
||||
cursor: default;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.base-textarea__field--error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.base-textarea__field--error:focus {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.base-textarea__char-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.base-textarea__error {
|
||||
font-size: 0.875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.base-textarea__help {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base-textarea__label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.base-textarea__field {
|
||||
background-color: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.base-textarea__field:disabled,
|
||||
.base-textarea__field:readonly {
|
||||
background-color: #1f2937;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-textarea__help,
|
||||
.base-textarea__char-count {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<span class="icon" :class="[`icon-${name}`, sizeClass]">
|
||||
<!-- Microphone -->
|
||||
<svg v-if="name === 'microphone'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C13.1 2 14 2.9 14 4V10C14 11.1 13.1 12 12 12S10 11.1 10 10V4C10 2.9 10.9 2 12 2M19 10V12C19 15.3 16.3 18 13 18V22H11V18C7.7 18 5 15.3 5 12V10H7V12C7 14.2 8.8 16 11 16H13C15.2 16 17 14.2 17 12V10H19Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Stop -->
|
||||
<svg v-else-if="name === 'stop'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18,18H6V6H18V18Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Play -->
|
||||
<svg v-else-if="name === 'play'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Send -->
|
||||
<svg v-else-if="name === 'send'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Trash -->
|
||||
<svg v-else-if="name === 'trash'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Warning -->
|
||||
<svg v-else-if="name === 'warning'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Info -->
|
||||
<svg v-else-if="name === 'info'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Camera -->
|
||||
<svg v-else-if="name === 'camera'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4,4H7L9,2H15L17,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Upload -->
|
||||
<svg v-else-if="name === 'upload'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Close -->
|
||||
<svg v-else-if="name === 'close'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Search -->
|
||||
<svg v-else-if="name === 'search'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Plus -->
|
||||
<svg v-else-if="name === 'plus'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Settings -->
|
||||
<svg v-else-if="name === 'settings'" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Default fallback -->
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md'
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => `icon-${props.size}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<header class="chat-header">
|
||||
<h2 class="chat-title">{{ channelName }}</h2>
|
||||
<div class="chat-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('search')"
|
||||
aria-label="Search messages"
|
||||
>
|
||||
🔍
|
||||
</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
channelName: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-header {
|
||||
background: #1f2937;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div
|
||||
class="file-container"
|
||||
@click="handleFileClick"
|
||||
:class="{ 'clickable': isPreviewable }"
|
||||
>
|
||||
<div class="file-icon">
|
||||
<Icon :name="fileIcon" size="md" />
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.original_name }}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
<span class="file-type">{{ file.file_type }}</span>
|
||||
</div>
|
||||
<div v-if="isPreviewable" class="preview-hint">
|
||||
Click to preview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click.stop="downloadFile"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File preview modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="file-modal"
|
||||
@click="showPreview = false"
|
||||
@keydown.escape="showPreview = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ file.original_name }}</h3>
|
||||
<button @click="showPreview = false" class="close-button">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<!-- Text preview -->
|
||||
<div v-if="isTextFile && previewContent" class="text-preview">
|
||||
<pre>{{ previewContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdfFile"
|
||||
:src="fileUrl"
|
||||
class="pdf-preview"
|
||||
></iframe>
|
||||
|
||||
<!-- Generic file info -->
|
||||
<div v-else class="file-details">
|
||||
<Icon :name="fileIcon" size="xl" />
|
||||
<p>Cannot preview this file type</p>
|
||||
<button @click="downloadFile" class="download-file-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewContent = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const ext = fileExtension.value
|
||||
|
||||
if (['pdf'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'table'
|
||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
||||
return 'archive'
|
||||
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
|
||||
return 'file-text'
|
||||
} else {
|
||||
return 'file'
|
||||
}
|
||||
})
|
||||
|
||||
const isTextFile = computed(() => {
|
||||
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
|
||||
return textExtensions.includes(fileExtension.value)
|
||||
})
|
||||
|
||||
const isPdfFile = computed(() => {
|
||||
return fileExtension.value === 'pdf'
|
||||
})
|
||||
|
||||
const isPreviewable = computed(() => {
|
||||
return isTextFile.value || isPdfFile.value
|
||||
})
|
||||
|
||||
const handleFileClick = async () => {
|
||||
if (!isPreviewable.value) {
|
||||
downloadFile()
|
||||
return
|
||||
}
|
||||
|
||||
if (isTextFile.value && !previewContent.value) {
|
||||
await loadTextPreview()
|
||||
}
|
||||
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const loadTextPreview = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch(fileUrl.value)
|
||||
const text = await response.text()
|
||||
|
||||
// Limit preview size to prevent UI issues
|
||||
if (text.length > 50000) {
|
||||
previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
|
||||
} else {
|
||||
previewContent.value = text
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file preview:', error)
|
||||
previewContent.value = 'Error loading file preview'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
const response = await fetch(fileUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.file-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-container.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.file-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pdf-preview {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-file-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.download-file-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-container {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.file-container.clickable:hover {
|
||||
background: #4b5563;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.text-preview pre {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-details {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="image-message">
|
||||
<div
|
||||
class="image-thumbnail"
|
||||
@click="showFullSize = true"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="thumbnail"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<Icon name="search" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<span class="image-name">{{ file.original_name }}</span>
|
||||
<span class="image-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Full-size image modal -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="showFullSize"
|
||||
class="image-modal"
|
||||
@click="showFullSize = false"
|
||||
@keydown.escape="showFullSize = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="file.original_name"
|
||||
class="full-image"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button @click="downloadImage" class="action-button">
|
||||
<Icon name="download" size="sm" />
|
||||
Download
|
||||
</button>
|
||||
<button @click="showFullSize = false" class="action-button">
|
||||
<Icon name="x" size="sm" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showFullSize = ref(false)
|
||||
const imageError = ref(false)
|
||||
|
||||
const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imageUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = imageUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && showFullSize.value) {
|
||||
showFullSize.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-thumbnail:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: #6b7280;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.full-image {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.image-thumbnail {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.image-thumbnail:hover {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.image-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="input-actions">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('file-upload')"
|
||||
aria-label="Upload file"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📎
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('camera')"
|
||||
aria-label="Take photo"
|
||||
:disabled="disabled"
|
||||
>
|
||||
📷
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('voice')"
|
||||
aria-label="Record voice message"
|
||||
:disabled="disabled"
|
||||
>
|
||||
🎤
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || disabled"
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
canSend?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
canSend: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
'send': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="message-input-container">
|
||||
<div class="message-input">
|
||||
<BaseTextarea
|
||||
v-model="messageText"
|
||||
placeholder="Type a message..."
|
||||
:rows="1"
|
||||
auto-resize
|
||||
@keydown="handleInputKeydown"
|
||||
@submit="handleSubmit"
|
||||
ref="textareaRef"
|
||||
/>
|
||||
|
||||
<InputActions
|
||||
:disabled="isDisabled"
|
||||
:can-send="canSend"
|
||||
@file-upload="$emit('file-upload')"
|
||||
@camera="$emit('camera')"
|
||||
@voice="$emit('voice')"
|
||||
@send="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import InputActions from './InputActions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'send-message': [content: string]
|
||||
'file-upload': []
|
||||
'camera': []
|
||||
'voice': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { playWater, playSent } = useAudio()
|
||||
|
||||
const messageText = ref('')
|
||||
const textareaRef = ref()
|
||||
|
||||
const currentChannelId = computed(() => appStore.currentChannelId)
|
||||
const isDisabled = computed(() => !currentChannelId.value)
|
||||
const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSend.value) return
|
||||
|
||||
const content = messageText.value.trim()
|
||||
messageText.value = ''
|
||||
|
||||
playWater()
|
||||
emit('send-message', content)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input-container {
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.message-input-container {
|
||||
background: #1f2937;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="voice-message">
|
||||
<div class="voice-player">
|
||||
<button
|
||||
@click="togglePlayback"
|
||||
class="play-button"
|
||||
:disabled="loading"
|
||||
>
|
||||
<Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
|
||||
</button>
|
||||
|
||||
<div class="voice-info">
|
||||
<div class="voice-waveform">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="voice-meta" aria-live="off">
|
||||
<span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="downloadVoice"
|
||||
class="download-button"
|
||||
title="Download"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="voice-filename">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { apiService } from '@/services/api'
|
||||
import Icon from '@/components/base/Icon.vue'
|
||||
import type { FileAttachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
file: FileAttachment
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
let audio: HTMLAudioElement | null = null
|
||||
|
||||
const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
|
||||
|
||||
const progress = computed(() => {
|
||||
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
|
||||
})
|
||||
|
||||
const togglePlayback = async () => {
|
||||
if (!audio) {
|
||||
await initAudio()
|
||||
}
|
||||
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audio.pause()
|
||||
} else {
|
||||
await audio.play()
|
||||
}
|
||||
}
|
||||
|
||||
const initAudio = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
audio = new Audio(audioUrl.value)
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const audioDuration = audio!.duration
|
||||
duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
|
||||
})
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio!.currentTime
|
||||
})
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
})
|
||||
|
||||
await audio.load()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load audio:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!isFinite(seconds) || isNaN(seconds)) {
|
||||
return '0:00'
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const downloadVoice = async () => {
|
||||
try {
|
||||
const response = await fetch(audioUrl.value)
|
||||
const blob = await response.blob()
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.original_name
|
||||
link.click()
|
||||
|
||||
// Clean up the blob URL after download
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to download voice message:', error)
|
||||
// Fallback to direct link
|
||||
const link = document.createElement('a')
|
||||
link.href = audioUrl.value
|
||||
link.download = props.file.original_name
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on component unmount
|
||||
onUnmounted(() => {
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audio = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voice-message {
|
||||
margin: 0.5rem 0;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.voice-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.play-button:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.play-button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voice-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.voice-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-player {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.progress {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background: #4b5563;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.voice-filename {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="channel-info-dialog">
|
||||
<div class="info-section">
|
||||
<BaseInput
|
||||
v-model="channelName"
|
||||
label="Channel name"
|
||||
placeholder="Enter channel name"
|
||||
ref="nameInput"
|
||||
/>
|
||||
|
||||
<BaseInput
|
||||
v-model="channelIdDisplay"
|
||||
label="Channel ID (for API use)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<div class="action-group">
|
||||
<h3>Channel Actions</h3>
|
||||
|
||||
<BaseButton
|
||||
@click="makeDefault"
|
||||
variant="secondary"
|
||||
:disabled="isDefault"
|
||||
>
|
||||
{{ isDefault ? 'Already Default' : 'Make Default Channel' }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="showMergeDialog = true"
|
||||
variant="secondary"
|
||||
:disabled="availableChannels.length === 0"
|
||||
>
|
||||
Merge Channel
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
@click="showDeleteConfirm = true"
|
||||
variant="danger"
|
||||
>
|
||||
Delete Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<BaseButton @click="cancel" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton @click="save" :loading="saving">
|
||||
Save Changes
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Merge Channel Dialog -->
|
||||
<BaseDialog v-model:show="showMergeDialog" title="Merge Channel" size="md">
|
||||
<div class="merge-dialog">
|
||||
<p class="merge-warning">
|
||||
This will move all messages from "{{ channel.name }}" into the selected target channel,
|
||||
then delete this channel. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="merge-form">
|
||||
<label for="target-channel">Merge into:</label>
|
||||
<select
|
||||
id="target-channel"
|
||||
v-model="selectedTargetChannel"
|
||||
class="target-select"
|
||||
>
|
||||
<option value="">Select target channel...</option>
|
||||
<option
|
||||
v-for="ch in availableChannels"
|
||||
:key="ch.id"
|
||||
:value="ch.id"
|
||||
>
|
||||
{{ ch.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<BaseButton @click="showMergeDialog = false" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="performMerge"
|
||||
variant="danger"
|
||||
:disabled="!selectedTargetChannel"
|
||||
:loading="merging"
|
||||
>
|
||||
Merge Channels
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<BaseDialog v-model:show="showDeleteConfirm" title="Delete Channel" size="md">
|
||||
<div class="delete-dialog">
|
||||
<p class="delete-warning">
|
||||
Are you sure you want to delete "{{ channel.name }}"?
|
||||
This will permanently delete all messages in this channel.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="delete-actions">
|
||||
<BaseButton @click="showDeleteConfirm = false" variant="secondary">
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="performDelete"
|
||||
variant="danger"
|
||||
:loading="deleting"
|
||||
>
|
||||
Delete Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import { syncService } from '@/services/sync'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseDialog from '@/components/base/BaseDialog.vue'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
interface Props {
|
||||
channel: Channel
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'channel-updated': [channel: Channel]
|
||||
'channel-deleted': [channelId: number]
|
||||
'channel-merged': [sourceId: number, targetId: number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
// Form state
|
||||
const channelName = ref(props.channel.name)
|
||||
const channelIdDisplay = ref(props.channel.id.toString())
|
||||
const saving = ref(false)
|
||||
|
||||
// Dialog states
|
||||
const showMergeDialog = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const selectedTargetChannel = ref<number | null>(null)
|
||||
const merging = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Input ref for focus
|
||||
const nameInput = ref()
|
||||
|
||||
// Computed properties
|
||||
const isDefault = computed(() =>
|
||||
appStore.settings.defaultChannelId === props.channel.id
|
||||
)
|
||||
|
||||
const availableChannels = computed(() =>
|
||||
appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
const makeDefault = async () => {
|
||||
try {
|
||||
await appStore.updateSettings({ defaultChannelId: props.channel.id })
|
||||
toastStore.success(`${props.channel.name} is now the default channel`)
|
||||
} catch (error) {
|
||||
console.error('Failed to set default channel:', error)
|
||||
toastStore.error('Failed to set default channel')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!channelName.value.trim()) {
|
||||
toastStore.error('Channel name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// Try online update first
|
||||
try {
|
||||
await apiService.updateChannel(props.channel.id, channelName.value.trim())
|
||||
// Update local store
|
||||
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
|
||||
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
|
||||
if (channelIndex !== -1) {
|
||||
appStore.channels[channelIndex] = updatedChannel
|
||||
await appStore.saveState()
|
||||
}
|
||||
emit('channel-updated', updatedChannel)
|
||||
toastStore.success('Channel updated successfully')
|
||||
} catch (error) {
|
||||
// Offline fallback - update locally only
|
||||
console.log('Offline mode: updating channel locally')
|
||||
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
|
||||
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
|
||||
if (channelIndex !== -1) {
|
||||
appStore.channels[channelIndex] = updatedChannel
|
||||
await appStore.saveState()
|
||||
}
|
||||
emit('channel-updated', updatedChannel)
|
||||
toastStore.success('Channel updated locally (will sync when online)')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to update channel:', error)
|
||||
toastStore.error('Failed to update channel')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performMerge = async () => {
|
||||
if (!selectedTargetChannel.value) return
|
||||
|
||||
try {
|
||||
merging.value = true
|
||||
|
||||
// Try online merge first
|
||||
try {
|
||||
await apiService.mergeChannels(props.channel.id, selectedTargetChannel.value)
|
||||
// Remove source channel from local store
|
||||
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
// Clear messages for the merged channel
|
||||
delete appStore.messages[props.channel.id]
|
||||
await appStore.saveState()
|
||||
|
||||
emit('channel-merged', props.channel.id, selectedTargetChannel.value)
|
||||
toastStore.success('Channels merged successfully')
|
||||
|
||||
// Switch to target channel if we were on the source channel
|
||||
if (appStore.currentChannelId === props.channel.id) {
|
||||
await appStore.setCurrentChannel(selectedTargetChannel.value)
|
||||
}
|
||||
} catch (error) {
|
||||
// For merge, we can't do offline fallback easily since it affects multiple channels
|
||||
console.error('Failed to merge channels:', error)
|
||||
toastStore.error('Failed to merge channels - this requires an internet connection')
|
||||
}
|
||||
|
||||
showMergeDialog.value = false
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to merge channels:', error)
|
||||
toastStore.error('Failed to merge channels')
|
||||
} finally {
|
||||
merging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performDelete = async () => {
|
||||
try {
|
||||
deleting.value = true
|
||||
|
||||
// Try online delete first
|
||||
try {
|
||||
await apiService.deleteChannel(props.channel.id)
|
||||
// Remove from local store
|
||||
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
|
||||
delete appStore.messages[props.channel.id]
|
||||
await appStore.saveState()
|
||||
|
||||
emit('channel-deleted', props.channel.id)
|
||||
toastStore.success('Channel deleted successfully')
|
||||
|
||||
// Switch to first available channel if we were on the deleted channel
|
||||
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
|
||||
await appStore.setCurrentChannel(appStore.channels[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
// For delete, we can't do offline fallback easily since it affects server state
|
||||
console.error('Failed to delete channel:', error)
|
||||
toastStore.error('Failed to delete channel - this requires an internet connection')
|
||||
}
|
||||
|
||||
showDeleteConfirm.value = false
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error)
|
||||
toastStore.error('Failed to delete channel')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.channel-info-dialog {
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.action-group h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Merge Dialog Styles */
|
||||
.merge-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.merge-warning {
|
||||
padding: 1rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.merge-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.merge-form label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.target-select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.target-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.merge-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Delete Dialog Styles */
|
||||
.delete-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 6px;
|
||||
color: #dc2626;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.actions-section {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.action-group h3 {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.merge-warning {
|
||||
background: #451a03;
|
||||
border-color: #92400e;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
background: #450a0a;
|
||||
border-color: #dc2626;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.merge-form label {
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
.target-select {
|
||||
background: #374151;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.target-select:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="create-channel-dialog">
|
||||
<form @submit.prevent="handleSubmit" class="channel-form">
|
||||
<BaseInput
|
||||
v-model="channelName"
|
||||
label="Channel Name"
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
:error="error"
|
||||
:disabled="isLoading"
|
||||
ref="nameInput"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isLoading"
|
||||
:disabled="!channelName.trim()"
|
||||
>
|
||||
Create Channel
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
created: [channelId: number]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const channelName = ref('')
|
||||
const error = ref('')
|
||||
const isLoading = ref(false)
|
||||
const nameInput = ref()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!channelName.value.trim()) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const newChannel = await apiService.createChannel(channelName.value.trim())
|
||||
appStore.addChannel(newChannel)
|
||||
toastStore.success(`Channel "${newChannel.name}" created successfully!`)
|
||||
emit('created', newChannel.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel:', err)
|
||||
error.value = 'Failed to create channel. Please try again.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nameInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-channel-dialog {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,467 @@
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface AudioRecording {
|
||||
blob: Blob | null
|
||||
duration: number
|
||||
isRecording: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
// Global audio state to ensure singleton behavior
|
||||
let audioSystemInitialized = false
|
||||
let soundsLoaded = false
|
||||
let globalAudioContext: AudioContext | null = null
|
||||
let globalSoundBuffers = new Map<string, AudioBuffer>()
|
||||
let globalWaterSounds: AudioBuffer[] = []
|
||||
let globalSentSounds: AudioBuffer[] = []
|
||||
|
||||
export function useAudio() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Audio Context (use global instance)
|
||||
const audioContext = ref<AudioContext | null>(globalAudioContext)
|
||||
|
||||
// Sound buffers (use global arrays)
|
||||
const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
|
||||
const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
|
||||
const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
|
||||
|
||||
// Recording state
|
||||
const recording = ref<AudioRecording>({
|
||||
blob: null,
|
||||
duration: 0,
|
||||
isRecording: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
})
|
||||
|
||||
// Media recorder
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let recordingChunks: Blob[] = []
|
||||
let recordingStartTime: number = 0
|
||||
let recordingInterval: number | null = null
|
||||
|
||||
// Text-to-speech state
|
||||
const isSpeaking = ref(false)
|
||||
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
||||
const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
|
||||
|
||||
// Initialize audio context
|
||||
const initAudioContext = async () => {
|
||||
if (!globalAudioContext) {
|
||||
globalAudioContext = new AudioContext()
|
||||
audioContext.value = globalAudioContext
|
||||
}
|
||||
|
||||
if (globalAudioContext.state === 'suspended') {
|
||||
try {
|
||||
await globalAudioContext.resume()
|
||||
} catch (error) {
|
||||
console.warn('AudioContext resume failed, user interaction required:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load a single sound file
|
||||
const loadSound = async (url: string): Promise<AudioBuffer | null> => {
|
||||
try {
|
||||
if (!audioContext.value) {
|
||||
await initAudioContext()
|
||||
}
|
||||
|
||||
if (!audioContext.value) {
|
||||
// AudioContext creation failed (probably no user interaction yet)
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
return await audioContext.value.decodeAudioData(arrayBuffer)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load sound ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Load all sound files
|
||||
const loadAllSounds = async () => {
|
||||
if (soundsLoaded) {
|
||||
console.log('Sounds already loaded, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting to load all sounds...')
|
||||
soundsLoaded = true
|
||||
|
||||
// Load basic sounds
|
||||
const basicSounds = {
|
||||
intro: '/sounds/intro.wav',
|
||||
login: '/sounds/login.wav',
|
||||
copy: '/sounds/copy.wav',
|
||||
uploadFailed: '/sounds/uploadfail.wav'
|
||||
}
|
||||
|
||||
for (const [name, url] of Object.entries(basicSounds)) {
|
||||
const buffer = await loadSound(url)
|
||||
if (buffer) {
|
||||
globalSoundBuffers.set(name, buffer)
|
||||
soundBuffers.value.set(name, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
// Load water sounds (1-10)
|
||||
console.log('Loading water sounds...')
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`/sounds/water${i}.wav`)
|
||||
if (buffer) {
|
||||
globalWaterSounds.push(buffer)
|
||||
waterSounds.value.push(buffer)
|
||||
console.log(`Loaded water sound ${i}`)
|
||||
} else {
|
||||
console.warn(`Failed to load water sound ${i}`)
|
||||
}
|
||||
}
|
||||
console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
|
||||
|
||||
// Load sent sounds (1-6)
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`/sounds/sent${i}.wav`)
|
||||
if (buffer) {
|
||||
globalSentSounds.push(buffer)
|
||||
sentSounds.value.push(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All sounds loaded and ready to play')
|
||||
} catch (error) {
|
||||
console.error('Error loading sounds:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play a sound buffer
|
||||
const playSoundBuffer = async (buffer: AudioBuffer) => {
|
||||
if (!appStore.settings.soundEnabled) return
|
||||
|
||||
try {
|
||||
await initAudioContext()
|
||||
if (!globalAudioContext) {
|
||||
console.error('AudioContext not initialized')
|
||||
return
|
||||
}
|
||||
const source = globalAudioContext.createBufferSource()
|
||||
source.buffer = buffer
|
||||
source.connect(globalAudioContext.destination)
|
||||
source.start(0)
|
||||
} catch (error) {
|
||||
console.error('Error playing sound:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Play specific sounds
|
||||
const playSound = async (name: string) => {
|
||||
const buffer = globalSoundBuffers.get(name)
|
||||
if (buffer) {
|
||||
await playSoundBuffer(buffer)
|
||||
} else {
|
||||
console.warn(`Sound ${name} not loaded`)
|
||||
}
|
||||
}
|
||||
|
||||
const playWater = async () => {
|
||||
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Water sounds not loaded - trying to load them now')
|
||||
if (globalAudioContext) {
|
||||
await loadAllSounds()
|
||||
if (globalWaterSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
|
||||
await playSoundBuffer(globalWaterSounds[randomIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playSent = async () => {
|
||||
if (globalSentSounds.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
|
||||
await playSoundBuffer(globalSentSounds[randomIndex])
|
||||
} else {
|
||||
console.warn('Sent sounds not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
// Voice recording
|
||||
const startRecording = async (): Promise<boolean> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
recordingChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordingChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
|
||||
recording.value.blob = blob
|
||||
recording.value.isRecording = false
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval)
|
||||
recordingInterval = null
|
||||
}
|
||||
|
||||
// Stop all tracks to release microphone
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
recording.value.isRecording = true
|
||||
recording.value.duration = 0
|
||||
recordingStartTime = Date.now()
|
||||
|
||||
// Update duration every 100ms
|
||||
recordingInterval = setInterval(() => {
|
||||
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
||||
}, 100)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error)
|
||||
recording.value.isRecording = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder && recording.value.isRecording) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = async () => {
|
||||
if (!recording.value.blob) return false
|
||||
|
||||
try {
|
||||
const audio = new Audio(URL.createObjectURL(recording.value.blob))
|
||||
|
||||
recording.value.isPlaying = true
|
||||
recording.value.currentTime = 0
|
||||
|
||||
audio.ontimeupdate = () => {
|
||||
recording.value.currentTime = audio.currentTime
|
||||
}
|
||||
|
||||
audio.onended = () => {
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
URL.revokeObjectURL(audio.src)
|
||||
}
|
||||
|
||||
await audio.play()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to play recording:', error)
|
||||
recording.value.isPlaying = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const clearRecording = () => {
|
||||
if (recording.value.blob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
|
||||
}
|
||||
recording.value.blob = null
|
||||
recording.value.duration = 0
|
||||
recording.value.isPlaying = false
|
||||
recording.value.currentTime = 0
|
||||
}
|
||||
|
||||
// Text-to-speech functions
|
||||
const loadVoices = () => {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
availableVoices.value = voices
|
||||
|
||||
// Select default voice (prefer English voices)
|
||||
if (!selectedVoice.value && voices.length > 0) {
|
||||
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
|
||||
selectedVoice.value = englishVoice || voices[0]
|
||||
}
|
||||
}
|
||||
|
||||
const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
|
||||
if (!appStore.settings.ttsEnabled) return Promise.resolve()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Stop any current speech
|
||||
speechSynthesis.cancel()
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
// Set voice if available
|
||||
if (selectedVoice.value) {
|
||||
utterance.voice = selectedVoice.value
|
||||
}
|
||||
|
||||
// Apply options
|
||||
utterance.rate = options.rate || appStore.settings.ttsRate || 1
|
||||
utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
|
||||
utterance.volume = options.volume || appStore.settings.ttsVolume || 1
|
||||
|
||||
utterance.onstart = () => {
|
||||
isSpeaking.value = true
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
isSpeaking.value = false
|
||||
resolve()
|
||||
}
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
isSpeaking.value = false
|
||||
console.error('Speech synthesis error:', event.error)
|
||||
reject(new Error(`Speech synthesis failed: ${event.error}`))
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
} else {
|
||||
reject(new Error('Speech synthesis not supported'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopSpeaking = () => {
|
||||
if ('speechSynthesis' in window) {
|
||||
speechSynthesis.cancel()
|
||||
isSpeaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setVoice = (voice: SpeechSynthesisVoice) => {
|
||||
selectedVoice.value = voice
|
||||
appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
|
||||
}
|
||||
|
||||
// Announce message for accessibility
|
||||
const announceMessage = async (content: string, channel?: string) => {
|
||||
if (!appStore.settings.ttsEnabled) return
|
||||
|
||||
let textToSpeak = content
|
||||
if (channel) {
|
||||
textToSpeak = `New message in ${channel}: ${content}`
|
||||
}
|
||||
|
||||
try {
|
||||
await speak(textToSpeak)
|
||||
} catch (error) {
|
||||
console.error('Failed to announce message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed
|
||||
const canRecord = computed(() => {
|
||||
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
})
|
||||
|
||||
const recordingDurationFormatted = computed(() => {
|
||||
const duration = recording.value.duration
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
// Initialize audio on first user interaction
|
||||
const initAudioOnUserGesture = async () => {
|
||||
if (!audioContext.value || audioContext.value.state === 'suspended') {
|
||||
await initAudioContext()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system (only once)
|
||||
const initializeAudioSystem = () => {
|
||||
if (!audioSystemInitialized) {
|
||||
audioSystemInitialized = true
|
||||
|
||||
// Set up user gesture listeners to initialize audio and load sounds
|
||||
const initializeAudio = async () => {
|
||||
console.log('User interaction detected, initializing audio system...')
|
||||
await initAudioOnUserGesture()
|
||||
await loadAllSounds() // Load sounds after user interaction
|
||||
console.log('Audio system initialized')
|
||||
document.removeEventListener('click', initializeAudio)
|
||||
document.removeEventListener('keydown', initializeAudio)
|
||||
}
|
||||
|
||||
document.addEventListener('click', initializeAudio, { once: true })
|
||||
document.addEventListener('keydown', initializeAudio, { once: true })
|
||||
|
||||
// Initialize voices for speech synthesis
|
||||
if ('speechSynthesis' in window) {
|
||||
loadVoices()
|
||||
// Voices may not be immediately available
|
||||
speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||
|
||||
// Restore selected voice from settings
|
||||
if (appStore.settings.selectedVoiceURI) {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
|
||||
if (savedVoice) {
|
||||
selectedVoice.value = savedVoice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize audio system when composable is first used
|
||||
initializeAudioSystem()
|
||||
|
||||
return {
|
||||
// State
|
||||
recording,
|
||||
canRecord,
|
||||
recordingDurationFormatted,
|
||||
isSpeaking: readonly(isSpeaking),
|
||||
availableVoices: readonly(availableVoices),
|
||||
selectedVoice: readonly(selectedVoice),
|
||||
|
||||
// Audio playback
|
||||
playSound,
|
||||
playWater,
|
||||
playSent,
|
||||
|
||||
// Voice recording
|
||||
startRecording,
|
||||
stopRecording,
|
||||
playRecording,
|
||||
clearRecording,
|
||||
|
||||
// Text-to-speech
|
||||
speak,
|
||||
stopSpeaking,
|
||||
setVoice,
|
||||
announceMessage,
|
||||
|
||||
// Audio context
|
||||
initAudioContext
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ref, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { apiService } from '@/services/api'
|
||||
import type { UnsentMessage } from '@/types'
|
||||
|
||||
export function useOfflineSync() {
|
||||
const appStore = useAppStore()
|
||||
const toastStore = useToastStore()
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const isSyncing = ref(false)
|
||||
let syncInterval: number | null = null
|
||||
|
||||
// Monitor online status
|
||||
const updateOnlineStatus = () => {
|
||||
const wasOnline = isOnline.value
|
||||
isOnline.value = navigator.onLine
|
||||
|
||||
if (!wasOnline && isOnline.value) {
|
||||
toastStore.success('Back online - syncing data...')
|
||||
syncUnsentMessages()
|
||||
} else if (wasOnline && !isOnline.value) {
|
||||
toastStore.info('You are offline - messages will be queued')
|
||||
}
|
||||
}
|
||||
|
||||
// Sync unsent messages when online
|
||||
const syncUnsentMessages = async () => {
|
||||
if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing.value = true
|
||||
const failedMessages: UnsentMessage[] = []
|
||||
|
||||
for (const unsentMessage of appStore.unsentMessages) {
|
||||
try {
|
||||
const response = await apiService.createMessage(
|
||||
unsentMessage.channelId,
|
||||
unsentMessage.content
|
||||
)
|
||||
|
||||
// Message sent successfully - remove from unsent queue
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
|
||||
// Add to messages (will be handled by WebSocket event too)
|
||||
appStore.addMessage(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync message:', error)
|
||||
|
||||
// Increment retry count (create mutable copy)
|
||||
const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
|
||||
|
||||
// If too many retries, give up
|
||||
if (mutableMessage.retries >= 3) {
|
||||
toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
|
||||
appStore.removeUnsentMessage(unsentMessage.id)
|
||||
} else {
|
||||
failedMessages.push(mutableMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update unsent messages with failed ones
|
||||
if (failedMessages.length > 0) {
|
||||
toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
|
||||
} else if (appStore.unsentMessages.length > 0) {
|
||||
toastStore.success('All offline messages synced!')
|
||||
}
|
||||
|
||||
isSyncing.value = false
|
||||
await appStore.saveState()
|
||||
}
|
||||
|
||||
// Queue message for sending when offline
|
||||
const queueMessage = async (channelId: number, content: string): Promise<string> => {
|
||||
const unsentMessage: UnsentMessage = {
|
||||
id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
channelId,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
}
|
||||
|
||||
appStore.addUnsentMessage(unsentMessage)
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to send immediately if online
|
||||
if (isOnline.value) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
|
||||
return unsentMessage.id
|
||||
}
|
||||
|
||||
// Send message (online or queue for offline)
|
||||
const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
|
||||
if (isOnline.value) {
|
||||
try {
|
||||
const response = await apiService.createMessage(channelId, content)
|
||||
appStore.addMessage(response)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send message online:', error)
|
||||
// Fall back to queuing
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.error('Failed to send message - queued for later')
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
await queueMessage(channelId, content)
|
||||
toastStore.info('Message queued for sending when online')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save state periodically
|
||||
const startAutoSave = () => {
|
||||
if (syncInterval) clearInterval(syncInterval)
|
||||
|
||||
syncInterval = setInterval(async () => {
|
||||
try {
|
||||
await appStore.saveState()
|
||||
|
||||
// Try to sync unsent messages if online
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-save failed:', error)
|
||||
}
|
||||
}, 10000) // Save every 10 seconds
|
||||
}
|
||||
|
||||
const stopAutoSave = () => {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle beforeunload to save state
|
||||
const handleBeforeUnload = () => {
|
||||
appStore.saveState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Add event listeners
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
// Start auto-save
|
||||
startAutoSave()
|
||||
|
||||
// Initial sync if online and has unsent messages
|
||||
if (isOnline.value && appStore.unsentMessages.length > 0) {
|
||||
syncUnsentMessages()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
stopAutoSave()
|
||||
})
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
sendMessage,
|
||||
syncUnsentMessages,
|
||||
queueMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { apiService } from './services/api'
|
||||
|
||||
// Import routes
|
||||
import { routes } from './router/index'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Router guard to ensure API service has proper token
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { useAuthStore } = await import('./stores/auth')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check authentication first
|
||||
await authStore.checkAuth()
|
||||
|
||||
// Check if going to protected route
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
// If authenticated but going to auth page, redirect to main
|
||||
if (authStore.isAuthenticated && to.name === 'auth') {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Set token for API service if authenticated
|
||||
if (authStore.isAuthenticated && authStore.token) {
|
||||
apiService.setToken(authStore.token)
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import MainView from '@/views/MainView.vue'
|
||||
import AuthView from '@/views/AuthView.vue'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
name: 'auth',
|
||||
component: AuthView
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { ToastMessage } from '@/types'
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<ToastMessage[]>([])
|
||||
|
||||
const addToast = (message: string, type: ToastMessage['type'] = 'info', duration = 3000) => {
|
||||
const id = Date.now().toString()
|
||||
const toast: ToastMessage = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const clearToasts = () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
const success = (message: string, duration?: number) => addToast(message, 'success', duration)
|
||||
const error = (message: string, duration?: number) => addToast(message, 'error', duration)
|
||||
const info = (message: string, duration?: number) => addToast(message, 'info', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
clearToasts,
|
||||
success,
|
||||
error,
|
||||
info
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,wav,mp3}']
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'sounds/*.wav'],
|
||||
manifest: {
|
||||
name: 'Notebrook',
|
||||
short_name: 'Notebrook',
|
||||
description: 'Light note taking app in messenger style',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user