Compare commits

...

10 Commits

76 changed files with 17125 additions and 4 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ backend/db.sqlite
backend/uploads/
.DS_Store
frontend/dist/
frontend-vue/dist

View File

@@ -6,5 +6,6 @@ export const router = Router({mergeParams: true});
router.post('/', authenticate, ChannelController.createChannel);
router.get('/', authenticate, ChannelController.getChannels);
router.put('/:channelId', authenticate, ChannelController.updateChannel);
router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);

View File

@@ -4,21 +4,21 @@ WORKDIR /usr/src/app
FROM base AS install
COPY backend/package.json backend/package-lock.json /temp/dev/backend/
COPY frontend/package.json frontend/package-lock.json /temp/dev/frontend/
COPY frontend-vue/package.json frontend-vue/package-lock.json /temp/dev/frontend/
RUN cd /temp/dev/backend && npm install
RUN cd /temp/dev/frontend && npm install
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
COPY backend/package.json backend/package-lock.json /temp/prod/backend/
COPY frontend/package.json frontend/package-lock.json /temp/prod/frontend/
COPY frontend-vue/package.json frontend-vue/package-lock.json /temp/prod/frontend/
RUN cd /temp/prod/backend && npm install --production
RUN cd /temp/prod/frontend && npm install --production
FROM install AS build-frontend
WORKDIR /usr/src/app/frontend
COPY frontend/ .
COPY frontend-vue/ .
COPY --from=install /temp/dev/frontend/node_modules node_modules
RUN npm run build

8
frontend-vue/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1 @@
PWA icons would go here - placeholder files

View File

@@ -0,0 +1 @@
PWA icons would go here - placeholder files

14
frontend-vue/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notebrook</title>
<meta name="description" content="Light note taking app in messenger style">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

8340
frontend-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend-vue/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "notebrook-frontend-vue",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.4.5",
"pinia": "^2.3.0",
"idb-keyval": "^6.2.1",
"@vueuse/core": "^11.3.0",
"@vueuse/sound": "^2.0.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.1.10",
"vite-plugin-pwa": "^0.21.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.1.3",
"eslint": "^9.17.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2"
}
}

View File

@@ -0,0 +1 @@
data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVFiFtZddaBxVFMd/587M7iabNE1TbdI2VsWCVixYH/xAfRBBfBBBQcEH8UVBfPBBRcEXwQcfxAcRfLAP4oMPIr74oFgQC1qwYKHggyK2aZu0SZM0yWYzOzNz78/HmZ1Ndjeb3ewcmLkz95z/+Z977jl3wDmH/4NSSoUQoj9JknW+73vW2oy1NhVCmBDiEjAAbAImhBDHjTEfN5vNkw8//PBs/xfKOVf3f9M0zXe995cBjDHGOWdLpVIhhBgHtgArgBHgdeAW4CpgDaDAJmvtF8aYhzqdzrfOuer8OLquA6RSqd1a61XGmHettVprDYBzLhFCWGPMJuDncrn8erVavbNQKFyz2Wye6XQ6RwF836dt2581m81HnHNXd133jRBiddcCY8wW7/1w13WTUqnkOOdahULBSSm9EGKllHKVUmqllHKFUqovhOgTQvQJIXqFED1SyjYwC3Qcxzifw2az+WGhUBhpNpudtbW1x7TWnzrnXhJCbFtqQZIkh5Ik+dQY87ExZtwYM+m9P2mtPQWc6nQ6p1ut1ulu+1prbTtlNpqmabZarVONRuNU13XfOecOWmtPCCGOAkeNMd855+aXLbfAGDNhjPl69+7dW7XWu4wx9xhj7gZuB24E1gJVoAyUgBJgbLFYrBljjo2NjX2xvb39/aNHj06sXr06tm17DhgZGRnZIaW8p1KpbO3MdAY7M1kHGoTw+sDAQH9/f//qcrm8ptls/t1oNE6fP3/+1wMHDkxNTU1NlUqlcrVabf3www8AcO+991JZt25dvVGvfzs7Ozs+Ozt7dnp6+sza2tqZqampk1NTU5OTk5MT3vvJ+vr6ifvuu+8TAOcchw4dYmhoyAjhZ2sAbW3VarXJycnJqenp6dOzs7Ozs7Ozk9PT0xPT09MT9Xr9XLfdr+nKqampyenp6cnZ2dnJvr6+yfHx8S0AkiQxzjmstVq11qIsZvmm/dFrdDo/bthubr+q02ht7na7OT58+DAXL16k0Wjwl4ZCofCElPKxarW6O06cLGYJ07RAGOe9nxkfHz+2adOmN4aHh68HhqSUryilHlNKPeG9X9tFJKV8Ukp5u5TyHinl3c65ByuVygMLOgC2b9/OsqUUCGELhcJOKeU+KeXjUsrHpZRPSSn3SCl3Sil3SCm3Sym3Sym3LblAKTUipbxVSrlFSrlZSrlJSrlRSrlBSrleKaWWF2Tt+2Y5kZqX5hXjjn/TF9tGm7qCRAAAAABJRU5ErkJggg==

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.

74
frontend-vue/src/App.vue Normal file
View File

@@ -0,0 +1,74 @@
<template>
<div id="app">
<router-view />
<!-- Toast notifications -->
<div class="toast-container" v-if="toastStore.toasts.length > 0">
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
class="toast"
:class="toast.type"
>
{{ toast.message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
// Authentication is now handled by the router guard in main.ts
</script>
<style>
#app {
height: 100vh;
width: 100vw;
overflow: hidden;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 12px 16px;
border-radius: 8px;
color: white;
font-size: 14px;
max-width: 300px;
animation: slideIn 0.3s ease-out;
}
.toast.success {
background-color: #10b981;
}
.toast.error {
background-color: #ef4444;
}
.toast.info {
background-color: #3b82f6;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<button
:type="type"
:disabled="disabled"
:class="[
'base-button',
`base-button--${variant}`,
`base-button--${size}`,
{ 'base-button--loading': loading }
]"
:aria-label="ariaLabel"
:aria-describedby="ariaDescribedby"
@click="$emit('click', $event)"
@keydown="handleKeydown"
>
<span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
<span :class="{ 'base-button__content--hidden': loading }">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
ariaLabel?: string
ariaDescribedby?: string
}
withDefaults(defineProps<Props>(), {
type: 'button',
variant: 'primary',
size: 'md',
disabled: false,
loading: false
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click', event as any)
}
}
</script>
<style scoped>
.base-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
text-decoration: none;
}
.base-button:focus-visible {
outline: 2px solid #646cff;
outline-offset: 2px;
}
.base-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Sizes */
.base-button--sm {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.base-button--md {
padding: 0.75rem 1rem;
font-size: 1rem;
}
.base-button--lg {
padding: 1rem 1.5rem;
font-size: 1.125rem;
}
/* Variants */
.base-button--primary {
background-color: #646cff;
color: white;
}
.base-button--primary:hover:not(:disabled) {
background-color: #535bf2;
}
.base-button--secondary {
background-color: #f9f9f9;
color: #213547;
border-color: #d1d5db;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: #9ca3af;
}
.base-button--danger {
background-color: #ef4444;
color: white;
}
.base-button--danger:hover:not(:disabled) {
background-color: #dc2626;
}
.base-button--ghost {
background-color: transparent;
color: #646cff;
}
.base-button--ghost:hover:not(:disabled) {
background-color: rgba(100, 108, 255, 0.1);
}
/* Loading state */
.base-button--loading {
cursor: wait;
}
.base-button__spinner {
position: absolute;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.base-button__content--hidden {
opacity: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-button--secondary {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #4b5563;
border-color: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<Transition name="dialog">
<div
v-if="show"
class="dialog-overlay"
@click="handleOverlayClick"
@keydown.esc="handleClose"
role="dialog"
:aria-labelledby="titleId"
:aria-describedby="contentId"
aria-modal="true"
>
<div
ref="dialogRef"
:class="[
'dialog',
`dialog--${size}`
]"
@click.stop
>
<div class="dialog__header" v-if="$slots.header || title">
<h2 :id="titleId" class="dialog__title">
<slot name="header">{{ title }}</slot>
</h2>
<button
v-if="closable"
class="dialog__close"
@click="handleClose"
aria-label="Close dialog"
type="button"
>
<span aria-hidden="true">&times;</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>

View File

@@ -0,0 +1,185 @@
<template>
<div class="base-input">
<label v-if="label" :for="inputId" class="base-input__label">
{{ label }}
<span v-if="required" class="base-input__required">*</span>
</label>
<div class="base-input__wrapper">
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:autocomplete="autocomplete"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? `${inputId}-error` : undefined"
:class="[
'base-input__field',
{ 'base-input__field--error': error }
]"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="$emit('keydown', $event)"
@keyup="$emit('keyup', $event)"
/>
</div>
<div v-if="error" :id="`${inputId}-error`" class="base-input__error">
{{ error }}
</div>
<div v-else-if="helpText" class="base-input__help">
{{ helpText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string | number
type?: string
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
autocomplete?: string
error?: string
helpText?: string
id?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
readonly: false,
required: false
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
}>()
const inputRef = ref<HTMLInputElement>()
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = props.type === 'number' ? parseFloat(target.value) || 0 : target.value
emit('update:modelValue', value)
}
const focus = () => {
inputRef.value?.focus()
}
defineExpose({
focus,
inputRef
})
</script>
<style scoped>
.base-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.base-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-input__required {
color: #ef4444;
}
.base-input__wrapper {
position: relative;
}
.base-input__field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background-color: #ffffff;
color: #111827;
transition: all 0.2s ease;
outline: none;
}
.base-input__field:focus {
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.base-input__field:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.base-input__field:readonly {
background-color: #f9fafb;
cursor: default;
}
.base-input__field--error {
border-color: #ef4444;
}
.base-input__field--error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-input__error {
font-size: 0.875rem;
color: #ef4444;
}
.base-input__help {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-input__label {
color: rgba(255, 255, 255, 0.87);
}
.base-input__field {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-input__field:disabled,
.base-input__field:readonly {
background-color: #1f2937;
color: #9ca3af;
}
.base-input__help {
color: #9ca3af;
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
<template>
<header class="chat-header">
<h2 class="chat-title">{{ channelName }}</h2>
<div class="chat-actions">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('search')"
aria-label="Search messages"
>
🔍
</BaseButton>
</div>
</header>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
interface Props {
channelName: string
}
defineProps<Props>()
defineEmits<{
search: []
}>()
</script>
<style scoped>
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
min-height: 64px;
}
.chat-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.chat-header {
background: #1f2937;
border-bottom-color: #374151;
}
.chat-title {
color: rgba(255, 255, 255, 0.87);
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="file-attachment">
<!-- Image files -->
<ImageMessage
v-if="isImageFile"
:file="file"
/>
<!-- Audio/voice files -->
<VoiceMessage
v-else-if="isAudioFile"
:file="file"
/>
<!-- Other files -->
<FileMessage
v-else
:file="file"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ImageMessage from './ImageMessage.vue'
import VoiceMessage from './VoiceMessage.vue'
import FileMessage from './FileMessage.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const fileExtension = computed(() => {
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
})
const isImageFile = computed(() => {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
return imageExtensions.includes(fileExtension.value)
})
const isAudioFile = computed(() => {
const audioExtensions = ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a']
return audioExtensions.includes(fileExtension.value)
})
</script>
<style scoped>
.file-attachment {
margin: 0.25rem 0;
}
.file-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
text-decoration: none;
color: #374151;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.file-link:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.file-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.75rem;
color: #6b7280;
font-weight: 400;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.file-link {
background: #374151;
border-color: #4b5563;
color: rgba(255, 255, 255, 0.87);
}
.file-link:hover {
background: #4b5563;
border-color: #6b7280;
}
.file-size {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,458 @@
<template>
<div class="file-message">
<div
class="file-container"
@click="handleFileClick"
:class="{ 'clickable': isPreviewable }"
>
<div class="file-icon">
<Icon :name="fileIcon" size="md" />
</div>
<div class="file-info">
<div class="file-name">{{ file.original_name }}</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
<span class="file-type">{{ file.file_type }}</span>
</div>
<div v-if="isPreviewable" class="preview-hint">
Click to preview
</div>
</div>
<button
@click.stop="downloadFile"
class="download-button"
title="Download"
>
<Icon name="download" size="sm" />
</button>
</div>
<!-- File preview modal -->
<teleport to="body">
<div
v-if="showPreview"
class="file-modal"
@click="showPreview = false"
@keydown.escape="showPreview = false"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ file.original_name }}</h3>
<button @click="showPreview = false" class="close-button">
<Icon name="x" size="sm" />
</button>
</div>
<div class="preview-container">
<!-- Text preview -->
<div v-if="isTextFile && previewContent" class="text-preview">
<pre>{{ previewContent }}</pre>
</div>
<!-- PDF preview -->
<iframe
v-else-if="isPdfFile"
:src="fileUrl"
class="pdf-preview"
></iframe>
<!-- Generic file info -->
<div v-else class="file-details">
<Icon :name="fileIcon" size="xl" />
<p>Cannot preview this file type</p>
<button @click="downloadFile" class="download-file-button">
<Icon name="download" size="sm" />
Download File
</button>
</div>
</div>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const showPreview = ref(false)
const previewContent = ref<string>('')
const loading = ref(false)
const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const fileExtension = computed(() => {
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
})
const fileIcon = computed(() => {
const ext = fileExtension.value
if (['pdf'].includes(ext)) {
return 'file-text'
} else if (['doc', 'docx'].includes(ext)) {
return 'file-text'
} else if (['xls', 'xlsx'].includes(ext)) {
return 'table'
} else if (['zip', 'rar', '7z'].includes(ext)) {
return 'archive'
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
return 'file-text'
} else {
return 'file'
}
})
const isTextFile = computed(() => {
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
return textExtensions.includes(fileExtension.value)
})
const isPdfFile = computed(() => {
return fileExtension.value === 'pdf'
})
const isPreviewable = computed(() => {
return isTextFile.value || isPdfFile.value
})
const handleFileClick = async () => {
if (!isPreviewable.value) {
downloadFile()
return
}
if (isTextFile.value && !previewContent.value) {
await loadTextPreview()
}
showPreview.value = true
}
const loadTextPreview = async () => {
try {
loading.value = true
const response = await fetch(fileUrl.value)
const text = await response.text()
// Limit preview size to prevent UI issues
if (text.length > 50000) {
previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
} else {
previewContent.value = text
}
} catch (error) {
console.error('Failed to load file preview:', error)
previewContent.value = 'Error loading file preview'
} finally {
loading.value = false
}
}
const downloadFile = async () => {
try {
const response = await fetch(fileUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download file:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = fileUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
</script>
<style scoped>
.file-message {
margin: 0.5rem 0;
max-width: 400px;
}
.file-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.75rem;
transition: all 0.2s ease;
}
.file-container.clickable {
cursor: pointer;
}
.file-container.clickable:hover {
background: #e5e7eb;
border-color: #3b82f6;
transform: translateY(-1px);
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: #3b82f6;
color: white;
border-radius: 8px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.25rem;
}
.file-meta {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
}
.preview-hint {
font-size: 0.75rem;
color: #3b82f6;
margin-top: 0.25rem;
}
.download-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.download-button:hover {
background: #f9fafb;
color: #374151;
border-color: #9ca3af;
}
/* Modal styles */
.file-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
cursor: default;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
color: #6b7280;
}
.close-button:hover {
color: #374151;
}
.preview-container {
flex: 1;
overflow: auto;
padding: 1rem;
}
.text-preview {
max-height: 70vh;
overflow: auto;
}
.text-preview pre {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.4;
color: #374151;
margin: 0;
}
.pdf-preview {
width: 100%;
height: 70vh;
border: none;
}
.file-details {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: #6b7280;
}
.download-file-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
cursor: pointer;
margin-top: 1rem;
transition: background 0.2s ease;
}
.download-file-button:hover {
background: #2563eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.file-container {
background: #374151;
border-color: #4b5563;
}
.file-container.clickable:hover {
background: #4b5563;
border-color: #60a5fa;
}
.file-name {
color: rgba(255, 255, 255, 0.87);
}
.file-meta {
color: rgba(255, 255, 255, 0.6);
}
.preview-hint {
color: #60a5fa;
}
.download-button {
color: rgba(255, 255, 255, 0.6);
border-color: #4b5563;
}
.download-button:hover {
background: #4b5563;
color: rgba(255, 255, 255, 0.87);
border-color: #6b7280;
}
.modal-content {
background: #1f2937;
}
.modal-header {
border-bottom-color: #374151;
}
.modal-header h3 {
color: rgba(255, 255, 255, 0.87);
}
.close-button {
color: rgba(255, 255, 255, 0.6);
}
.close-button:hover {
color: rgba(255, 255, 255, 0.87);
}
.text-preview pre {
color: rgba(255, 255, 255, 0.87);
}
.file-details {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="image-message">
<div
class="image-thumbnail"
@click="showFullSize = true"
:style="{ cursor: 'pointer' }"
>
<img
:src="imageUrl"
:alt="file.original_name"
class="thumbnail"
@error="imageError = true"
/>
<div class="image-overlay">
<Icon name="search" size="sm" />
</div>
</div>
<div class="image-info">
<span class="image-name">{{ file.original_name }}</span>
<span class="image-size">{{ formatFileSize(file.file_size) }}</span>
</div>
<!-- Full-size image modal -->
<teleport to="body">
<div
v-if="showFullSize"
class="image-modal"
@click="showFullSize = false"
@keydown.escape="showFullSize = false"
>
<div class="modal-content" @click.stop>
<img
:src="imageUrl"
:alt="file.original_name"
class="full-image"
/>
<div class="modal-actions">
<button @click="downloadImage" class="action-button">
<Icon name="download" size="sm" />
Download
</button>
<button @click="showFullSize = false" class="action-button">
<Icon name="x" size="sm" />
Close
</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const showFullSize = ref(false)
const imageError = ref(false)
const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const downloadImage = async () => {
try {
const response = await fetch(imageUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download image:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = imageUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && showFullSize.value) {
showFullSize.value = false
}
})
</script>
<style scoped>
.image-message {
margin: 0.5rem 0;
max-width: 300px;
}
.image-thumbnail {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.image-thumbnail:hover {
border-color: #3b82f6;
transform: scale(1.02);
}
.image-thumbnail:hover .image-overlay {
opacity: 1;
}
.thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
font-size: 0.75rem;
}
.image-name {
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.image-size {
color: #6b7280;
margin-left: 0.5rem;
}
/* Modal styles */
.image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
cursor: default;
}
.full-image {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: 8px;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.image-thumbnail {
border-color: #4b5563;
}
.image-thumbnail:hover {
border-color: #60a5fa;
}
.image-info {
background: #374151;
}
.image-name {
color: rgba(255, 255, 255, 0.87);
}
.image-size {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="input-actions">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('file-upload')"
aria-label="Upload file"
:disabled="disabled"
>
📎
</BaseButton>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('camera')"
aria-label="Take photo"
:disabled="disabled"
>
📷
</BaseButton>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('voice')"
aria-label="Record voice message"
:disabled="disabled"
>
🎤
</BaseButton>
<BaseButton
variant="primary"
size="sm"
@click="$emit('send')"
:disabled="!canSend || disabled"
aria-label="Send message"
>
Send
</BaseButton>
</div>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
interface Props {
disabled?: boolean
canSend?: boolean
}
withDefaults(defineProps<Props>(), {
disabled: false,
canSend: false
})
defineEmits<{
'file-upload': []
'camera': []
'voice': []
'send': []
}>()
</script>
<style scoped>
.input-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="message-input-container">
<div class="message-input">
<BaseTextarea
v-model="messageText"
placeholder="Type a message..."
:rows="1"
auto-resize
@keydown="handleInputKeydown"
@submit="handleSubmit"
ref="textareaRef"
/>
<InputActions
:disabled="isDisabled"
:can-send="canSend"
@file-upload="$emit('file-upload')"
@camera="$emit('camera')"
@voice="$emit('voice')"
@send="handleSubmit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useAudio } from '@/composables/useAudio'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import InputActions from './InputActions.vue'
const emit = defineEmits<{
'send-message': [content: string]
'file-upload': []
'camera': []
'voice': []
}>()
const appStore = useAppStore()
const { playWater, playSent } = useAudio()
const messageText = ref('')
const textareaRef = ref()
const currentChannelId = computed(() => appStore.currentChannelId)
const isDisabled = computed(() => !currentChannelId.value)
const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
const handleSubmit = () => {
if (!canSend.value) return
const content = messageText.value.trim()
messageText.value = ''
playWater()
emit('send-message', content)
}
const focus = () => {
textareaRef.value?.focus()
}
defineExpose({
focus
})
</script>
<style scoped>
.message-input-container {
padding: 1rem;
background: white;
border-top: 1px solid #e5e7eb;
}
.message-input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
max-width: 100%;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.message-input-container {
background: #1f2937;
border-top-color: #374151;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<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 {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 8px;
}
.message:hover {
background: #f1f3f4;
}
.message:focus {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
.message--unsent {
background: #fff3e0;
border-color: #ff9800;
}
.message--highlighted {
background: #e3f2fd;
border-color: #2196f3;
}
.message__content {
color: #212529;
font-size: 14px;
line-height: 1.4;
margin-bottom: 8px;
}
.message__files {
margin: 8px 0;
}
.message__meta {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.message__time {
color: #6c757d;
font-size: 12px;
}
.message__status {
color: #ff9800;
font-size: 12px;
font-weight: 500;
}
@media (prefers-color-scheme: dark) {
.message {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.message:hover {
background: #374151;
}
.message__content {
color: #e2e8f0;
}
.message__time {
color: #a0aec0;
}
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div
class="messages-container"
ref="containerRef"
@keydown="handleKeydown"
tabindex="0"
role="list"
:aria-label="messagesAriaLabel"
:aria-description="navigationHint"
>
<div class="messages" role="presentation">
<!-- Regular Messages -->
<MessageItem
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:tabindex="index === focusedMessageIndex ? 0 : -1"
:data-message-index="index"
@focus="focusedMessageIndex = index"
/>
<!-- Unsent Messages -->
<MessageItem
v-for="(unsentMsg, index) in unsentMessages"
:key="unsentMsg.id"
:message="unsentMsg"
:is-unsent="true"
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
:data-message-index="messages.length + index"
@focus="focusedMessageIndex = messages.length + index"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import MessageItem from './MessageItem.vue'
import type { ExtendedMessage, UnsentMessage } from '@/types'
interface Props {
messages: ExtendedMessage[]
unsentMessages: UnsentMessage[]
}
const emit = defineEmits<{
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
}>()
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
const focusedMessageIndex = ref(0)
// Combined messages array for easier navigation
const allMessages = computed(() => [...props.messages, ...props.unsentMessages])
const totalMessages = computed(() => allMessages.value.length)
// ARIA labels for screen readers
const messagesAriaLabel = computed(() => {
const total = totalMessages.value
const current = focusedMessageIndex.value + 1
if (total === 0) {
return 'Messages list, no messages'
} else if (total === 1) {
return 'Messages list, 1 message'
} else {
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
}
})
const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 messages, Home/End for first/last, Enter to select'
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (totalMessages.value === 0) return
let newIndex = focusedMessageIndex.value
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 1)
break
case 'ArrowDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
break
case 'PageUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 10)
break
case 'PageDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = totalMessages.value - 1
break
case 'Enter':
case ' ':
event.preventDefault()
selectCurrentMessage()
return
default:
return
}
if (newIndex !== focusedMessageIndex.value) {
focusMessage(newIndex)
}
}
const focusMessage = (index: number) => {
focusedMessageIndex.value = index
nextTick(() => {
const messageElement = containerRef.value?.querySelector(`[data-message-index="${index}"]`) as HTMLElement
if (messageElement) {
messageElement.focus()
messageElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
const selectCurrentMessage = () => {
const currentMessage = allMessages.value[focusedMessageIndex.value]
if (currentMessage) {
emit('message-selected', currentMessage, focusedMessageIndex.value)
}
}
// Method to focus a specific message (for external use, like search results)
const focusMessageById = (messageId: string | number) => {
const index = allMessages.value.findIndex(msg => msg.id === messageId)
if (index !== -1) {
focusMessage(index)
}
}
const scrollToBottom = () => {
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = containerRef.value.scrollHeight
}
})
}
// Watch for new messages and auto-scroll
watch(() => [props.messages.length, props.unsentMessages.length], () => {
// When new messages arrive, focus the last message and scroll to bottom
if (totalMessages.value > 0) {
focusedMessageIndex.value = totalMessages.value - 1
}
scrollToBottom()
})
// Reset focus when messages change significantly
watch(() => totalMessages.value, (newTotal) => {
if (focusedMessageIndex.value >= newTotal) {
focusedMessageIndex.value = Math.max(0, newTotal - 1)
}
})
onMounted(() => {
scrollToBottom()
// Focus the last message on mount
if (totalMessages.value > 0) {
focusedMessageIndex.value = totalMessages.value - 1
}
})
defineExpose({
scrollToBottom,
focusMessageById
})
</script>
<style scoped>
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #fafafa;
}
.messages-container:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.messages {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Scrollbar styling */
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: #f1f5f9;
}
.messages-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.messages-container {
background: #111827;
}
.messages-container:focus {
outline-color: #60a5fa;
}
.messages-container::-webkit-scrollbar-track {
background: #1f2937;
}
.messages-container::-webkit-scrollbar-thumb {
background: #4b5563;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<div class="voice-message">
<div class="voice-player">
<button
@click="togglePlayback"
class="play-button"
:disabled="loading"
>
<Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
</button>
<div class="voice-info">
<div class="voice-waveform">
<div class="progress-bar">
<div
class="progress"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
<div class="voice-meta" aria-live="off">
<span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
</div>
</div>
<button
@click="downloadVoice"
class="download-button"
title="Download"
>
<Icon name="download" size="sm" />
</button>
</div>
<div class="voice-filename">
{{ file.original_name }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const isPlaying = ref(false)
const loading = ref(false)
const currentTime = ref(0)
const duration = ref(0)
let audio: HTMLAudioElement | null = null
const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const progress = computed(() => {
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
})
const togglePlayback = async () => {
if (!audio) {
await initAudio()
}
if (!audio) return
if (isPlaying.value) {
audio.pause()
} else {
await audio.play()
}
}
const initAudio = async () => {
try {
loading.value = true
audio = new Audio(audioUrl.value)
audio.addEventListener('loadedmetadata', () => {
const audioDuration = audio!.duration
duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
})
audio.addEventListener('timeupdate', () => {
currentTime.value = audio!.currentTime
})
audio.addEventListener('play', () => {
isPlaying.value = true
})
audio.addEventListener('pause', () => {
isPlaying.value = false
})
audio.addEventListener('ended', () => {
isPlaying.value = false
currentTime.value = 0
})
await audio.load()
} catch (error) {
console.error('Failed to load audio:', error)
} finally {
loading.value = false
}
}
const formatTime = (seconds: number): string => {
if (!isFinite(seconds) || isNaN(seconds)) {
return '0:00'
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const downloadVoice = async () => {
try {
const response = await fetch(audioUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download voice message:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = audioUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
// Cleanup on component unmount
onUnmounted(() => {
if (audio) {
audio.pause()
audio.src = ''
audio = null
}
})
</script>
<style scoped>
.voice-message {
margin: 0.5rem 0;
max-width: 350px;
}
.voice-player {
display: flex;
align-items: center;
gap: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.75rem;
}
.play-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #3b82f6;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.play-button:hover:not(:disabled) {
background: #2563eb;
transform: scale(1.05);
}
.play-button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.voice-info {
flex: 1;
min-width: 0;
}
.voice-waveform {
margin-bottom: 0.5rem;
}
.progress-bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.progress {
height: 100%;
background: #3b82f6;
transition: width 0.1s ease;
}
.voice-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.duration {
color: #374151;
font-weight: 500;
}
.file-size {
color: #6b7280;
}
.download-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.download-button:hover {
background: #f9fafb;
color: #374151;
border-color: #9ca3af;
}
.voice-filename {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.voice-player {
background: #374151;
border-color: #4b5563;
}
.progress-bar {
background: #4b5563;
}
.progress {
background: #60a5fa;
}
.duration {
color: rgba(255, 255, 255, 0.87);
}
.file-size {
color: rgba(255, 255, 255, 0.6);
}
.download-button {
color: rgba(255, 255, 255, 0.6);
border-color: #4b5563;
}
.download-button:hover {
background: #4b5563;
color: rgba(255, 255, 255, 0.87);
border-color: #6b7280;
}
.voice-filename {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,526 @@
<template>
<div class="camera-capture-dialog">
<div class="camera-container">
<!-- Camera Feed -->
<div class="camera-feed" v-if="!capturedImage">
<video
ref="videoElement"
autoplay
playsinline
muted
:class="{ 'mirrored': isFrontCamera }"
></video>
<!-- Camera Controls Overlay -->
<div class="camera-overlay">
<div class="camera-info">
<div class="camera-status" :class="{ 'active': isStreaming }">
<Icon name="camera" />
<span v-if="isStreaming">Camera Active</span>
<span v-else>Camera Inactive</span>
</div>
</div>
<!-- Switch Camera Button -->
<BaseButton
v-if="availableCameras.length > 1"
@click="switchCamera"
variant="secondary"
size="sm"
class="switch-camera-btn"
:disabled="!isStreaming"
>
<Icon name="camera" />
Switch
</BaseButton>
</div>
</div>
<!-- Captured Image Preview -->
<div class="image-preview" v-if="capturedImage">
<img
:src="capturedImage"
alt="Captured photo"
class="captured-photo"
/>
</div>
<!-- Error Message -->
<div class="error-message" v-if="errorMessage">
<Icon name="warning" />
{{ errorMessage }}
</div>
<!-- Camera Permission Info -->
<div class="permission-info" v-if="!hasPermission && !errorMessage">
<Icon name="info" />
<p>Camera access is required to take photos. Please grant permission when prompted.</p>
</div>
</div>
<!-- Capture Controls -->
<div class="capture-controls">
<div class="capture-buttons" v-if="!capturedImage">
<BaseButton
@click="capturePhoto"
variant="primary"
size="lg"
:disabled="!isStreaming"
class="capture-btn"
>
<Icon name="camera" />
Take Photo
</BaseButton>
</div>
<div class="review-buttons" v-if="capturedImage">
<BaseButton
@click="retakePhoto"
variant="secondary"
>
<Icon name="camera" />
Retake
</BaseButton>
<BaseButton
@click="sendPhoto"
variant="primary"
:disabled="isSending"
:loading="isSending"
>
<Icon name="send" />
Send Photo
</BaseButton>
</div>
</div>
<!-- Dialog Actions -->
<div class="dialog-actions">
<BaseButton
@click="closeDialog"
variant="secondary"
>
Cancel
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/base/Icon.vue'
const emit = defineEmits<{
close: []
sent: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
// Refs
const videoElement = ref<HTMLVideoElement>()
const capturedImage = ref<string>()
const isStreaming = ref(false)
const hasPermission = ref(false)
const isSending = ref(false)
const errorMessage = ref('')
const availableCameras = ref<MediaDeviceInfo[]>([])
const currentCameraIndex = ref(0)
const isFrontCamera = ref(true)
// Stream management
let currentStream: MediaStream | null = null
// Methods
const initializeCamera = async () => {
try {
errorMessage.value = ''
// Get available cameras
const devices = await navigator.mediaDevices.enumerateDevices()
availableCameras.value = devices.filter(device => device.kind === 'videoinput')
if (availableCameras.value.length === 0) {
throw new Error('No cameras found')
}
// Start with front camera if available
const frontCamera = availableCameras.value.find(camera =>
camera.label.toLowerCase().includes('front') ||
camera.label.toLowerCase().includes('user')
)
if (frontCamera) {
currentCameraIndex.value = availableCameras.value.indexOf(frontCamera)
isFrontCamera.value = true
} else {
currentCameraIndex.value = 0
isFrontCamera.value = false
}
await startCamera()
hasPermission.value = true
} catch (error) {
console.error('Failed to initialize camera:', error)
errorMessage.value = 'Failed to access camera. Please check permissions and try again.'
hasPermission.value = false
}
}
const startCamera = async () => {
try {
// Stop current stream if exists
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop())
}
const constraints: MediaStreamConstraints = {
video: {
deviceId: availableCameras.value[currentCameraIndex.value]?.deviceId,
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: isFrontCamera.value ? 'user' : 'environment'
}
}
currentStream = await navigator.mediaDevices.getUserMedia(constraints)
if (videoElement.value) {
videoElement.value.srcObject = currentStream
isStreaming.value = true
}
} catch (error) {
console.error('Failed to start camera:', error)
throw error
}
}
const switchCamera = async () => {
if (availableCameras.value.length <= 1) return
currentCameraIndex.value = (currentCameraIndex.value + 1) % availableCameras.value.length
// Determine if this is likely a front camera
const currentCamera = availableCameras.value[currentCameraIndex.value]
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') ||
currentCamera.label.toLowerCase().includes('user')
try {
await startCamera()
} catch (error) {
console.error('Failed to switch camera:', error)
toastStore.error('Failed to switch camera')
}
}
const capturePhoto = () => {
if (!videoElement.value || !isStreaming.value) return
try {
// Create canvas to capture frame
const canvas = document.createElement('canvas')
const video = videoElement.value
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
// Flip horizontally for front camera
if (isFrontCamera.value) {
ctx.scale(-1, 1)
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height)
} else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
// Convert to data URL
capturedImage.value = canvas.toDataURL('image/jpeg', 0.8)
// Stop camera stream
stopCamera()
toastStore.success('Photo captured!')
} catch (error) {
console.error('Failed to capture photo:', error)
toastStore.error('Failed to capture photo')
}
}
const retakePhoto = () => {
capturedImage.value = undefined
initializeCamera()
}
const sendPhoto = async () => {
if (!capturedImage.value) return
isSending.value = true
errorMessage.value = ''
try {
// Create a message first to attach the photo to
const message = await apiService.createMessage(appStore.currentChannelId!, 'Photo')
// Convert data URL to blob
const response = await fetch(capturedImage.value)
const blob = await response.blob()
// Create file from blob
const file = new File([blob], `photo-${Date.now()}.jpg`, {
type: 'image/jpeg'
})
// Upload photo
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
// Immediately update the local message with file metadata
const updatedMessage = {
...message,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
// Update the message in the store
appStore.updateMessage(message.id, updatedMessage)
toastStore.success('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>

View File

@@ -0,0 +1,465 @@
<template>
<div class="channel-info-dialog">
<div class="info-section">
<BaseInput
v-model="channelName"
label="Channel name"
placeholder="Enter channel name"
ref="nameInput"
/>
<BaseInput
v-model="channelIdDisplay"
label="Channel ID (for API use)"
readonly
/>
</div>
<div class="actions-section">
<div class="action-group">
<h3>Channel Actions</h3>
<BaseButton
@click="makeDefault"
variant="secondary"
:disabled="isDefault"
>
{{ isDefault ? 'Already Default' : 'Make Default Channel' }}
</BaseButton>
<BaseButton
@click="showMergeDialog = true"
variant="secondary"
:disabled="availableChannels.length === 0"
>
Merge Channel
</BaseButton>
<BaseButton
@click="showDeleteConfirm = true"
variant="danger"
>
Delete Channel
</BaseButton>
</div>
</div>
<div class="dialog-actions">
<BaseButton @click="cancel" variant="secondary">
Cancel
</BaseButton>
<BaseButton @click="save" :loading="saving">
Save Changes
</BaseButton>
</div>
<!-- Merge Channel Dialog -->
<BaseDialog v-model:show="showMergeDialog" title="Merge Channel" size="md">
<div class="merge-dialog">
<p class="merge-warning">
This will move all messages from "{{ channel.name }}" into the selected target channel,
then delete this channel. This action cannot be undone.
</p>
<div class="merge-form">
<label for="target-channel">Merge into:</label>
<select
id="target-channel"
v-model="selectedTargetChannel"
class="target-select"
>
<option value="">Select target channel...</option>
<option
v-for="ch in availableChannels"
:key="ch.id"
:value="ch.id"
>
{{ ch.name }}
</option>
</select>
</div>
<div class="merge-actions">
<BaseButton @click="showMergeDialog = false" variant="secondary">
Cancel
</BaseButton>
<BaseButton
@click="performMerge"
variant="danger"
:disabled="!selectedTargetChannel"
:loading="merging"
>
Merge Channels
</BaseButton>
</div>
</div>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<BaseDialog v-model:show="showDeleteConfirm" title="Delete Channel" size="md">
<div class="delete-dialog">
<p class="delete-warning">
Are you sure you want to delete "{{ channel.name }}"?
This will permanently delete all messages in this channel.
This action cannot be undone.
</p>
<div class="delete-actions">
<BaseButton @click="showDeleteConfirm = false" variant="secondary">
Cancel
</BaseButton>
<BaseButton
@click="performDelete"
variant="danger"
:loading="deleting"
>
Delete Channel
</BaseButton>
</div>
</div>
</BaseDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseDialog from '@/components/base/BaseDialog.vue'
import type { Channel } from '@/types'
interface Props {
channel: Channel
}
const emit = defineEmits<{
close: []
'channel-updated': [channel: Channel]
'channel-deleted': [channelId: number]
'channel-merged': [sourceId: number, targetId: number]
}>()
const props = defineProps<Props>()
const appStore = useAppStore()
const toastStore = useToastStore()
// Form state
const channelName = ref(props.channel.name)
const channelIdDisplay = ref(props.channel.id.toString())
const saving = ref(false)
// Dialog states
const showMergeDialog = ref(false)
const showDeleteConfirm = ref(false)
const selectedTargetChannel = ref<number | null>(null)
const merging = ref(false)
const deleting = ref(false)
// Input ref for focus
const nameInput = ref()
// Computed properties
const isDefault = computed(() =>
appStore.settings.defaultChannelId === props.channel.id
)
const availableChannels = computed(() =>
appStore.channels.filter(ch => ch.id !== props.channel.id)
)
// Actions
const makeDefault = async () => {
try {
await appStore.updateSettings({ defaultChannelId: props.channel.id })
toastStore.success(`${props.channel.name} is now the default channel`)
} catch (error) {
console.error('Failed to set default channel:', error)
toastStore.error('Failed to set default channel')
}
}
const save = async () => {
if (!channelName.value.trim()) {
toastStore.error('Channel name is required')
return
}
try {
saving.value = true
// Try online update first
try {
await apiService.updateChannel(props.channel.id, channelName.value.trim())
// Update local store
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
if (channelIndex !== -1) {
appStore.channels[channelIndex] = updatedChannel
await appStore.saveState()
}
emit('channel-updated', updatedChannel)
toastStore.success('Channel updated successfully')
} catch (error) {
// Offline fallback - update locally only
console.log('Offline mode: updating channel locally')
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
if (channelIndex !== -1) {
appStore.channels[channelIndex] = updatedChannel
await appStore.saveState()
}
emit('channel-updated', updatedChannel)
toastStore.success('Channel updated locally (will sync when online)')
}
emit('close')
} catch (error) {
console.error('Failed to update channel:', error)
toastStore.error('Failed to update channel')
} finally {
saving.value = false
}
}
const performMerge = async () => {
if (!selectedTargetChannel.value) return
try {
merging.value = true
// Try online merge first
try {
await apiService.mergeChannels(props.channel.id, selectedTargetChannel.value)
// Remove source channel from local store
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
// Clear messages for the merged channel
delete appStore.messages[props.channel.id]
await appStore.saveState()
emit('channel-merged', props.channel.id, selectedTargetChannel.value)
toastStore.success('Channels merged successfully')
// Switch to target channel if we were on the source channel
if (appStore.currentChannelId === props.channel.id) {
await appStore.setCurrentChannel(selectedTargetChannel.value)
}
} catch (error) {
// For merge, we can't do offline fallback easily since it affects multiple channels
console.error('Failed to merge channels:', error)
toastStore.error('Failed to merge channels - this requires an internet connection')
}
showMergeDialog.value = false
emit('close')
} catch (error) {
console.error('Failed to merge channels:', error)
toastStore.error('Failed to merge channels')
} finally {
merging.value = false
}
}
const performDelete = async () => {
try {
deleting.value = true
// Try online delete first
try {
await apiService.deleteChannel(props.channel.id)
// Remove from local store
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
delete appStore.messages[props.channel.id]
await appStore.saveState()
emit('channel-deleted', props.channel.id)
toastStore.success('Channel deleted successfully')
// Switch to first available channel if we were on the deleted channel
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
await appStore.setCurrentChannel(appStore.channels[0].id)
}
} catch (error) {
// For delete, we can't do offline fallback easily since it affects server state
console.error('Failed to delete channel:', error)
toastStore.error('Failed to delete channel - this requires an internet connection')
}
showDeleteConfirm.value = false
emit('close')
} catch (error) {
console.error('Failed to delete channel:', error)
toastStore.error('Failed to delete channel')
} finally {
deleting.value = false
}
}
const cancel = () => {
emit('close')
}
onMounted(() => {
nameInput.value?.focus()
})
</script>
<style scoped>
.channel-info-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 2rem;
min-width: 400px;
}
.info-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.actions-section {
border-top: 1px solid #e5e7eb;
padding-top: 1.5rem;
}
.action-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
.action-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
border-top: 1px solid #e5e7eb;
padding-top: 1.5rem;
}
/* Merge Dialog Styles */
.merge-dialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.merge-warning {
padding: 1rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
color: #92400e;
margin: 0;
line-height: 1.5;
}
.merge-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.merge-form label {
font-weight: 500;
color: #374151;
}
.target-select {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
}
.target-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.merge-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Delete Dialog Styles */
.delete-dialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.delete-warning {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
margin: 0;
line-height: 1.5;
}
.delete-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.actions-section {
border-top-color: #374151;
}
.action-group h3 {
color: rgba(255, 255, 255, 0.87);
}
.dialog-actions {
border-top-color: #374151;
}
.merge-warning {
background: #451a03;
border-color: #92400e;
color: #fbbf24;
}
.delete-warning {
background: #450a0a;
border-color: #dc2626;
color: #fca5a5;
}
.merge-form label {
color: rgba(255, 255, 255, 0.87);
}
.target-select {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.target-select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="create-channel-dialog">
<form @submit.prevent="handleSubmit" class="channel-form">
<BaseInput
v-model="channelName"
label="Channel Name"
placeholder="Enter channel name"
required
:error="error"
:disabled="isLoading"
ref="nameInput"
/>
<div class="form-actions">
<BaseButton
type="button"
variant="secondary"
@click="$emit('cancel')"
:disabled="isLoading"
>
Cancel
</BaseButton>
<BaseButton
type="submit"
:loading="isLoading"
:disabled="!channelName.trim()"
>
Create Channel
</BaseButton>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
cancel: []
created: [channelId: number]
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const channelName = ref('')
const error = ref('')
const isLoading = ref(false)
const nameInput = ref()
const handleSubmit = async () => {
if (!channelName.value.trim()) return
isLoading.value = true
error.value = ''
try {
const newChannel = await apiService.createChannel(channelName.value.trim())
appStore.addChannel(newChannel)
toastStore.success(`Channel "${newChannel.name}" created successfully!`)
emit('created', newChannel.id)
} catch (err) {
console.error('Failed to create channel:', err)
error.value = 'Failed to create channel. Please try again.'
} finally {
isLoading.value = false
}
}
onMounted(() => {
nameInput.value?.focus()
})
</script>
<style scoped>
.create-channel-dialog {
padding: 1rem 0;
}
.channel-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,368 @@
<template>
<div class="file-upload-dialog">
<div class="upload-area"
:class="{ 'upload-area--dragging': isDragging }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
class="file-input"
:disabled="isUploading"
multiple
/>
<div v-if="!selectedFiles.length" class="upload-prompt">
<div class="upload-icon">📎</div>
<p>Click to select files or drag and drop</p>
<p class="upload-hint">All file types supported</p>
</div>
<div v-else class="selected-files">
<h4>Selected Files:</h4>
<div class="file-list">
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<button
@click="removeFile(index)"
class="remove-file"
:disabled="isUploading"
aria-label="Remove file"
>
×
</button>
</div>
</div>
</div>
</div>
<div v-if="uploadProgress.length > 0" class="upload-progress">
<div v-for="(progress, index) in uploadProgress" :key="index" class="progress-item">
<div class="progress-label">{{ selectedFiles[index]?.name }}</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
</div>
<div class="progress-text">{{ progress }}%</div>
</div>
</div>
<div class="dialog-actions">
<BaseButton
variant="secondary"
@click="$emit('cancel')"
:disabled="isUploading"
>
Cancel
</BaseButton>
<BaseButton
@click="uploadFiles"
:loading="isUploading"
:disabled="selectedFiles.length === 0"
>
Upload {{ selectedFiles.length }} file{{ selectedFiles.length === 1 ? '' : 's' }}
</BaseButton>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
cancel: []
uploaded: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const fileInput = ref<HTMLInputElement>()
const selectedFiles = ref<File[]>([])
const uploadProgress = ref<number[]>([])
const isDragging = ref(false)
const isUploading = ref(false)
const error = ref('')
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
const handleDrop = (event: DragEvent) => {
isDragging.value = false
const files = Array.from(event.dataTransfer?.files || [])
addFiles(files)
}
const handleFileSelect = (event: Event) => {
const files = Array.from((event.target as HTMLInputElement).files || [])
addFiles(files)
}
const addFiles = (files: File[]) => {
selectedFiles.value.push(...files)
uploadProgress.value = new Array(selectedFiles.value.length).fill(0)
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
uploadProgress.value.splice(index, 1)
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const uploadFiles = async () => {
if (!appStore.currentChannelId || selectedFiles.value.length === 0) return
isUploading.value = true
error.value = ''
try {
// For single file, use the filename as message content
// For multiple files, show count
const messageContent = selectedFiles.value.length === 1
? selectedFiles.value[0].name
: `Uploaded ${selectedFiles.value.length} files`
// Create a message first to attach files to
const message = await apiService.createMessage(appStore.currentChannelId, messageContent)
// Upload the first file (backend uses single file per message)
const file = selectedFiles.value[0]
try {
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file)
uploadProgress.value[0] = 100
// Immediately update the local message with file metadata
const updatedMessage = {
...message,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
// Update the message in the store
appStore.updateMessage(message.id, updatedMessage)
toastStore.success('File uploaded successfully!')
} catch (fileError) {
console.error(`Failed to upload ${file.name}:`, fileError)
toastStore.error(`Failed to upload ${file.name}`)
uploadProgress.value[0] = 0
}
emit('uploaded')
} catch (err) {
console.error('Upload failed:', err)
error.value = 'Upload failed. Please try again.'
} finally {
isUploading.value = false
}
}
</script>
<style scoped>
.file-upload-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upload-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.upload-area:hover,
.upload-area--dragging {
border-color: #646cff;
background: #f8faff;
}
.file-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-prompt {
pointer-events: none;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.upload-hint {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.selected-files h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: #f9fafb;
border-radius: 6px;
}
.file-name {
flex: 1;
font-weight: 500;
word-break: break-all;
}
.file-size {
font-size: 0.875rem;
color: #6b7280;
}
.remove-file {
background: #ef4444;
color: white;
border: none;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.remove-file:hover:not(:disabled) {
background: #dc2626;
}
.upload-progress {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.progress-item {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-label {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.progress-bar {
flex: 2;
height: 0.5rem;
background: #e5e7eb;
border-radius: 0.25rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #646cff;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
min-width: 3rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.error-message {
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #dc2626;
font-size: 0.875rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.upload-area {
border-color: #4b5563;
}
.upload-area:hover,
.upload-area--dragging {
border-color: #646cff;
background: #1e293b;
}
.file-item {
background: #374151;
}
.progress-bar {
background: #4b5563;
}
.error-message {
background: #422006;
border-color: #92400e;
color: #fbbf24;
}
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<div class="search-dialog">
<div class="search-form">
<BaseInput
v-model="searchQuery"
placeholder="Search messages..."
@keydown.enter="performSearch"
ref="searchInput"
/>
<div class="search-filters">
<select
v-model="selectedChannelId"
class="channel-filter"
>
<option :value="null">All channels</option>
<option
v-for="channel in appStore.channels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}
</option>
</select>
<BaseButton
@click="performSearch"
:loading="isSearching"
:disabled="!searchQuery.trim()"
>
Search
</BaseButton>
</div>
</div>
<div v-if="isSearching" class="search-loading">
Searching...
</div>
<div v-else-if="searchResults.length > 0" class="search-results">
<div class="results-header">
Found {{ searchResults.length }} result{{ searchResults.length === 1 ? '' : 's' }}
</div>
<div class="results-list">
<div
v-for="result in searchResults"
:key="`${result.channel_id}-${result.id}`"
class="result-item"
@click="goToMessage(result)"
tabindex="0"
@keydown.enter="goToMessage(result)"
>
<div class="result-channel">
{{ getChannelName(result.channel_id) }}
</div>
<div class="result-content">
{{ result.content }}
</div>
<div class="result-time">
{{ formatTime(result.created_at) }}
</div>
</div>
</div>
</div>
<div v-else-if="hasSearched && searchResults.length === 0" class="no-results">
No messages found for "{{ searchQuery }}"
</div>
<div v-if="error" class="search-error">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import type { Message, ExtendedMessage } from '@/types'
const emit = defineEmits<{
close: []
'select-message': [message: ExtendedMessage]
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const searchQuery = ref('')
const selectedChannelId = ref<number | null>(null)
const searchResults = ref<ExtendedMessage[]>([])
const isSearching = ref(false)
const hasSearched = ref(false)
const error = ref('')
const searchInput = ref()
const performSearch = async () => {
if (!searchQuery.value.trim()) return
isSearching.value = true
error.value = ''
try {
const response = await apiService.search(
searchQuery.value.trim(),
selectedChannelId.value || undefined
)
// Transform search results to match expected format
searchResults.value = response.results.map((result: any) => ({
...result,
channel_id: result.channelId || result.channel_id,
created_at: result.createdAt || result.created_at
})) as ExtendedMessage[]
console.log('Search results transformed:', searchResults.value)
hasSearched.value = true
} catch (err) {
console.error('Search failed:', err)
error.value = 'Search failed. Please try again.'
toastStore.error('Search failed')
} finally {
isSearching.value = false
}
}
const goToMessage = (message: ExtendedMessage) => {
emit('select-message', message)
emit('close')
}
const getChannelName = (channelId: number): string => {
if (!channelId) return 'Unknown Channel'
const channel = appStore.channels.find(c => c.id === channelId)
return channel?.name || `Channel ${channelId}`
}
const formatTime = (timestamp: string): string => {
if (!timestamp) return 'Unknown time'
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return date.toLocaleString()
}
onMounted(() => {
searchInput.value?.focus()
})
</script>
<style scoped>
.search-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 400px;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-filters {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.channel-filter {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
min-width: 150px;
}
.channel-filter:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.search-loading {
display: flex;
justify-content: center;
padding: 2rem;
color: #6b7280;
}
.search-results {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.results-header {
font-weight: 600;
color: #374151;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
.result-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.result-item:hover,
.result-item:focus {
background: #f9fafb;
border-color: #646cff;
outline: none;
}
.result-channel {
font-size: 0.75rem;
font-weight: 600;
color: #646cff;
margin-bottom: 0.25rem;
}
.result-content {
color: #111827;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.result-time {
font-size: 0.75rem;
color: #6b7280;
}
.no-results {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
color: #6b7280;
font-style: italic;
}
.search-error {
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #dc2626;
font-size: 0.875rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.channel-filter {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.results-header {
color: rgba(255, 255, 255, 0.87);
border-bottom-color: #374151;
}
.result-item {
border-color: #374151;
}
.result-item:hover,
.result-item:focus {
background: #374151;
}
.result-content {
color: rgba(255, 255, 255, 0.87);
}
.search-error {
background: #422006;
border-color: #92400e;
color: #fbbf24;
}
}
</style>

View File

@@ -0,0 +1,364 @@
<template>
<div class="settings-dialog">
<form @submit.prevent="handleSave" class="settings-form">
<div class="setting-group">
<h3>Audio Settings</h3>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.soundEnabled"
class="checkbox"
/>
<span>Enable sound effects</span>
</label>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.speechEnabled"
class="checkbox"
/>
<span>Enable speech synthesis (deprecated)</span>
</label>
</div>
<div class="setting-group">
<h3>Text-to-Speech</h3>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.ttsEnabled"
class="checkbox"
/>
<span>Enable text-to-speech announcements</span>
</label>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="voice-select">Voice</label>
<select
id="voice-select"
v-model="selectedVoiceURI"
class="select"
@change="handleVoiceChange"
>
<option value="" disabled>Select a voice...</option>
<option
v-for="voice in availableVoices"
:key="voice.voiceURI"
:value="voice.voiceURI"
>
{{ voice.name }} ({{ voice.lang }})
</option>
</select>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="rate-slider">Speech Rate: {{ localSettings.ttsRate.toFixed(1) }}</label>
<input
id="rate-slider"
type="range"
min="0.5"
max="2"
step="0.1"
v-model.number="localSettings.ttsRate"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="pitch-slider">Speech Pitch: {{ localSettings.ttsPitch.toFixed(1) }}</label>
<input
id="pitch-slider"
type="range"
min="0"
max="2"
step="0.1"
v-model.number="localSettings.ttsPitch"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="volume-slider">Speech Volume: {{ localSettings.ttsVolume.toFixed(1) }}</label>
<input
id="volume-slider"
type="range"
min="0"
max="1"
step="0.1"
v-model.number="localSettings.ttsVolume"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<BaseButton
type="button"
variant="secondary"
size="sm"
@click="testSpeech"
:disabled="!selectedVoiceURI"
>
Test Speech
</BaseButton>
</div>
</div>
<div class="setting-group">
<h3>Appearance</h3>
<div class="setting-item">
<label for="theme-select">Theme</label>
<select
id="theme-select"
v-model="localSettings.theme"
class="select"
>
<option value="auto">Auto (System)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div class="setting-group" v-if="appStore.channels.length > 0">
<h3>Default Channel</h3>
<div class="setting-item">
<label for="default-channel-select">Default Channel</label>
<select
id="default-channel-select"
v-model="localSettings.defaultChannelId"
class="select"
>
<option :value="null">None</option>
<option
v-for="channel in appStore.channels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}
</option>
</select>
</div>
</div>
<div class="form-actions">
<BaseButton
type="button"
variant="secondary"
@click="$emit('close')"
>
Cancel
</BaseButton>
<BaseButton
type="submit"
:loading="isSaving"
>
Save Settings
</BaseButton>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import BaseButton from '@/components/base/BaseButton.vue'
import type { AppSettings } from '@/types'
const emit = defineEmits<{
close: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const { availableVoices, speak, setVoice } = useAudio()
const isSaving = ref(false)
const selectedVoiceURI = ref('')
const localSettings = reactive<AppSettings>({
soundEnabled: true,
speechEnabled: true,
ttsEnabled: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
selectedVoiceURI: null,
defaultChannelId: null,
theme: 'auto'
})
const handleVoiceChange = () => {
const voice = availableVoices.value.find(v => v.voiceURI === selectedVoiceURI.value)
if (voice) {
setVoice(voice)
localSettings.selectedVoiceURI = voice.voiceURI
}
}
const testSpeech = async () => {
try {
await speak('This is a test of the text-to-speech system.', {
rate: localSettings.ttsRate,
pitch: localSettings.ttsPitch,
volume: localSettings.ttsVolume
})
} catch (error) {
toastStore.error('Speech test failed')
}
}
const handleSave = async () => {
isSaving.value = true
try {
await appStore.updateSettings(localSettings)
toastStore.success('Settings saved successfully!')
emit('close')
} catch (error) {
console.error('Failed to save settings:', error)
toastStore.error('Failed to save settings')
} finally {
isSaving.value = false
}
}
onMounted(() => {
// Copy current settings to local state
Object.assign(localSettings, appStore.settings)
// Set up voice selection
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
})
</script>
<style scoped>
.settings-dialog {
padding: 1rem 0;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-group h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.setting-item label {
font-weight: 500;
color: #374151;
}
.checkbox {
width: 1.25rem;
height: 1.25rem;
accent-color: #646cff;
cursor: pointer;
}
.select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
min-width: 150px;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.slider {
width: 100%;
max-width: 200px;
height: 4px;
border-radius: 2px;
background: #e5e7eb;
outline: none;
cursor: pointer;
appearance: none;
}
.slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #646cff;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #646cff;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.setting-group h3 {
color: rgba(255, 255, 255, 0.87);
border-bottom-color: #374151;
}
.setting-item label {
color: rgba(255, 255, 255, 0.87);
}
.select {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.form-actions {
border-top-color: #374151;
}
}
</style>

View File

@@ -0,0 +1,479 @@
<template>
<div class="voice-recording-dialog">
<div class="recording-container">
<!-- Recording Status -->
<div class="recording-status">
<div class="status-indicator" :class="{ 'recording': recording.isRecording, 'has-recording': recording.blob }">
<div class="pulse" v-if="recording.isRecording"></div>
<Icon name="microphone" />
</div>
<div class="status-text">
<h3 v-if="recording.isRecording">Recording...</h3>
<h3 v-else-if="recording.blob">Recording Complete</h3>
<h3 v-else>Ready to Record</h3>
<p class="duration">{{ recordingDurationFormatted }}</p>
</div>
</div>
<!-- Waveform Visualization (placeholder) -->
<div class="waveform" v-if="recording.isRecording">
<div class="wave-bar" v-for="i in 20" :key="i" :style="{ height: getWaveHeight(i) + 'px' }"></div>
</div>
<!-- Playback Controls -->
<div class="playback-controls" v-if="recording.blob">
<div class="progress-bar">
<div class="progress" :style="{ width: playbackProgress + '%' }"></div>
</div>
<div class="playback-time">
{{ formatTime(recording.currentTime) }} / {{ formatTime(recording.duration) }}
</div>
</div>
<!-- Control Buttons -->
<div class="controls">
<BaseButton
v-if="!recording.isRecording && !recording.blob"
@click="startRecording"
variant="primary"
size="lg"
:disabled="!canRecord"
class="record-btn"
>
<Icon name="microphone" />
Start Recording
</BaseButton>
<BaseButton
v-if="recording.isRecording"
@click="stopRecording"
variant="danger"
size="lg"
class="stop-btn"
>
<Icon name="stop" />
Stop Recording
</BaseButton>
<div class="playback-buttons" v-if="recording.blob && !recording.isRecording">
<BaseButton
@click="playRecording"
variant="secondary"
:disabled="recording.isPlaying"
>
<Icon name="play" />
Play
</BaseButton>
<BaseButton
@click="clearRecording"
variant="secondary"
>
<Icon name="trash" />
Clear
</BaseButton>
<BaseButton
@click="startRecording"
variant="secondary"
>
<Icon name="microphone" />
Re-record
</BaseButton>
</div>
</div>
<!-- Error Message -->
<div class="error-message" v-if="errorMessage">
<Icon name="warning" />
{{ errorMessage }}
</div>
<!-- Microphone Permission Info -->
<div class="permission-info" v-if="!canRecord">
<Icon name="info" />
<p>Microphone access is required for voice recording. Please grant permission when prompted.</p>
</div>
</div>
<!-- Dialog Actions -->
<div class="dialog-actions">
<BaseButton
@click="$emit('close')"
variant="secondary"
>
Cancel
</BaseButton>
<BaseButton
@click="sendVoiceMessage"
variant="primary"
:disabled="!recording.blob || isSending"
:loading="isSending"
>
<Icon name="send" />
Send Voice Message
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useAudio } from '@/composables/useAudio'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/base/Icon.vue'
const emit = defineEmits<{
close: []
sent: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const {
recording,
canRecord,
recordingDurationFormatted,
startRecording: startAudioRecording,
stopRecording: stopAudioRecording,
playRecording,
clearRecording
} = useAudio()
const isSending = ref(false)
const errorMessage = ref('')
const waveAnimation = ref<number[]>([])
// Computed
const playbackProgress = computed(() => {
if (!recording.value.duration) return 0
return (recording.value.currentTime / recording.value.duration) * 100
})
// Methods
const startRecording = async () => {
errorMessage.value = ''
const success = await startAudioRecording()
if (!success) {
errorMessage.value = 'Failed to start recording. Please check microphone permissions.'
} else {
startWaveAnimation()
}
}
const stopRecording = () => {
stopAudioRecording()
stopWaveAnimation()
}
const sendVoiceMessage = async () => {
if (!recording.value.blob) return
isSending.value = true
errorMessage.value = ''
try {
// Create a message first to attach the voice file to
const message = await apiService.createMessage(appStore.currentChannelId!, 'Voice message')
// Create file from blob
const file = new File([recording.value.blob!], `voice-${Date.now()}.webm`, {
type: 'audio/webm;codecs=opus'
})
// Upload voice file
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
// Immediately update the local message with file metadata
const updatedMessage = {
...message,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
// Update the message in the store
appStore.updateMessage(message.id, updatedMessage)
toastStore.success('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>

View File

@@ -0,0 +1,78 @@
<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)"
@info="$emit('channel-info', $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]
'channel-info': [channel: Channel]
}>()
</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>

View File

@@ -0,0 +1,191 @@
<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]
info: [channel: Channel]
}>()
</script>
<style scoped>
.channel-item {
list-style: none;
margin: 0;
}
.channel-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.channel-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
text-align: left;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
margin: 0 0.5rem 0.25rem 0.5rem;
}
.channel-button:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.channel-button:focus {
outline: none;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.channel-item--active .channel-button {
background: #3b82f6;
color: white;
}
.channel-item--active .channel-button:hover {
background: #2563eb;
}
.channel-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-unread {
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.channel-item--active .channel-unread {
background: rgba(255, 255, 255, 0.9);
color: #3b82f6;
}
.channel-info-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
opacity: 0.6;
transition: all 0.2s ease;
flex-shrink: 0;
}
.channel-info-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
}
.channel-info-button:focus {
outline: none;
background: rgba(59, 130, 246, 0.1);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
opacity: 1;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.channel-button {
color: rgba(255, 255, 255, 0.6);
}
.channel-button:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.87);
}
.channel-button:focus {
background: rgba(96, 165, 250, 0.1);
color: #60a5fa;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.channel-item--active .channel-button {
background: #3b82f6;
color: white;
}
.channel-item--active .channel-button:hover {
background: #2563eb;
}
.channel-info-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.channel-info-button:focus {
background: rgba(96, 165, 250, 0.1);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<aside class="sidebar">
<div class="sidebar__header">
<h1 class="sidebar__title">Notebrook</h1>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('create-channel')"
aria-label="Create new channel"
>
+
</BaseButton>
</div>
<div class="sidebar__content">
<ChannelList
:channels="channels"
:current-channel-id="currentChannelId"
:unread-counts="unreadCounts"
@select-channel="$emit('select-channel', $event)"
@channel-info="$emit('channel-info', $event)"
/>
</div>
<div class="sidebar__footer">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('settings')"
aria-label="Open settings"
>
</BaseButton>
</div>
</aside>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import ChannelList from './ChannelList.vue'
import type { Channel } from '@/types'
interface Props {
channels: Channel[]
currentChannelId: number | null
unreadCounts: Record<number, number>
}
defineProps<Props>()
defineEmits<{
'create-channel': []
'select-channel': [channelId: number]
'channel-info': [channel: Channel]
'settings': []
}>()
</script>
<style scoped>
.sidebar {
width: 300px;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
}
.sidebar__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #111827;
}
.sidebar__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar__footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: center;
flex-shrink: 0;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.sidebar {
background: #1f2937;
border-right-color: #374151;
}
.sidebar__header {
background: #1f2937;
border-bottom-color: #374151;
}
.sidebar__title {
color: rgba(255, 255, 255, 0.87);
}
.sidebar__footer {
background: #1f2937;
border-top-color: #374151;
}
}
/* Responsive design */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.sidebar__header {
padding: 1rem;
}
.sidebar__title {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,467 @@
import { ref, computed, readonly } from 'vue'
import { useAppStore } from '@/stores/app'
interface AudioRecording {
blob: Blob | null
duration: number
isRecording: boolean
isPlaying: boolean
currentTime: number
}
// Global audio state to ensure singleton behavior
let audioSystemInitialized = false
let soundsLoaded = false
let globalAudioContext: AudioContext | null = null
let globalSoundBuffers = new Map<string, AudioBuffer>()
let globalWaterSounds: AudioBuffer[] = []
let globalSentSounds: AudioBuffer[] = []
export function useAudio() {
const appStore = useAppStore()
// Audio Context (use global instance)
const audioContext = ref<AudioContext | null>(globalAudioContext)
// Sound buffers (use global arrays)
const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
// Recording state
const recording = ref<AudioRecording>({
blob: null,
duration: 0,
isRecording: false,
isPlaying: false,
currentTime: 0
})
// Media recorder
let mediaRecorder: MediaRecorder | null = null
let recordingChunks: Blob[] = []
let recordingStartTime: number = 0
let recordingInterval: number | null = null
// Text-to-speech state
const isSpeaking = ref(false)
const availableVoices = ref<SpeechSynthesisVoice[]>([])
const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
// Initialize audio context
const initAudioContext = async () => {
if (!globalAudioContext) {
globalAudioContext = new AudioContext()
audioContext.value = globalAudioContext
}
if (globalAudioContext.state === 'suspended') {
try {
await globalAudioContext.resume()
} catch (error) {
console.warn('AudioContext resume failed, user interaction required:', error)
}
}
}
// Load a single sound file
const loadSound = async (url: string): Promise<AudioBuffer | null> => {
try {
if (!audioContext.value) {
await initAudioContext()
}
if (!audioContext.value) {
// AudioContext creation failed (probably no user interaction yet)
return null
}
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
return await audioContext.value.decodeAudioData(arrayBuffer)
} catch (error) {
console.warn(`Failed to load sound ${url}:`, error)
return null
}
}
// Load all sound files
const loadAllSounds = async () => {
if (soundsLoaded) {
console.log('Sounds already loaded, skipping...')
return
}
try {
console.log('Starting to load all sounds...')
soundsLoaded = true
// Load basic sounds
const basicSounds = {
intro: '/sounds/intro.wav',
login: '/sounds/login.wav',
copy: '/sounds/copy.wav',
uploadFailed: '/sounds/uploadfail.wav'
}
for (const [name, url] of Object.entries(basicSounds)) {
const buffer = await loadSound(url)
if (buffer) {
globalSoundBuffers.set(name, buffer)
soundBuffers.value.set(name, buffer)
}
}
// Load water sounds (1-10)
console.log('Loading water sounds...')
for (let i = 1; i <= 10; i++) {
const buffer = await loadSound(`/sounds/water${i}.wav`)
if (buffer) {
globalWaterSounds.push(buffer)
waterSounds.value.push(buffer)
console.log(`Loaded water sound ${i}`)
} else {
console.warn(`Failed to load water sound ${i}`)
}
}
console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
// Load sent sounds (1-6)
for (let i = 1; i <= 6; i++) {
const buffer = await loadSound(`/sounds/sent${i}.wav`)
if (buffer) {
globalSentSounds.push(buffer)
sentSounds.value.push(buffer)
}
}
console.log('All sounds loaded and ready to play')
} catch (error) {
console.error('Error loading sounds:', error)
}
}
// Play a sound buffer
const playSoundBuffer = async (buffer: AudioBuffer) => {
if (!appStore.settings.soundEnabled) return
try {
await initAudioContext()
if (!globalAudioContext) {
console.error('AudioContext not initialized')
return
}
const source = globalAudioContext.createBufferSource()
source.buffer = buffer
source.connect(globalAudioContext.destination)
source.start(0)
} catch (error) {
console.error('Error playing sound:', error)
}
}
// Play specific sounds
const playSound = async (name: string) => {
const buffer = globalSoundBuffers.get(name)
if (buffer) {
await playSoundBuffer(buffer)
} else {
console.warn(`Sound ${name} not loaded`)
}
}
const playWater = async () => {
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex])
} else {
console.warn('Water sounds not loaded - trying to load them now')
if (globalAudioContext) {
await loadAllSounds()
if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex])
}
}
}
}
const playSent = async () => {
if (globalSentSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
await playSoundBuffer(globalSentSounds[randomIndex])
} else {
console.warn('Sent sounds not loaded')
}
}
// Voice recording
const startRecording = async (): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: true
}
})
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
recordingChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordingChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
recording.value.blob = blob
recording.value.isRecording = false
if (recordingInterval) {
clearInterval(recordingInterval)
recordingInterval = null
}
// Stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start()
recording.value.isRecording = true
recording.value.duration = 0
recordingStartTime = Date.now()
// Update duration every 100ms
recordingInterval = setInterval(() => {
recording.value.duration = (Date.now() - recordingStartTime) / 1000
}, 100)
return true
} catch (error) {
console.error('Failed to start recording:', error)
recording.value.isRecording = false
return false
}
}
const stopRecording = () => {
if (mediaRecorder && recording.value.isRecording) {
mediaRecorder.stop()
}
}
const playRecording = async () => {
if (!recording.value.blob) return false
try {
const audio = new Audio(URL.createObjectURL(recording.value.blob))
recording.value.isPlaying = true
recording.value.currentTime = 0
audio.ontimeupdate = () => {
recording.value.currentTime = audio.currentTime
}
audio.onended = () => {
recording.value.isPlaying = false
recording.value.currentTime = 0
URL.revokeObjectURL(audio.src)
}
await audio.play()
return true
} catch (error) {
console.error('Failed to play recording:', error)
recording.value.isPlaying = false
return false
}
}
const clearRecording = () => {
if (recording.value.blob) {
URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
}
recording.value.blob = null
recording.value.duration = 0
recording.value.isPlaying = false
recording.value.currentTime = 0
}
// Text-to-speech functions
const loadVoices = () => {
const voices = speechSynthesis.getVoices()
availableVoices.value = voices
// Select default voice (prefer English voices)
if (!selectedVoice.value && voices.length > 0) {
const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
selectedVoice.value = englishVoice || voices[0]
}
}
const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
if (!appStore.settings.ttsEnabled) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
if ('speechSynthesis' in window) {
// Stop any current speech
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
// Set voice if available
if (selectedVoice.value) {
utterance.voice = selectedVoice.value
}
// Apply options
utterance.rate = options.rate || appStore.settings.ttsRate || 1
utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
utterance.volume = options.volume || appStore.settings.ttsVolume || 1
utterance.onstart = () => {
isSpeaking.value = true
}
utterance.onend = () => {
isSpeaking.value = false
resolve()
}
utterance.onerror = (event) => {
isSpeaking.value = false
console.error('Speech synthesis error:', event.error)
reject(new Error(`Speech synthesis failed: ${event.error}`))
}
speechSynthesis.speak(utterance)
} else {
reject(new Error('Speech synthesis not supported'))
}
})
}
const stopSpeaking = () => {
if ('speechSynthesis' in window) {
speechSynthesis.cancel()
isSpeaking.value = false
}
}
const setVoice = (voice: SpeechSynthesisVoice) => {
selectedVoice.value = voice
appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
}
// Announce message for accessibility
const announceMessage = async (content: string, channel?: string) => {
if (!appStore.settings.ttsEnabled) return
let textToSpeak = content
if (channel) {
textToSpeak = `New message in ${channel}: ${content}`
}
try {
await speak(textToSpeak)
} catch (error) {
console.error('Failed to announce message:', error)
}
}
// Computed
const canRecord = computed(() => {
return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
})
const recordingDurationFormatted = computed(() => {
const duration = recording.value.duration
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
// Initialize audio on first user interaction
const initAudioOnUserGesture = async () => {
if (!audioContext.value || audioContext.value.state === 'suspended') {
await initAudioContext()
}
}
// Initialize audio system (only once)
const initializeAudioSystem = () => {
if (!audioSystemInitialized) {
audioSystemInitialized = true
// Set up user gesture listeners to initialize audio and load sounds
const initializeAudio = async () => {
console.log('User interaction detected, initializing audio system...')
await initAudioOnUserGesture()
await loadAllSounds() // Load sounds after user interaction
console.log('Audio system initialized')
document.removeEventListener('click', initializeAudio)
document.removeEventListener('keydown', initializeAudio)
}
document.addEventListener('click', initializeAudio, { once: true })
document.addEventListener('keydown', initializeAudio, { once: true })
// Initialize voices for speech synthesis
if ('speechSynthesis' in window) {
loadVoices()
// Voices may not be immediately available
speechSynthesis.addEventListener('voiceschanged', loadVoices)
// Restore selected voice from settings
if (appStore.settings.selectedVoiceURI) {
const voices = speechSynthesis.getVoices()
const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
if (savedVoice) {
selectedVoice.value = savedVoice
}
}
}
}
}
// Initialize audio system when composable is first used
initializeAudioSystem()
return {
// State
recording,
canRecord,
recordingDurationFormatted,
isSpeaking: readonly(isSpeaking),
availableVoices: readonly(availableVoices),
selectedVoice: readonly(selectedVoice),
// Audio playback
playSound,
playWater,
playSent,
// Voice recording
startRecording,
stopRecording,
playRecording,
clearRecording,
// Text-to-speech
speak,
stopSpeaking,
setVoice,
announceMessage,
// Audio context
initAudioContext
}
}

View File

@@ -0,0 +1,93 @@
import { onMounted, onUnmounted, ref, readonly } from 'vue'
interface ShortcutConfig {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
handler: () => void
preventDefault?: boolean
}
export function useKeyboardShortcuts() {
const shortcuts = ref<Map<string, ShortcutConfig>>(new Map())
const isListening = ref(false)
const getShortcutKey = (config: ShortcutConfig): string => {
const parts = []
if (config.ctrlKey) parts.push('ctrl')
if (config.shiftKey) parts.push('shift')
if (config.altKey) parts.push('alt')
if (config.metaKey) parts.push('meta')
parts.push(config.key.toLowerCase())
return parts.join('+')
}
const handleKeydown = (event: KeyboardEvent) => {
// Skip shortcuts when focused on input/textarea elements
const target = event.target as HTMLElement
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
return
}
const config: ShortcutConfig = {
key: event.key.toLowerCase(),
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
handler: () => {}
}
const shortcutKey = getShortcutKey(config)
const shortcut = shortcuts.value.get(shortcutKey)
if (shortcut) {
if (shortcut.preventDefault !== false) {
event.preventDefault()
}
shortcut.handler()
}
}
const addShortcut = (config: ShortcutConfig) => {
const key = getShortcutKey(config)
shortcuts.value.set(key, config)
}
const removeShortcut = (config: Omit<ShortcutConfig, 'handler'>) => {
const key = getShortcutKey(config as ShortcutConfig)
shortcuts.value.delete(key)
}
const startListening = () => {
if (!isListening.value) {
document.addEventListener('keydown', handleKeydown)
isListening.value = true
}
}
const stopListening = () => {
if (isListening.value) {
document.removeEventListener('keydown', handleKeydown)
isListening.value = false
}
}
onMounted(() => {
startListening()
})
onUnmounted(() => {
stopListening()
})
return {
addShortcut,
removeShortcut,
startListening,
stopListening,
isListening
}
}

View File

@@ -0,0 +1,178 @@
import { ref, onMounted, onUnmounted, readonly } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import type { UnsentMessage } from '@/types'
export function useOfflineSync() {
const appStore = useAppStore()
const toastStore = useToastStore()
const isOnline = ref(navigator.onLine)
const isSyncing = ref(false)
let syncInterval: number | null = null
// Monitor online status
const updateOnlineStatus = () => {
const wasOnline = isOnline.value
isOnline.value = navigator.onLine
if (!wasOnline && isOnline.value) {
toastStore.success('Back online - syncing data...')
syncUnsentMessages()
} else if (wasOnline && !isOnline.value) {
toastStore.info('You are offline - messages will be queued')
}
}
// Sync unsent messages when online
const syncUnsentMessages = async () => {
if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
return
}
isSyncing.value = true
const failedMessages: UnsentMessage[] = []
for (const unsentMessage of appStore.unsentMessages) {
try {
const response = await apiService.createMessage(
unsentMessage.channelId,
unsentMessage.content
)
// Message sent successfully - remove from unsent queue
appStore.removeUnsentMessage(unsentMessage.id)
// Add to messages (will be handled by WebSocket event too)
appStore.addMessage(response)
} catch (error) {
console.error('Failed to sync message:', error)
// Increment retry count (create mutable copy)
const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
// If too many retries, give up
if (mutableMessage.retries >= 3) {
toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
appStore.removeUnsentMessage(unsentMessage.id)
} else {
failedMessages.push(mutableMessage)
}
}
}
// Update unsent messages with failed ones
if (failedMessages.length > 0) {
toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
} else if (appStore.unsentMessages.length > 0) {
toastStore.success('All offline messages synced!')
}
isSyncing.value = false
await appStore.saveState()
}
// Queue message for sending when offline
const queueMessage = async (channelId: number, content: string): Promise<string> => {
const unsentMessage: UnsentMessage = {
id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
channelId,
content,
timestamp: Date.now(),
retries: 0
}
appStore.addUnsentMessage(unsentMessage)
await appStore.saveState()
// Try to send immediately if online
if (isOnline.value) {
syncUnsentMessages()
}
return unsentMessage.id
}
// Send message (online or queue for offline)
const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
if (isOnline.value) {
try {
const response = await apiService.createMessage(channelId, content)
appStore.addMessage(response)
return true
} catch (error) {
console.error('Failed to send message online:', error)
// Fall back to queuing
await queueMessage(channelId, content)
toastStore.error('Failed to send message - queued for later')
return false
}
} else {
await queueMessage(channelId, content)
toastStore.info('Message queued for sending when online')
return false
}
}
// Auto-save state periodically
const startAutoSave = () => {
if (syncInterval) clearInterval(syncInterval)
syncInterval = setInterval(async () => {
try {
await appStore.saveState()
// Try to sync unsent messages if online
if (isOnline.value && appStore.unsentMessages.length > 0) {
syncUnsentMessages()
}
} catch (error) {
console.error('Auto-save failed:', error)
}
}, 10000) // Save every 10 seconds
}
const stopAutoSave = () => {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
// Handle beforeunload to save state
const handleBeforeUnload = () => {
appStore.saveState()
}
onMounted(() => {
// Add event listeners
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
window.addEventListener('beforeunload', handleBeforeUnload)
// Start auto-save
startAutoSave()
// Initial sync if online and has unsent messages
if (isOnline.value && appStore.unsentMessages.length > 0) {
syncUnsentMessages()
}
})
onUnmounted(() => {
// Clean up
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
window.removeEventListener('beforeunload', handleBeforeUnload)
stopAutoSave()
})
return {
isOnline,
isSyncing,
sendMessage,
syncUnsentMessages,
queueMessage
}
}

View File

@@ -0,0 +1,174 @@
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,
// Handle flattened file fields
fileId: data.fileId,
filePath: data.filePath,
fileType: data.fileType,
fileSize: data.fileSize,
originalName: data.originalName,
fileCreatedAt: data.fileCreatedAt
}
console.log('WebSocket: Transformed message:', message)
console.log('Transformed content:', JSON.stringify(message.content))
appStore.addMessage(message)
// Announce new message for accessibility
const channel = appStore.channels.find(c => c.id === message.channel_id)
if (channel && appStore.settings.ttsEnabled) {
announceMessage(message.content, channel.name)
}
}
const handleMessageUpdated = (data: any) => {
// Handle full message updates including file metadata
const messageUpdate: Partial<ExtendedMessage> = {
content: data.content
}
// Handle flattened file fields from server
if (data.fileId) {
messageUpdate.fileId = data.fileId
messageUpdate.filePath = data.filePath
messageUpdate.fileType = data.fileType
messageUpdate.fileSize = data.fileSize
messageUpdate.originalName = data.originalName
messageUpdate.fileCreatedAt = data.fileCreatedAt
}
appStore.updateMessage(parseInt(data.id), messageUpdate)
}
const handleMessageDeleted = (data: { id: string }) => {
appStore.removeMessage(parseInt(data.id))
}
const handleFileUploaded = (data: any) => {
// Handle file upload events with flattened format
const messageUpdate: Partial<ExtendedMessage> = {
fileId: data.fileId,
filePath: data.filePath,
fileType: data.fileType,
fileSize: data.fileSize,
originalName: data.originalName,
fileCreatedAt: data.fileCreatedAt
}
appStore.updateMessage(data.message_id, messageUpdate)
}
const handleChannelCreated = (data: { channel: Channel }) => {
appStore.addChannel(data.channel)
toastStore.success(`Channel "${data.channel.name}" created`)
}
const handleChannelDeleted = (data: { id: string }) => {
const channelId = parseInt(data.id)
const channel = appStore.channels.find(c => c.id === channelId)
appStore.removeChannel(channelId)
if (channel) {
toastStore.info(`Channel "${channel.name}" was deleted`)
}
}
const handleChannelMerged = (data: { channelId: string, targetChannelId: string }) => {
const sourceChannelId = parseInt(data.channelId)
const targetChannelId = parseInt(data.targetChannelId)
const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
if (sourceChannel && targetChannel) {
// Merge messages from source to target
const sourceMessages = [...(appStore.messages[sourceChannelId] || [])]
const targetMessages = [...(appStore.messages[targetChannelId] || [])]
appStore.setMessages(targetChannelId, [...targetMessages, ...sourceMessages])
// Remove source channel
appStore.removeChannel(sourceChannelId)
toastStore.success(`Channel "${sourceChannel.name}" merged into "${targetChannel.name}"`)
}
}
const handleChannelUpdated = (data: { id: string, name: string }) => {
// Update channel in store (if we implement channel renaming)
const channelId = parseInt(data.id)
const channels = [...appStore.channels]
const channelIndex = channels.findIndex(c => c.id === channelId)
if (channelIndex !== -1) {
channels[channelIndex] = { ...channels[channelIndex], name: data.name }
appStore.setChannels(channels)
}
}
const setupEventHandlers = () => {
websocketService.on('message-created', handleMessageCreated)
websocketService.on('message-updated', handleMessageUpdated)
websocketService.on('message-deleted', handleMessageDeleted)
websocketService.on('file-uploaded', handleFileUploaded)
websocketService.on('channel-created', handleChannelCreated)
websocketService.on('channel-deleted', handleChannelDeleted)
websocketService.on('channel-merged', handleChannelMerged)
websocketService.on('channel-updated', handleChannelUpdated)
websocketService.on('connected', () => {
console.log('WebSocket connected successfully')
toastStore.success('Connected to server')
})
websocketService.on('disconnected', () => {
toastStore.error('Disconnected from server')
})
websocketService.on('error', () => {
toastStore.error('WebSocket connection error')
})
}
const removeEventHandlers = () => {
websocketService.off('message-created', handleMessageCreated)
websocketService.off('message-updated', handleMessageUpdated)
websocketService.off('message-deleted', handleMessageDeleted)
websocketService.off('file-uploaded', handleFileUploaded)
websocketService.off('channel-created', handleChannelCreated)
websocketService.off('channel-deleted', handleChannelDeleted)
websocketService.off('channel-merged', handleChannelMerged)
websocketService.off('channel-updated', handleChannelUpdated)
}
onMounted(() => {
setupEventHandlers()
websocketService.connect()
})
onUnmounted(() => {
removeEventHandlers()
websocketService.disconnect()
})
return {
connect: () => websocketService.connect(),
disconnect: () => websocketService.disconnect(),
isConnected: () => websocketService.isConnected
}
}

48
frontend-vue/src/main.ts Normal file
View File

@@ -0,0 +1,48 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
import { apiService } from './services/api'
// Import routes
import { routes } from './router/index'
const app = createApp(App)
const pinia = createPinia()
const router = createRouter({
history: createWebHistory(),
routes
})
// Router guard to ensure API service has proper token
router.beforeEach(async (to, from, next) => {
const { useAuthStore } = await import('./stores/auth')
const authStore = useAuthStore()
// Check authentication first
await authStore.checkAuth()
// Check if going to protected route
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/auth')
return
}
// If authenticated but going to auth page, redirect to main
if (authStore.isAuthenticated && to.name === 'auth') {
next('/')
return
}
// Set token for API service if authenticated
if (authStore.isAuthenticated && authStore.token) {
apiService.setToken(authStore.token)
}
next()
})
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,17 @@
import type { RouteRecordRaw } from 'vue-router'
import MainView from '@/views/MainView.vue'
import AuthView from '@/views/AuthView.vue'
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'main',
component: MainView,
meta: { requiresAuth: true }
},
{
path: '/auth',
name: 'auth',
component: AuthView
}
]

View File

@@ -0,0 +1,153 @@
import type { Channel, Message, ExtendedMessage, FileAttachment } from '@/types'
class ApiService {
private baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
private token = ''
setToken(token: string) {
this.token = token
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
}
private getHeaders(): HeadersInit {
return {
'Authorization': this.token,
'Content-Type': 'application/json'
}
}
private getFormHeaders(): HeadersInit {
return {
'Authorization': this.token
}
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const headers = {
...this.getHeaders(),
...options.headers
}
console.log('Making API request to:', url, 'with headers:', headers)
const response = await fetch(url, {
...options,
headers
})
if (!response.ok) {
console.error('API request failed:', response.status, response.statusText)
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Authentication
async checkToken(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/check-token`, {
headers: { Authorization: this.token }
})
return response.ok
} catch {
return false
}
}
// Channels
async getChannels(): Promise<{ channels: Channel[] }> {
return this.request('/channels')
}
async createChannel(name: string): Promise<Channel> {
return this.request('/channels', {
method: 'POST',
body: JSON.stringify({ name })
})
}
async updateChannel(channelId: number, name: string): Promise<{ message: string }> {
return this.request(`/channels/${channelId}`, {
method: 'PUT',
body: JSON.stringify({ name })
})
}
async deleteChannel(channelId: number): Promise<{ message: string }> {
return this.request(`/channels/${channelId}`, {
method: 'DELETE'
})
}
async mergeChannels(sourceChannelId: number, targetChannelId: number): Promise<{ message: string }> {
return this.request(`/channels/${sourceChannelId}/merge`, {
method: 'PUT',
body: JSON.stringify({ targetChannelId: targetChannelId.toString() })
})
}
// Messages
async getMessages(channelId: number): Promise<{ messages: ExtendedMessage[] }> {
return this.request(`/channels/${channelId}/messages`)
}
async createMessage(channelId: number, content: string): Promise<Message> {
return this.request(`/channels/${channelId}/messages`, {
method: 'POST',
body: JSON.stringify({ content })
})
}
async updateMessage(channelId: number, messageId: number, content: string): Promise<{ id: string, content: string }> {
return this.request(`/channels/${channelId}/messages/${messageId}`, {
method: 'PUT',
body: JSON.stringify({ content })
})
}
async deleteMessage(channelId: number, messageId: number): Promise<{ message: string }> {
return this.request(`/channels/${channelId}/messages/${messageId}`, {
method: 'DELETE'
})
}
// Files
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${this.baseUrl}/channels/${channelId}/messages/${messageId}/files`, {
method: 'POST',
headers: this.getFormHeaders(),
body: formData
})
if (!response.ok) {
throw new Error(`File upload failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
async getFiles(channelId: number, messageId: number): Promise<{ files: FileAttachment[] }> {
return this.request(`/channels/${channelId}/messages/${messageId}/files`)
}
// Search
async search(query: string, channelId?: number): Promise<{ results: Message[] }> {
const params = new URLSearchParams({ query })
if (channelId) {
params.append('channelId', channelId.toString())
}
return this.request(`/search?${params.toString()}`)
}
// File URL helper
getFileUrl(filePath: string): string {
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
}
}
export const apiService = new ApiService()

View File

@@ -0,0 +1,206 @@
import { apiService } from './api'
import { useAppStore } from '@/stores/app'
import type { ExtendedMessage, UnsentMessage } from '@/types'
export class SyncService {
private getAppStore() {
return useAppStore()
}
/**
* Sync messages for a channel: merge server data with local data
*/
async syncChannelMessages(channelId: number): Promise<void> {
try {
console.log(`Syncing messages for channel ${channelId}`)
const appStore = this.getAppStore()
// Get server messages
const serverResponse = await apiService.getMessages(channelId)
const serverMessages = serverResponse.messages
// Get local messages
const localMessages = appStore.messages[channelId] || []
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
// Merge messages using a simple strategy:
// 1. Create a map of all messages by ID
// 2. Server messages take precedence (they may have been updated)
// 3. Keep local messages that don't exist on server (may be unsent)
const messageMap = new Map<number, ExtendedMessage>()
// Add local messages first
localMessages.forEach(msg => {
if (typeof msg.id === 'number') {
messageMap.set(msg.id, msg)
}
})
// Add/update with server messages (server wins for conflicts)
serverMessages.forEach((msg: any) => {
// Transform server message format to match our types
const transformedMsg: ExtendedMessage = {
id: msg.id,
channel_id: msg.channelId || msg.channel_id,
content: msg.content,
created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id,
// Map the flattened file fields from backend
fileId: msg.fileId,
filePath: msg.filePath,
fileType: msg.fileType,
fileSize: msg.fileSize,
originalName: msg.originalName,
fileCreatedAt: msg.fileCreatedAt
}
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
messageMap.set(msg.id, transformedMsg)
})
// Convert back to array, sorted by creation time
const mergedMessages = Array.from(messageMap.values())
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log(`Merged result: ${mergedMessages.length} messages`)
// Update local storage
appStore.setMessages(channelId, mergedMessages)
await appStore.saveState()
} catch (error) {
console.warn(`Failed to sync messages for channel ${channelId}:`, error)
throw error
}
}
/**
* Attempt to send all unsent messages
*/
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()

View File

@@ -0,0 +1,134 @@
import type { WebSocketEvent } from '@/types'
class WebSocketService {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectInterval = 1000
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
connect() {
if (this.ws?.readyState === WebSocket.OPEN) {
return
}
// In development, connect to backend server (port 3000)
// In production, use same host as frontend
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = isDev ? 'localhost:3000' : window.location.host
const wsUrl = `${protocol}//${host}`
try {
this.ws = new WebSocket(wsUrl)
this.setupEventListeners()
} catch (error) {
console.error('WebSocket connection failed:', error)
this.scheduleReconnect()
}
}
private setupEventListeners() {
if (!this.ws) return
this.ws.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
this.emit('connected', null)
}
this.ws.onmessage = (event) => {
try {
const data: WebSocketEvent = JSON.parse(event.data)
console.log('WebSocket raw message received:', event.data)
console.log('Parsed WebSocket data:', data)
this.emit(data.type, data.data)
} catch (error) {
console.error('Failed to parse WebSocket message:', error, 'Raw data:', event.data)
}
}
this.ws.onclose = (event) => {
console.log('WebSocket disconnected:', event.code, event.reason)
this.emit('disconnected', { code: event.code, reason: event.reason })
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
this.emit('error', error)
}
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
this.reconnectAttempts++
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1)
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`)
setTimeout(() => {
this.connect()
}, delay)
}
on(event: string, handler: (data: any) => void) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, [])
}
this.eventHandlers.get(event)!.push(handler)
}
off(event: string, handler: (data: any) => void) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
const index = handlers.indexOf(handler)
if (index !== -1) {
handlers.splice(index, 1)
}
}
}
private emit(event: string, data: any) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
handlers.forEach(handler => {
try {
handler(data)
} catch (error) {
console.error(`Error in WebSocket event handler for ${event}:`, error)
}
})
}
}
send(message: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
console.warn('WebSocket not connected, cannot send message')
}
}
disconnect() {
if (this.ws) {
this.ws.close(1000, 'Client disconnect')
this.ws = null
}
this.eventHandlers.clear()
this.reconnectAttempts = 0
}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
}
export const websocketService = new WebSocketService()

View File

@@ -0,0 +1,178 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, set } from 'idb-keyval'
import type { Channel, ExtendedMessage, UnsentMessage, AppSettings } from '@/types'
export const useAppStore = defineStore('app', () => {
// State
const channels = ref<Channel[]>([])
const currentChannelId = ref<number | null>(null)
const messages = ref<Record<number, ExtendedMessage[]>>({})
const unsentMessages = ref<UnsentMessage[]>([])
const settings = ref<AppSettings>({
soundEnabled: true,
speechEnabled: true,
ttsEnabled: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
selectedVoiceURI: null,
defaultChannelId: null,
theme: 'auto'
})
// Computed
const currentChannel = computed(() =>
channels.value.find(c => c.id === currentChannelId.value) || null
)
const currentMessages = computed(() => {
const channelId = currentChannelId.value
const channelMessages = channelId ? messages.value[channelId] || [] : []
return channelMessages
})
const unsentMessagesForChannel = computed(() =>
currentChannelId.value
? unsentMessages.value.filter(msg => msg.channelId === currentChannelId.value)
: []
)
// Actions
const setChannels = (newChannels: Channel[]) => {
channels.value = newChannels
}
const addChannel = (channel: Channel) => {
channels.value.push(channel)
messages.value[channel.id] = []
}
const removeChannel = (channelId: number) => {
channels.value = channels.value.filter(c => c.id !== channelId)
delete messages.value[channelId]
if (currentChannelId.value === channelId) {
currentChannelId.value = channels.value[0]?.id || null
}
}
const setCurrentChannel = async (channelId: number | null) => {
currentChannelId.value = channelId
await set('current_channel_id', channelId)
}
const setMessages = (channelId: number, channelMessages: ExtendedMessage[]) => {
console.log('Store: Setting messages for channel', channelId, ':', channelMessages.length, 'messages')
messages.value[channelId] = channelMessages
}
const addMessage = (message: ExtendedMessage) => {
console.log('Store: Adding message to channel', message.channel_id, ':', message)
if (!messages.value[message.channel_id]) {
messages.value[message.channel_id] = []
}
messages.value[message.channel_id].push(message)
console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages')
// Note: Auto-save is now handled by the sync service to avoid excessive I/O
}
const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)]
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) {
channelMessages[messageIndex] = { ...channelMessages[messageIndex], ...updates }
break
}
}
}
const removeMessage = (messageId: number) => {
for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)]
const messageIndex = channelMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) {
channelMessages.splice(messageIndex, 1)
break
}
}
}
const addUnsentMessage = (message: UnsentMessage) => {
unsentMessages.value.push(message)
}
const removeUnsentMessage = (messageId: string) => {
const index = unsentMessages.value.findIndex(m => m.id === messageId)
if (index !== -1) {
unsentMessages.value.splice(index, 1)
}
}
const updateSettings = async (newSettings: Partial<AppSettings>) => {
settings.value = { ...settings.value, ...newSettings }
await set('app_settings', JSON.parse(JSON.stringify(settings.value)))
}
const loadState = async () => {
try {
const [storedChannelId, storedMessages, storedUnsentMessages, storedSettings] = await Promise.all([
get('current_channel_id'),
get('messages'),
get('unsent_messages'),
get('app_settings')
])
if (storedChannelId) currentChannelId.value = storedChannelId
if (storedMessages) messages.value = storedMessages
if (storedUnsentMessages) unsentMessages.value = storedUnsentMessages
if (storedSettings) settings.value = { ...settings.value, ...storedSettings }
} catch (error) {
console.error('Failed to load state from storage:', error)
}
}
const saveState = async () => {
try {
// Convert reactive objects to plain objects for IndexedDB
await Promise.all([
set('current_channel_id', currentChannelId.value),
set('messages', JSON.parse(JSON.stringify(messages.value))),
set('unsent_messages', JSON.parse(JSON.stringify(unsentMessages.value))),
set('app_settings', JSON.parse(JSON.stringify(settings.value)))
])
} catch (error) {
console.error('Failed to save state to storage:', error)
}
}
return {
// State
channels,
currentChannelId,
messages,
unsentMessages,
settings,
// Computed
currentChannel,
currentMessages,
unsentMessagesForChannel,
// Actions
setChannels,
addChannel,
removeChannel,
setCurrentChannel,
setMessages,
addMessage,
updateMessage,
removeMessage,
addUnsentMessage,
removeUnsentMessage,
updateSettings,
loadState,
saveState
}
})

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref, readonly } from 'vue'
import { get, set } from 'idb-keyval'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const isAuthenticated = ref(false)
const setToken = async (newToken: string) => {
token.value = newToken
isAuthenticated.value = true
await set('auth_token', newToken)
}
const clearAuth = async () => {
token.value = null
isAuthenticated.value = false
await set('auth_token', null)
}
const checkAuth = async () => {
try {
const storedToken = await get('auth_token')
if (storedToken) {
// Verify token with backend
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
const response = await fetch(`${baseUrl}/check-token`, {
headers: { Authorization: storedToken }
})
if (response.ok) {
token.value = storedToken
isAuthenticated.value = true
} else {
console.warn('Stored token is invalid, clearing auth')
await clearAuth()
}
}
} catch (error) {
console.error('Auth check failed:', error)
await clearAuth()
}
}
const authenticate = async (authToken: string): Promise<boolean> => {
try {
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
const response = await fetch(`${baseUrl}/check-token`, {
headers: { Authorization: authToken }
})
if (response.ok) {
await setToken(authToken)
return true
} else {
await clearAuth()
return false
}
} catch (error) {
console.error('Authentication failed:', error)
await clearAuth()
return false
}
}
return {
token,
isAuthenticated,
setToken,
clearAuth,
checkAuth,
authenticate
}
})

View File

@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { ref, readonly } from 'vue'
import type { ToastMessage } from '@/types'
export const useToastStore = defineStore('toast', () => {
const toasts = ref<ToastMessage[]>([])
const addToast = (message: string, type: ToastMessage['type'] = 'info', duration = 3000) => {
const id = Date.now().toString()
const toast: ToastMessage = {
id,
message,
type,
duration
}
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
return id
}
const removeToast = (id: string) => {
const index = toasts.value.findIndex(toast => toast.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
const clearToasts = () => {
toasts.value = []
}
const success = (message: string, duration?: number) => addToast(message, 'success', duration)
const error = (message: string, duration?: number) => addToast(message, 'error', duration)
const info = (message: string, duration?: number) => addToast(message, 'info', duration)
return {
toasts,
addToast,
removeToast,
clearToasts,
success,
error,
info
}
})

View File

@@ -0,0 +1,40 @@
/* Minimal reset styles only */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Accessibility helpers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,124 @@
// API Types matching backend schema
export interface Channel {
id: number
name: string
created_at: string
}
export interface Message {
id: number
channel_id: number
content: string
created_at: string
file_id?: number
}
export interface MessageWithFile extends Message {
fileId?: number
filePath?: string
fileType?: string
fileSize?: number
originalName?: string
fileCreatedAt?: string
}
export interface FileAttachment {
id: number
channel_id: number
message_id: number
file_path: string
file_type: string
file_size: number
original_name: string
created_at: string
}
// For compatibility, ExtendedMessage now represents the flattened structure from backend
export interface ExtendedMessage extends MessageWithFile {
files?: FileAttachment[] // Keep for backward compatibility but won't be used
}
// Mutable versions for store operations
export interface MutableMessage {
id: number
channel_id: number
content: string
created_at: string
file_id?: number
files?: FileAttachment[]
}
// WebSocket Event Types
export interface WebSocketEvent {
type: 'message-created' | 'message-updated' | 'message-deleted' |
'file-uploaded' | 'channel-created' | 'channel-deleted' |
'channel-merged' | 'channel-updated'
data: any
}
// Frontend State Types
export interface AppState {
isAuthenticated: boolean
currentChannelId: number | null
channels: Channel[]
messages: Record<number, ExtendedMessage[]>
unsentMessages: UnsentMessage[]
settings: AppSettings
}
export interface UnsentMessage {
id: string
channelId: number
content: string
timestamp: number
retries: number
}
export interface AppSettings {
soundEnabled: boolean
speechEnabled: boolean
ttsEnabled: boolean
ttsRate: number
ttsPitch: number
ttsVolume: number
selectedVoiceURI: string | null
defaultChannelId: number | null
theme: 'light' | 'dark' | 'auto'
}
// Audio Types
export interface AudioState {
isRecording: boolean
recordingTime: number
audioBlob: Blob | null
isPlaying: boolean
playbackTime: number
duration: number
}
// UI State Types
export interface ToastMessage {
id: string
message: string
type: 'success' | 'error' | 'info'
duration?: number
}
export interface DialogState {
isOpen: boolean
component: string | null
props: Record<string, any>
}
// Search Types
export interface SearchResult {
message: ExtendedMessage
channel: Channel
}
// File Upload Types
export interface UploadProgress {
loaded: number
total: number
percentage: number
}

View File

@@ -0,0 +1,146 @@
<template>
<div class="auth-view">
<div class="auth-card">
<div class="auth-card__header">
<h1 class="auth-card__title">Welcome to Notebrook</h1>
<p class="auth-card__subtitle">Enter your authentication token to continue</p>
</div>
<form @submit.prevent="handleAuth" class="auth-form">
<BaseInput
v-model="token"
type="password"
label="Authentication Token"
placeholder="Enter your token"
required
:error="error"
:disabled="isLoading"
@keydown.enter="handleAuth"
ref="tokenInput"
/>
<BaseButton
type="submit"
:loading="isLoading"
:disabled="!token.trim()"
class="auth-form__submit"
>
{{ isLoading ? 'Authenticating...' : 'Sign In' }}
</BaseButton>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
const router = useRouter()
const authStore = useAuthStore()
const toastStore = useToastStore()
const { playSound } = useAudio()
const token = ref('')
const error = ref('')
const isLoading = ref(false)
const tokenInput = ref()
const handleAuth = async () => {
if (!token.value.trim()) return
isLoading.value = true
error.value = ''
try {
const success = await authStore.authenticate(token.value.trim())
if (success) {
await playSound('login')
toastStore.success('Authentication successful!')
router.push('/')
} else {
error.value = 'Invalid authentication token'
tokenInput.value?.focus()
}
} catch (err) {
error.value = 'Authentication failed. Please try again.'
console.error('Auth error:', err)
} finally {
isLoading.value = false
}
}
onMounted(() => {
tokenInput.value?.focus()
playSound('intro')
})
</script>
<style scoped>
.auth-view {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.auth-card {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 400px;
padding: 2rem;
}
.auth-card__header {
text-align: center;
margin-bottom: 2rem;
}
.auth-card__title {
font-size: 2rem;
font-weight: 700;
color: #111827;
margin: 0 0 0.5rem 0;
}
.auth-card__subtitle {
color: #6b7280;
margin: 0;
font-size: 1rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.auth-form__submit {
width: 100%;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.auth-card {
background: #1f2937;
}
.auth-card__title {
color: rgba(255, 255, 255, 0.87);
}
.auth-card__subtitle {
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,618 @@
<template>
<div class="main-view">
<!-- Mobile Header -->
<header class="mobile-header">
<button
class="mobile-menu-button"
@click="sidebarOpen = !sidebarOpen"
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
>
<Icon name="menu" />
</button>
<h1 class="mobile-title">{{ appStore.currentChannel?.name || 'Notebrook' }}</h1>
<button
class="mobile-search-button"
@click="showSearchDialog = true"
aria-label="Search messages"
>
<Icon name="search" />
</button>
</header>
<!-- Sidebar Overlay -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="sidebarOpen = false"
></div>
<!-- Sidebar -->
<Sidebar
:class="{ 'sidebar-open': sidebarOpen }"
:channels="appStore.channels"
:current-channel-id="appStore.currentChannelId"
:unread-counts="unreadCounts"
@create-channel="showChannelDialog = true"
@select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
@channel-info="handleChannelInfo"
@settings="showSettings = true"
/>
<!-- Main Content -->
<main class="main-content">
<div v-if="appStore.currentChannel" class="chat-container">
<!-- Chat Header (Desktop only) -->
<ChatHeader
class="desktop-header"
:channel-name="appStore.currentChannel.name"
@search="showSearchDialog = true"
/>
<!-- Messages -->
<MessagesContainer
:messages="appStore.currentMessages"
:unsent-messages="appStore.unsentMessagesForChannel"
ref="messagesContainer"
/>
<!-- Message Input -->
<MessageInput
@send-message="handleSendMessage"
@file-upload="showFileDialog = true"
@camera="showCameraDialog = true"
@voice="showVoiceDialog = true"
ref="messageInput"
/>
</div>
<div v-else class="no-channel">
<p>Select a channel to start chatting</p>
</div>
</main>
<!-- Dialogs -->
<BaseDialog v-model:show="showChannelDialog" title="Create Channel">
<CreateChannelDialog
@cancel="showChannelDialog = false"
@created="handleChannelCreated"
/>
</BaseDialog>
<BaseDialog v-model:show="showSettings" title="Settings">
<SettingsDialog @close="showSettings = false" />
</BaseDialog>
<BaseDialog v-model:show="showSearchDialog" title="Search Messages" size="lg">
<SearchDialog
@close="showSearchDialog = false"
@select-message="handleSelectMessage"
/>
</BaseDialog>
<BaseDialog v-model:show="showFileDialog" title="Upload Files" size="lg">
<FileUploadDialog
@cancel="showFileDialog = false"
@uploaded="showFileDialog = false"
/>
</BaseDialog>
<BaseDialog v-model:show="showVoiceDialog" title="Record Voice Message">
<VoiceRecordingDialog
@close="showVoiceDialog = false"
@sent="handleVoiceSent"
/>
</BaseDialog>
<BaseDialog v-model:show="showCameraDialog" title="Take Photo">
<CameraCaptureDialog
@close="showCameraDialog = false"
@sent="handleCameraSent"
/>
</BaseDialog>
<BaseDialog v-model:show="showChannelInfoDialog" title="Channel Settings">
<ChannelInfoDialog
v-if="selectedChannelForInfo"
:channel="selectedChannelForInfo"
@close="showChannelInfoDialog = false"
/>
</BaseDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useOfflineSync } from '@/composables/useOfflineSync'
import { useWebSocket } from '@/composables/useWebSocket'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useAudio } from '@/composables/useAudio'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
// Components
import BaseDialog from '@/components/base/BaseDialog.vue'
import Icon from '@/components/base/Icon.vue'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import ChatHeader from '@/components/chat/ChatHeader.vue'
import MessagesContainer from '@/components/chat/MessagesContainer.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import CreateChannelDialog from '@/components/dialogs/CreateChannelDialog.vue'
import SettingsDialog from '@/components/dialogs/SettingsDialog.vue'
import SearchDialog from '@/components/dialogs/SearchDialog.vue'
import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
// Types
import type { ExtendedMessage, Channel } from '@/types'
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
const toastStore = useToastStore()
const { sendMessage: sendMessageOffline } = useOfflineSync()
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
// Set up services - ensure token is properly set
if (authStore.token) {
apiService.setToken(authStore.token)
}
// Refs
const messagesContainer = ref()
const messageInput = ref()
// Dialog states
const showChannelDialog = ref(false)
const showChannelInfoDialog = ref(false)
const showSettings = ref(false)
const showSearchDialog = ref(false)
const showFileDialog = ref(false)
const showVoiceDialog = ref(false)
const showCameraDialog = ref(false)
// Mobile sidebar state
const sidebarOpen = ref(false)
// Channel info state
const selectedChannelForInfo = ref<Channel | null>(null)
// Mock unread counts (implement real logic later)
const unreadCounts = ref<Record<number, number>>({})
// Set up keyboard shortcuts
const { addShortcut } = useKeyboardShortcuts()
const setupKeyboardShortcuts = () => {
// Ctrl+Shift+S - Settings
addShortcut({
key: 's',
ctrlKey: true,
shiftKey: true,
handler: () => { showSettings.value = true }
})
// Ctrl+Shift+F - Search
addShortcut({
key: 'f',
ctrlKey: true,
shiftKey: true,
handler: () => { showSearchDialog.value = true }
})
// Ctrl+Shift+C - Channel selector focus
addShortcut({
key: 'c',
ctrlKey: true,
shiftKey: true,
handler: () => {
// Focus the first channel in the list
const firstChannelButton = document.querySelector('.channel-item button') as HTMLElement
if (firstChannelButton) {
firstChannelButton.focus()
toastStore.info('Channel selector focused')
}
}
})
// Ctrl+Shift+X - Channel info
addShortcut({
key: 'x',
ctrlKey: true,
shiftKey: true,
handler: () => {
if (appStore.currentChannel) {
toastStore.info(`Channel: ${appStore.currentChannel.name} (${appStore.currentMessages.length} messages)`)
} else {
toastStore.info('No channel selected')
}
}
})
// Ctrl+Shift+V - Voice message
addShortcut({
key: 'v',
ctrlKey: true,
shiftKey: true,
handler: () => {
if (appStore.currentChannelId) {
showVoiceDialog.value = true
} else {
toastStore.info('Select a channel first')
}
}
})
// Space - Focus message input
addShortcut({
key: ' ',
handler: () => { messageInput.value?.focus() }
})
// Ctrl+Shift+T - Toggle TTS
addShortcut({
key: 't',
ctrlKey: true,
shiftKey: true,
handler: () => {
appStore.updateSettings({ ttsEnabled: !appStore.settings.ttsEnabled })
toastStore.info(`TTS ${appStore.settings.ttsEnabled ? 'enabled' : 'disabled'}`)
}
})
// Escape - Stop speaking
addShortcut({
key: 'escape',
handler: () => {
if (isSpeaking.value) {
stopSpeaking()
toastStore.info('Speech stopped')
}
}
})
// Alt+Numbers - Announce last N messages
for (let i = 1; i <= 9; i++) {
addShortcut({
key: i.toString(),
altKey: true,
handler: () => announceLastMessage(i)
})
}
// Alt+0 - Announce last 10 messages
addShortcut({
key: '0',
altKey: true,
handler: () => announceLastMessage(10)
})
}
const selectChannel = async (channelId: number) => {
console.log('Selecting channel:', channelId)
await appStore.setCurrentChannel(channelId)
// Try to sync messages for this channel
try {
await syncService.syncChannelMessages(channelId)
console.log('Channel messages synced')
} catch (error) {
console.log('Failed to sync channel messages, using local cache')
}
scrollToBottom()
}
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 handleChannelInfo = (channel: Channel) => {
selectedChannelForInfo.value = channel
showChannelInfoDialog.value = true
}
const isUnsentMessage = (messageId: string | number): boolean => {
return typeof messageId === 'string' && messageId.startsWith('unsent_')
}
// Initialize
onMounted(async () => {
// 1. Load saved state first (offline-first)
console.log('Loading local state...')
await appStore.loadState()
console.log('Local state loaded. Channels:', appStore.channels.length, 'Current channel:', appStore.currentChannelId, 'Unsent messages:', appStore.unsentMessages.length)
// 2. Try to sync with server (when online)
try {
console.log('Syncing with server...')
await syncService.fullSync()
toastStore.success('Synced with server')
} catch (error) {
console.log('Failed to sync with server, working offline with cached data')
if (appStore.channels.length === 0) {
toastStore.error('No internet connection and no cached data available')
} else {
toastStore.info('Working offline with cached data')
}
}
// 3. WebSocket connection (will gracefully fail if offline)
useWebSocket()
// 4. Set up keyboard shortcuts
setupKeyboardShortcuts()
// 5. Auto-select first channel if none selected and we have channels
if (!appStore.currentChannelId && appStore.channels.length > 0) {
await selectChannel(appStore.channels[0].id)
}
// 6. Set up periodic sync for unsent messages
const syncInterval = setInterval(async () => {
if (appStore.unsentMessages.length > 0) {
try {
console.log(`Attempting to sync ${appStore.unsentMessages.length} unsent messages`)
await syncService.retryUnsentMessages()
} catch (error) {
console.log('Background sync failed, will try again later')
}
}
}, 30000) // Every 30 seconds
// Cleanup interval on unmount
const cleanup = () => clearInterval(syncInterval)
window.addEventListener('beforeunload', cleanup)
})
</script>
<style scoped>
.main-view {
display: flex;
height: 100vh;
background: #ffffff;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
.no-channel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6b7280;
font-size: 1.125rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.main-view {
background: #111827;
}
.no-channel {
color: rgba(255, 255, 255, 0.6);
}
}
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 100;
}
.mobile-menu-button,
.mobile-search-button {
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
color: #6b7280;
}
.mobile-menu-button:hover,
.mobile-search-button:hover {
color: #374151;
}
.mobile-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: #111827;
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
/* Responsive design */
@media (max-width: 768px) {
.main-view {
flex-direction: column;
height: 100vh;
}
.mobile-header {
display: flex;
flex-shrink: 0;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 300;
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.main-content {
flex: 1;
overflow: hidden;
}
.chat-container {
height: 100%;
}
.desktop-header {
display: none;
}
}
@media (prefers-color-scheme: dark) {
.mobile-header {
background: #1f2937;
border-bottom-color: #374151;
}
.mobile-title {
color: rgba(255, 255, 255, 0.87);
}
.mobile-menu-button,
.mobile-search-button {
color: rgba(255, 255, 255, 0.6);
}
.mobile-menu-button:hover,
.mobile-search-button:hover {
color: rgba(255, 255, 255, 0.87);
}
}
</style>

View File

@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,wav,mp3}']
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'sounds/*.wav'],
manifest: {
name: 'Notebrook',
short_name: 'Notebrook',
description: 'Light note taking app in messenger style',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173
},
build: {
outDir: 'dist'
}
})

BIN
frontend/public/copy.wav Normal file

Binary file not shown.

View File

@@ -3,6 +3,7 @@ const audioContext = new AudioContext();
const soundFiles = {
intro: 'intro.wav',
login: 'login.wav',
copy: 'copy.wav',
uploadFailed: 'uploadfail.wav'
} as const;

View File

@@ -246,7 +246,12 @@ export class MainView extends View {
itm.onClick(() => {
this.openMessageDialog(message);
})
itm.onKeyDown(async(key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
itm.onKeyDown(async (key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
if (key === "c") {
navigator.clipboard.writeText(message.content.trim());
playSound("copy");
}
if (key === "Delete") {
await this.removeMessage(message.id);
if (this.messageList.children.length === 0) {
@@ -609,6 +614,24 @@ export class MainView extends View {
}
private handleHotkey(e: KeyboardEvent) {
let index = 10;
if ((e.key.match(/[1-9]/) || e.key === '0') && e.altKey) {
e.preventDefault();
if (e.key === '0') index = 10;
index = parseInt(e.key);
const messages = state.currentChannel?.messages;
if (messages && messages.length > 0) {
const msg = messages[messages.length - index];
if (msg) {
showToast(`${msg.content}; ${this.convertIsoTimeStringToFriendly(msg.createdAt)}`, 200);
} else {
showToast('No message is available in this position', 200);
}
} else {
showToast('There are no messages in this channel right now', 200)
}
}
if (e.ctrlKey && e.shiftKey) {
const action = this.hotkeyMap.get(e.key.toLowerCase());
if (action) {