Compare commits
	
		
			10 Commits
		
	
	
		
			18ebd0a6b8
			...
			864f0a5a45
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 864f0a5a45 | |||
| 8aeef63886 | |||
| 9f0943748f | |||
| 3edab7fbd7 | |||
| 58e0c10b4e | |||
| 64e50027ca | |||
| 57f4499c26 | |||
| 7b036e54a6 | |||
| 3dfbb8e6f6 | |||
| 
						 | 
					dc0db147de | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -4,3 +4,4 @@ backend/db.sqlite
 | 
				
			|||||||
backend/uploads/
 | 
					backend/uploads/
 | 
				
			||||||
.DS_Store
 | 
					.DS_Store
 | 
				
			||||||
frontend/dist/
 | 
					frontend/dist/
 | 
				
			||||||
 | 
					frontend-vue/dist
 | 
				
			||||||
@@ -6,5 +6,6 @@ export const router = Router({mergeParams: true});
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
router.post('/', authenticate, ChannelController.createChannel);
 | 
					router.post('/', authenticate, ChannelController.createChannel);
 | 
				
			||||||
router.get('/', authenticate, ChannelController.getChannels);
 | 
					router.get('/', authenticate, ChannelController.getChannels);
 | 
				
			||||||
 | 
					router.put('/:channelId', authenticate, ChannelController.updateChannel);
 | 
				
			||||||
router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
 | 
					router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
 | 
				
			||||||
router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);
 | 
					router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,21 +4,21 @@ WORKDIR /usr/src/app
 | 
				
			|||||||
FROM base AS install
 | 
					FROM base AS install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY backend/package.json backend/package-lock.json /temp/dev/backend/
 | 
					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/backend && npm install
 | 
				
			||||||
RUN cd /temp/dev/frontend && npm install
 | 
					RUN cd /temp/dev/frontend && npm install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
 | 
					RUN mkdir -p /temp/prod/backend /temp/prod/frontend
 | 
				
			||||||
COPY backend/package.json backend/package-lock.json /temp/prod/backend/
 | 
					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/backend && npm install --production
 | 
				
			||||||
RUN cd /temp/prod/frontend && npm install --production
 | 
					RUN cd /temp/prod/frontend && npm install --production
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM install AS build-frontend
 | 
					FROM install AS build-frontend
 | 
				
			||||||
WORKDIR /usr/src/app/frontend
 | 
					WORKDIR /usr/src/app/frontend
 | 
				
			||||||
COPY frontend/ .
 | 
					COPY frontend-vue/ .
 | 
				
			||||||
COPY --from=install /temp/dev/frontend/node_modules node_modules
 | 
					COPY --from=install /temp/dev/frontend/node_modules node_modules
 | 
				
			||||||
RUN npm run build
 | 
					RUN npm run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										8
									
								
								frontend-vue/env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend-vue/env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					/// <reference types="vite/client" />
 | 
				
			||||||
 | 
					/// <reference types="vite-plugin-pwa/client" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					declare module '*.vue' {
 | 
				
			||||||
 | 
					  import type { DefineComponent } from 'vue'
 | 
				
			||||||
 | 
					  const component: DefineComponent<{}, {}, any>
 | 
				
			||||||
 | 
					  export default component
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1
									
								
								frontend-vue/frontend-vue/public/pwa-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend-vue/frontend-vue/public/pwa-192x192.png
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					PWA icons would go here - placeholder files
 | 
				
			||||||
							
								
								
									
										1
									
								
								frontend-vue/frontend-vue/public/pwa-512x512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend-vue/frontend-vue/public/pwa-512x512.png
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					PWA icons would go here - placeholder files
 | 
				
			||||||
							
								
								
									
										14
									
								
								frontend-vue/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend-vue/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					  <head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <link rel="icon" href="/favicon.ico">
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					    <title>Notebrook</title>
 | 
				
			||||||
 | 
					    <meta name="description" content="Light note taking app in messenger style">
 | 
				
			||||||
 | 
					  </head>
 | 
				
			||||||
 | 
					  <body>
 | 
				
			||||||
 | 
					    <div id="app"></div>
 | 
				
			||||||
 | 
					    <script type="module" src="/src/main.ts"></script>
 | 
				
			||||||
 | 
					  </body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										8340
									
								
								frontend-vue/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										8340
									
								
								frontend-vue/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										38
									
								
								frontend-vue/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend-vue/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "notebrook-frontend-vue",
 | 
				
			||||||
 | 
					  "private": true,
 | 
				
			||||||
 | 
					  "version": "1.0.0",
 | 
				
			||||||
 | 
					  "type": "module",
 | 
				
			||||||
 | 
					  "scripts": {
 | 
				
			||||||
 | 
					    "dev": "vite",
 | 
				
			||||||
 | 
					    "build": "vue-tsc && vite build",
 | 
				
			||||||
 | 
					    "preview": "vite preview",
 | 
				
			||||||
 | 
					    "type-check": "vue-tsc --noEmit",
 | 
				
			||||||
 | 
					    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
 | 
				
			||||||
 | 
					    "format": "prettier --write src/"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "vue": "^3.5.13",
 | 
				
			||||||
 | 
					    "vue-router": "^4.4.5",
 | 
				
			||||||
 | 
					    "pinia": "^2.3.0",
 | 
				
			||||||
 | 
					    "idb-keyval": "^6.2.1",
 | 
				
			||||||
 | 
					    "@vueuse/core": "^11.3.0",
 | 
				
			||||||
 | 
					    "@vueuse/sound": "^2.0.1"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "@types/node": "^22.10.2",
 | 
				
			||||||
 | 
					    "@vitejs/plugin-vue": "^5.2.1",
 | 
				
			||||||
 | 
					    "@vue/tsconfig": "^0.7.0",
 | 
				
			||||||
 | 
					    "typescript": "^5.7.2",
 | 
				
			||||||
 | 
					    "vite": "^6.0.5",
 | 
				
			||||||
 | 
					    "vue-tsc": "^2.1.10",
 | 
				
			||||||
 | 
					    "vite-plugin-pwa": "^0.21.2",
 | 
				
			||||||
 | 
					    "@typescript-eslint/eslint-plugin": "^8.18.2",
 | 
				
			||||||
 | 
					    "@typescript-eslint/parser": "^8.18.2",
 | 
				
			||||||
 | 
					    "@vue/eslint-config-prettier": "^10.1.0",
 | 
				
			||||||
 | 
					    "@vue/eslint-config-typescript": "^14.1.3",
 | 
				
			||||||
 | 
					    "eslint": "^9.17.0",
 | 
				
			||||||
 | 
					    "eslint-plugin-vue": "^9.32.0",
 | 
				
			||||||
 | 
					    "prettier": "^3.4.2"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1
									
								
								frontend-vue/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend-vue/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVFiFtZddaBxVFMd/587M7iabNE1TbdI2VsWCVixYH/xAfRBBfBBBQcEH8UVBfPBBRcEXwQcfxAcRfLAP4oMPIr74oFgQC1qwYKHggyK2aZu0SZM0yWYzOzNz78/HmZ1Ndjeb3ewcmLkz95z/+Z977jl3wDmH/4NSSoUQoj9JknW+73vW2oy1NhVCmBDiEjAAbAImhBDHjTEfN5vNkw8//PBs/xfKOVf3f9M0zXe995cBjDHGOWdLpVIhhBgHtgArgBHgdeAW4CpgDaDAJmvtF8aYhzqdzrfOuer8OLquA6RSqd1a61XGmHettVprDYBzLhFCWGPMJuDncrn8erVavbNQKFyz2Wye6XQ6RwF836dt2581m81HnHNXd133jRBiddcCY8wW7/1w13WTUqnkOOdahULBSSm9EGKllHKVUmqllHKFUqovhOgTQvQJIXqFED1SyjYwC3Qcxzifw2az+WGhUBhpNpudtbW1x7TWnzrnXhJCbFtqQZIkh5Ik+dQY87ExZtwYM+m9P2mtPQWc6nQ6p1ut1ulu+1prbTtlNpqmabZarVONRuNU13XfOecOWmtPCCGOAkeNMd855+aXLbfAGDNhjPl69+7dW7XWu4wx9xhj7gZuB24E1gJVoAyUgBJgbLFYrBljjo2NjX2xvb39/aNHj06sXr06tm17DhgZGRnZIaW8p1KpbO3MdAY7M1kHGoTw+sDAQH9/f//qcrm8ptls/t1oNE6fP3/+1wMHDkxNTU1NlUqlcrVabf3www8AcO+991JZt25dvVGvfzs7Ozs+Ozt7dnp6+sza2tqZqampk1NTU5OTk5MT3vvJ+vr6ifvuu+8TAOcchw4dYmhoyAjhZ2sAbW3VarXJycnJqenp6dOzs7Ozs7Ozk9PT0xPT09MT9Xr9XLfdr+nKqampyenp6cnZ2dnJvr6+yfHx8S0AkiQxzjmstVq11qIsZvmm/dFrdDo/bthubr+q02ht7na7OT58+DAXL16k0Wjwl4ZCofCElPKxarW6O06cLGYJ07RAGOe9nxkfHz+2adOmN4aHh68HhqSUryilHlNKPeG9X9tFJKV8Ukp5u5TyHinl3c65ByuVygMLOgC2b9/OsqUUCGELhcJOKeU+KeXjUsrHpZRPSSn3SCl3Sil3SCm3Sym3Sym3LblAKTUipbxVSrlFSrlZSrlJSrlRSrlBSrleKaWWF2Tt+2Y5kZqX5hXjjn/TF9tGm7qCRAAAAABJRU5ErkJggg==
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/copy.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/copy.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/intro.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/intro.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/login.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/login.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent1.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent1.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent2.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent2.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent3.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent3.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent4.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent4.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent5.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent5.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent6.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/sent6.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/uploadfail.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/uploadfail.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water1.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water1.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water10.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water10.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water2.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water2.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water3.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water3.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water4.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water4.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water5.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water5.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water6.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water6.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water7.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water7.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water8.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water8.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water9.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend-vue/public/sounds/water9.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										74
									
								
								frontend-vue/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								frontend-vue/src/App.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div id="app">
 | 
				
			||||||
 | 
					    <router-view />
 | 
				
			||||||
 | 
					    <!-- Toast notifications -->
 | 
				
			||||||
 | 
					    <div class="toast-container" v-if="toastStore.toasts.length > 0">
 | 
				
			||||||
 | 
					      <div 
 | 
				
			||||||
 | 
					        v-for="toast in toastStore.toasts" 
 | 
				
			||||||
 | 
					        :key="toast.id"
 | 
				
			||||||
 | 
					        class="toast"
 | 
				
			||||||
 | 
					        :class="toast.type"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {{ toast.message }}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Authentication is now handled by the router guard in main.ts
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style>
 | 
				
			||||||
 | 
					#app {
 | 
				
			||||||
 | 
					  height: 100vh;
 | 
				
			||||||
 | 
					  width: 100vw;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast-container {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 20px;
 | 
				
			||||||
 | 
					  right: 20px;
 | 
				
			||||||
 | 
					  z-index: 10000;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast {
 | 
				
			||||||
 | 
					  padding: 12px 16px;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  max-width: 300px;
 | 
				
			||||||
 | 
					  animation: slideIn 0.3s ease-out;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast.success {
 | 
				
			||||||
 | 
					  background-color: #10b981;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast.error {
 | 
				
			||||||
 | 
					  background-color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toast.info {
 | 
				
			||||||
 | 
					  background-color: #3b82f6;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@keyframes slideIn {
 | 
				
			||||||
 | 
					  from {
 | 
				
			||||||
 | 
					    transform: translateX(100%);
 | 
				
			||||||
 | 
					    opacity: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  to {
 | 
				
			||||||
 | 
					    transform: translateX(0);
 | 
				
			||||||
 | 
					    opacity: 1;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										173
									
								
								frontend-vue/src/components/base/BaseButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								frontend-vue/src/components/base/BaseButton.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,173 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <button 
 | 
				
			||||||
 | 
					    :type="type"
 | 
				
			||||||
 | 
					    :disabled="disabled"
 | 
				
			||||||
 | 
					    :class="[
 | 
				
			||||||
 | 
					      'base-button',
 | 
				
			||||||
 | 
					      `base-button--${variant}`,
 | 
				
			||||||
 | 
					      `base-button--${size}`,
 | 
				
			||||||
 | 
					      { 'base-button--loading': loading }
 | 
				
			||||||
 | 
					    ]"
 | 
				
			||||||
 | 
					    :aria-label="ariaLabel"
 | 
				
			||||||
 | 
					    :aria-describedby="ariaDescribedby"
 | 
				
			||||||
 | 
					    @click="$emit('click', $event)"
 | 
				
			||||||
 | 
					    @keydown="handleKeydown"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
 | 
				
			||||||
 | 
					    <span :class="{ 'base-button__content--hidden': loading }">
 | 
				
			||||||
 | 
					      <slot />
 | 
				
			||||||
 | 
					    </span>
 | 
				
			||||||
 | 
					  </button>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  type?: 'button' | 'submit' | 'reset'
 | 
				
			||||||
 | 
					  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
 | 
				
			||||||
 | 
					  size?: 'sm' | 'md' | 'lg'
 | 
				
			||||||
 | 
					  disabled?: boolean
 | 
				
			||||||
 | 
					  loading?: boolean
 | 
				
			||||||
 | 
					  ariaLabel?: string
 | 
				
			||||||
 | 
					  ariaDescribedby?: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  type: 'button',
 | 
				
			||||||
 | 
					  variant: 'primary',
 | 
				
			||||||
 | 
					  size: 'md',
 | 
				
			||||||
 | 
					  disabled: false,
 | 
				
			||||||
 | 
					  loading: false
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  click: [event: MouseEvent]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  if (event.key === 'Enter' || event.key === ' ') {
 | 
				
			||||||
 | 
					    event.preventDefault()
 | 
				
			||||||
 | 
					    emit('click', event as any)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.base-button {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  display: inline-flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  border: 1px solid transparent;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  font-family: inherit;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button:focus-visible {
 | 
				
			||||||
 | 
					  outline: 2px solid #646cff;
 | 
				
			||||||
 | 
					  outline-offset: 2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button:disabled {
 | 
				
			||||||
 | 
					  opacity: 0.6;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Sizes */
 | 
				
			||||||
 | 
					.base-button--sm {
 | 
				
			||||||
 | 
					  padding: 0.5rem 0.75rem;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--md {
 | 
				
			||||||
 | 
					  padding: 0.75rem 1rem;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--lg {
 | 
				
			||||||
 | 
					  padding: 1rem 1.5rem;
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Variants */
 | 
				
			||||||
 | 
					.base-button--primary {
 | 
				
			||||||
 | 
					  background-color: #646cff;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--primary:hover:not(:disabled) {
 | 
				
			||||||
 | 
					  background-color: #535bf2;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--secondary {
 | 
				
			||||||
 | 
					  background-color: #f9f9f9;
 | 
				
			||||||
 | 
					  color: #213547;
 | 
				
			||||||
 | 
					  border-color: #d1d5db;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--secondary:hover:not(:disabled) {
 | 
				
			||||||
 | 
					  background-color: #f3f4f6;
 | 
				
			||||||
 | 
					  border-color: #9ca3af;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--danger {
 | 
				
			||||||
 | 
					  background-color: #ef4444;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--danger:hover:not(:disabled) {
 | 
				
			||||||
 | 
					  background-color: #dc2626;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--ghost {
 | 
				
			||||||
 | 
					  background-color: transparent;
 | 
				
			||||||
 | 
					  color: #646cff;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button--ghost:hover:not(:disabled) {
 | 
				
			||||||
 | 
					  background-color: rgba(100, 108, 255, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Loading state */
 | 
				
			||||||
 | 
					.base-button--loading {
 | 
				
			||||||
 | 
					  cursor: wait;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button__spinner {
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  width: 1rem;
 | 
				
			||||||
 | 
					  height: 1rem;
 | 
				
			||||||
 | 
					  border: 2px solid transparent;
 | 
				
			||||||
 | 
					  border-top: 2px solid currentColor;
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  animation: spin 1s linear infinite;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-button__content--hidden {
 | 
				
			||||||
 | 
					  opacity: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@keyframes spin {
 | 
				
			||||||
 | 
					  to {
 | 
				
			||||||
 | 
					    transform: rotate(360deg);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .base-button--secondary {
 | 
				
			||||||
 | 
					    background-color: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-button--secondary:hover:not(:disabled) {
 | 
				
			||||||
 | 
					    background-color: #4b5563;
 | 
				
			||||||
 | 
					    border-color: #6b7280;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										279
									
								
								frontend-vue/src/components/base/BaseDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								frontend-vue/src/components/base/BaseDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,279 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Teleport to="body">
 | 
				
			||||||
 | 
					    <Transition name="dialog">
 | 
				
			||||||
 | 
					      <div 
 | 
				
			||||||
 | 
					        v-if="show"
 | 
				
			||||||
 | 
					        class="dialog-overlay"
 | 
				
			||||||
 | 
					        @click="handleOverlayClick"
 | 
				
			||||||
 | 
					        @keydown.esc="handleClose"
 | 
				
			||||||
 | 
					        role="dialog"
 | 
				
			||||||
 | 
					        :aria-labelledby="titleId"
 | 
				
			||||||
 | 
					        :aria-describedby="contentId"
 | 
				
			||||||
 | 
					        aria-modal="true"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          ref="dialogRef"
 | 
				
			||||||
 | 
					          :class="[
 | 
				
			||||||
 | 
					            'dialog',
 | 
				
			||||||
 | 
					            `dialog--${size}`
 | 
				
			||||||
 | 
					          ]"
 | 
				
			||||||
 | 
					          @click.stop
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <div class="dialog__header" v-if="$slots.header || title">
 | 
				
			||||||
 | 
					            <h2 :id="titleId" class="dialog__title">
 | 
				
			||||||
 | 
					              <slot name="header">{{ title }}</slot>
 | 
				
			||||||
 | 
					            </h2>
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              v-if="closable"
 | 
				
			||||||
 | 
					              class="dialog__close"
 | 
				
			||||||
 | 
					              @click="handleClose"
 | 
				
			||||||
 | 
					              aria-label="Close dialog"
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <span aria-hidden="true">×</span>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <div :id="contentId" class="dialog__content">
 | 
				
			||||||
 | 
					            <slot />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <div v-if="$slots.footer" class="dialog__footer">
 | 
				
			||||||
 | 
					            <slot name="footer" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Transition>
 | 
				
			||||||
 | 
					  </Teleport>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed, nextTick, watch } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  show: boolean
 | 
				
			||||||
 | 
					  title?: string
 | 
				
			||||||
 | 
					  size?: 'sm' | 'md' | 'lg' | 'xl'
 | 
				
			||||||
 | 
					  closable?: boolean
 | 
				
			||||||
 | 
					  closeOnOverlay?: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  size: 'md',
 | 
				
			||||||
 | 
					  closable: true,
 | 
				
			||||||
 | 
					  closeOnOverlay: true
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  close: []
 | 
				
			||||||
 | 
					  'update:show': [value: boolean]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const dialogRef = ref<HTMLDivElement>()
 | 
				
			||||||
 | 
					const titleId = computed(() => `dialog-title-${Math.random().toString(36).substr(2, 9)}`)
 | 
				
			||||||
 | 
					const contentId = computed(() => `dialog-content-${Math.random().toString(36).substr(2, 9)}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleClose = () => {
 | 
				
			||||||
 | 
					  emit('close')
 | 
				
			||||||
 | 
					  emit('update:show', false)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleOverlayClick = () => {
 | 
				
			||||||
 | 
					  if (props.closeOnOverlay) {
 | 
				
			||||||
 | 
					    handleClose()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Focus management
 | 
				
			||||||
 | 
					let lastFocusedElement: HTMLElement | null = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const trapFocus = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  if (event.key !== 'Tab') return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const focusableElements = dialogRef.value?.querySelectorAll(
 | 
				
			||||||
 | 
					    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
 | 
				
			||||||
 | 
					  ) as NodeListOf<HTMLElement>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (!focusableElements || focusableElements.length === 0) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const firstElement = focusableElements[0]
 | 
				
			||||||
 | 
					  const lastElement = focusableElements[focusableElements.length - 1]
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (event.shiftKey) {
 | 
				
			||||||
 | 
					    if (document.activeElement === firstElement) {
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      lastElement.focus()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    if (document.activeElement === lastElement) {
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      firstElement.focus()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watch(() => props.show, async (isVisible) => {
 | 
				
			||||||
 | 
					  if (isVisible) {
 | 
				
			||||||
 | 
					    lastFocusedElement = document.activeElement as HTMLElement
 | 
				
			||||||
 | 
					    document.body.style.overflow = 'hidden'
 | 
				
			||||||
 | 
					    document.addEventListener('keydown', trapFocus)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Focus first focusable element or the dialog itself
 | 
				
			||||||
 | 
					    const firstFocusable = dialogRef.value?.querySelector(
 | 
				
			||||||
 | 
					      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
 | 
				
			||||||
 | 
					    ) as HTMLElement
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (firstFocusable) {
 | 
				
			||||||
 | 
					      firstFocusable.focus()
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      dialogRef.value?.focus()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    document.body.style.overflow = ''
 | 
				
			||||||
 | 
					    document.removeEventListener('keydown', trapFocus)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Restore focus to the element that was focused before the dialog opened
 | 
				
			||||||
 | 
					    if (lastFocusedElement) {
 | 
				
			||||||
 | 
					      lastFocusedElement.focus()
 | 
				
			||||||
 | 
					      lastFocusedElement = null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.dialog-overlay {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  background-color: rgba(0, 0, 0, 0.5);
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  z-index: 1000;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog {
 | 
				
			||||||
 | 
					  background: #ffffff;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
 | 
				
			||||||
 | 
					  max-height: 90vh;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog--sm {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 24rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog--md {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 32rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog--lg {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 48rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog--xl {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 64rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__header {
 | 
				
			||||||
 | 
					  padding: 1.5rem 1.5rem 0;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__title {
 | 
				
			||||||
 | 
					  font-size: 1.25rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__close {
 | 
				
			||||||
 | 
					  background: none;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  font-size: 1.5rem;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  padding: 0.25rem;
 | 
				
			||||||
 | 
					  line-height: 1;
 | 
				
			||||||
 | 
					  transition: color 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__close:hover {
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__close:focus-visible {
 | 
				
			||||||
 | 
					  outline: 2px solid #646cff;
 | 
				
			||||||
 | 
					  outline-offset: 2px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__content {
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog__footer {
 | 
				
			||||||
 | 
					  padding: 0 1.5rem 1.5rem;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Transitions */
 | 
				
			||||||
 | 
					.dialog-enter-active,
 | 
				
			||||||
 | 
					.dialog-leave-active {
 | 
				
			||||||
 | 
					  transition: opacity 0.3s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-enter-from,
 | 
				
			||||||
 | 
					.dialog-leave-to {
 | 
				
			||||||
 | 
					  opacity: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-enter-active .dialog,
 | 
				
			||||||
 | 
					.dialog-leave-active .dialog {
 | 
				
			||||||
 | 
					  transition: transform 0.3s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-enter-from .dialog,
 | 
				
			||||||
 | 
					.dialog-leave-to .dialog {
 | 
				
			||||||
 | 
					  transform: scale(0.9);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .dialog {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog__title {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog__close {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog__close:hover {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										185
									
								
								frontend-vue/src/components/base/BaseInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								frontend-vue/src/components/base/BaseInput.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,185 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="base-input">
 | 
				
			||||||
 | 
					    <label v-if="label" :for="inputId" class="base-input__label">
 | 
				
			||||||
 | 
					      {{ label }}
 | 
				
			||||||
 | 
					      <span v-if="required" class="base-input__required">*</span>
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="base-input__wrapper">
 | 
				
			||||||
 | 
					      <input
 | 
				
			||||||
 | 
					        :id="inputId"
 | 
				
			||||||
 | 
					        ref="inputRef"
 | 
				
			||||||
 | 
					        :type="type"
 | 
				
			||||||
 | 
					        :value="modelValue"
 | 
				
			||||||
 | 
					        :placeholder="placeholder"
 | 
				
			||||||
 | 
					        :disabled="disabled"
 | 
				
			||||||
 | 
					        :readonly="readonly"
 | 
				
			||||||
 | 
					        :required="required"
 | 
				
			||||||
 | 
					        :autocomplete="autocomplete"
 | 
				
			||||||
 | 
					        :aria-invalid="error ? 'true' : 'false'"
 | 
				
			||||||
 | 
					        :aria-describedby="error ? `${inputId}-error` : undefined"
 | 
				
			||||||
 | 
					        :class="[
 | 
				
			||||||
 | 
					          'base-input__field',
 | 
				
			||||||
 | 
					          { 'base-input__field--error': error }
 | 
				
			||||||
 | 
					        ]"
 | 
				
			||||||
 | 
					        @input="handleInput"
 | 
				
			||||||
 | 
					        @blur="$emit('blur', $event)"
 | 
				
			||||||
 | 
					        @focus="$emit('focus', $event)"
 | 
				
			||||||
 | 
					        @keydown="$emit('keydown', $event)"
 | 
				
			||||||
 | 
					        @keyup="$emit('keyup', $event)"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-if="error" :id="`${inputId}-error`" class="base-input__error">
 | 
				
			||||||
 | 
					      {{ error }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-else-if="helpText" class="base-input__help">
 | 
				
			||||||
 | 
					      {{ helpText }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  modelValue: string | number
 | 
				
			||||||
 | 
					  type?: string
 | 
				
			||||||
 | 
					  label?: string
 | 
				
			||||||
 | 
					  placeholder?: string
 | 
				
			||||||
 | 
					  disabled?: boolean
 | 
				
			||||||
 | 
					  readonly?: boolean
 | 
				
			||||||
 | 
					  required?: boolean
 | 
				
			||||||
 | 
					  autocomplete?: string
 | 
				
			||||||
 | 
					  error?: string
 | 
				
			||||||
 | 
					  helpText?: string
 | 
				
			||||||
 | 
					  id?: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  type: 'text',
 | 
				
			||||||
 | 
					  disabled: false,
 | 
				
			||||||
 | 
					  readonly: false,
 | 
				
			||||||
 | 
					  required: false
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  'update:modelValue': [value: string | number]
 | 
				
			||||||
 | 
					  blur: [event: FocusEvent]
 | 
				
			||||||
 | 
					  focus: [event: FocusEvent]
 | 
				
			||||||
 | 
					  keydown: [event: KeyboardEvent]
 | 
				
			||||||
 | 
					  keyup: [event: KeyboardEvent]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const inputRef = ref<HTMLInputElement>()
 | 
				
			||||||
 | 
					const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleInput = (event: Event) => {
 | 
				
			||||||
 | 
					  const target = event.target as HTMLInputElement
 | 
				
			||||||
 | 
					  const value = props.type === 'number' ? parseFloat(target.value) || 0 : target.value
 | 
				
			||||||
 | 
					  emit('update:modelValue', value)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const focus = () => {
 | 
				
			||||||
 | 
					  inputRef.value?.focus()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({
 | 
				
			||||||
 | 
					  focus,
 | 
				
			||||||
 | 
					  inputRef
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.base-input {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__label {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__required {
 | 
				
			||||||
 | 
					  color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__wrapper {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  padding: 0.75rem 1rem;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  font-family: inherit;
 | 
				
			||||||
 | 
					  background-color: #ffffff;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field:focus {
 | 
				
			||||||
 | 
					  border-color: #646cff;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field:disabled {
 | 
				
			||||||
 | 
					  background-color: #f9fafb;
 | 
				
			||||||
 | 
					  color: #9ca3af;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field:readonly {
 | 
				
			||||||
 | 
					  background-color: #f9fafb;
 | 
				
			||||||
 | 
					  cursor: default;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field--error {
 | 
				
			||||||
 | 
					  border-color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__field--error:focus {
 | 
				
			||||||
 | 
					  border-color: #ef4444;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__error {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-input__help {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .base-input__label {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-input__field {
 | 
				
			||||||
 | 
					    background-color: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-input__field:disabled,
 | 
				
			||||||
 | 
					  .base-input__field:readonly {
 | 
				
			||||||
 | 
					    background-color: #1f2937;
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-input__help {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										228
									
								
								frontend-vue/src/components/base/BaseTextarea.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								frontend-vue/src/components/base/BaseTextarea.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,228 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="base-textarea">
 | 
				
			||||||
 | 
					    <label v-if="label" :for="textareaId" class="base-textarea__label">
 | 
				
			||||||
 | 
					      {{ label }}
 | 
				
			||||||
 | 
					      <span v-if="required" class="base-textarea__required">*</span>
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="base-textarea__wrapper">
 | 
				
			||||||
 | 
					      <textarea
 | 
				
			||||||
 | 
					        :id="textareaId"
 | 
				
			||||||
 | 
					        ref="textareaRef"
 | 
				
			||||||
 | 
					        :value="modelValue"
 | 
				
			||||||
 | 
					        :placeholder="placeholder"
 | 
				
			||||||
 | 
					        :disabled="disabled"
 | 
				
			||||||
 | 
					        :readonly="readonly"
 | 
				
			||||||
 | 
					        :required="required"
 | 
				
			||||||
 | 
					        :rows="rows"
 | 
				
			||||||
 | 
					        :maxlength="maxlength"
 | 
				
			||||||
 | 
					        :aria-invalid="error ? 'true' : 'false'"
 | 
				
			||||||
 | 
					        :aria-describedby="error ? `${textareaId}-error` : undefined"
 | 
				
			||||||
 | 
					        :class="[
 | 
				
			||||||
 | 
					          'base-textarea__field',
 | 
				
			||||||
 | 
					          { 'base-textarea__field--error': error }
 | 
				
			||||||
 | 
					        ]"
 | 
				
			||||||
 | 
					        @input="handleInput"
 | 
				
			||||||
 | 
					        @blur="$emit('blur', $event)"
 | 
				
			||||||
 | 
					        @focus="$emit('focus', $event)"
 | 
				
			||||||
 | 
					        @keydown="handleKeydown"
 | 
				
			||||||
 | 
					        @keyup="$emit('keyup', $event)"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-if="showCharCount && maxlength" class="base-textarea__char-count">
 | 
				
			||||||
 | 
					      {{ modelValue.length }}/{{ maxlength }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
 | 
				
			||||||
 | 
					      {{ error }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-else-if="helpText" class="base-textarea__help">
 | 
				
			||||||
 | 
					      {{ helpText }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  modelValue: string
 | 
				
			||||||
 | 
					  label?: string
 | 
				
			||||||
 | 
					  placeholder?: string
 | 
				
			||||||
 | 
					  disabled?: boolean
 | 
				
			||||||
 | 
					  readonly?: boolean
 | 
				
			||||||
 | 
					  required?: boolean
 | 
				
			||||||
 | 
					  rows?: number
 | 
				
			||||||
 | 
					  maxlength?: number
 | 
				
			||||||
 | 
					  showCharCount?: boolean
 | 
				
			||||||
 | 
					  error?: string
 | 
				
			||||||
 | 
					  helpText?: string
 | 
				
			||||||
 | 
					  id?: string
 | 
				
			||||||
 | 
					  autoResize?: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  disabled: false,
 | 
				
			||||||
 | 
					  readonly: false,
 | 
				
			||||||
 | 
					  required: false,
 | 
				
			||||||
 | 
					  rows: 3,
 | 
				
			||||||
 | 
					  showCharCount: false,
 | 
				
			||||||
 | 
					  autoResize: false
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  'update:modelValue': [value: string]
 | 
				
			||||||
 | 
					  blur: [event: FocusEvent]
 | 
				
			||||||
 | 
					  focus: [event: FocusEvent]
 | 
				
			||||||
 | 
					  keydown: [event: KeyboardEvent]
 | 
				
			||||||
 | 
					  keyup: [event: KeyboardEvent]
 | 
				
			||||||
 | 
					  submit: []
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const textareaRef = ref<HTMLTextAreaElement>()
 | 
				
			||||||
 | 
					const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleInput = (event: Event) => {
 | 
				
			||||||
 | 
					  const target = event.target as HTMLTextAreaElement
 | 
				
			||||||
 | 
					  emit('update:modelValue', target.value)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (props.autoResize) {
 | 
				
			||||||
 | 
					    autoResize(target)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  emit('keydown', event)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Submit on Ctrl+Enter or Cmd+Enter
 | 
				
			||||||
 | 
					  if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
 | 
				
			||||||
 | 
					    event.preventDefault()
 | 
				
			||||||
 | 
					    emit('submit')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const autoResize = (textarea: HTMLTextAreaElement) => {
 | 
				
			||||||
 | 
					  textarea.style.height = 'auto'
 | 
				
			||||||
 | 
					  textarea.style.height = textarea.scrollHeight + 'px'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const focus = () => {
 | 
				
			||||||
 | 
					  textareaRef.value?.focus()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const selectAll = () => {
 | 
				
			||||||
 | 
					  textareaRef.value?.select()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({
 | 
				
			||||||
 | 
					  focus,
 | 
				
			||||||
 | 
					  selectAll,
 | 
				
			||||||
 | 
					  textareaRef
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.base-textarea {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__label {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__required {
 | 
				
			||||||
 | 
					  color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__wrapper {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  padding: 0.75rem 1rem;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  font-family: inherit;
 | 
				
			||||||
 | 
					  background-color: #ffffff;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  resize: vertical;
 | 
				
			||||||
 | 
					  min-height: 3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field:focus {
 | 
				
			||||||
 | 
					  border-color: #646cff;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field:disabled {
 | 
				
			||||||
 | 
					  background-color: #f9fafb;
 | 
				
			||||||
 | 
					  color: #9ca3af;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					  resize: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field:readonly {
 | 
				
			||||||
 | 
					  background-color: #f9fafb;
 | 
				
			||||||
 | 
					  cursor: default;
 | 
				
			||||||
 | 
					  resize: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field--error {
 | 
				
			||||||
 | 
					  border-color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__field--error:focus {
 | 
				
			||||||
 | 
					  border-color: #ef4444;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__char-count {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  text-align: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__error {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  color: #ef4444;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.base-textarea__help {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .base-textarea__label {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-textarea__field {
 | 
				
			||||||
 | 
					    background-color: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-textarea__field:disabled,
 | 
				
			||||||
 | 
					  .base-textarea__field:readonly {
 | 
				
			||||||
 | 
					    background-color: #1f2937;
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .base-textarea__help,
 | 
				
			||||||
 | 
					  .base-textarea__char-count {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										121
									
								
								frontend-vue/src/components/base/Icon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								frontend-vue/src/components/base/Icon.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,121 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <span class="icon" :class="[`icon-${name}`, sizeClass]">
 | 
				
			||||||
 | 
					    <!-- Microphone -->
 | 
				
			||||||
 | 
					    <svg v-if="name === 'microphone'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M12 2C13.1 2 14 2.9 14 4V10C14 11.1 13.1 12 12 12S10 11.1 10 10V4C10 2.9 10.9 2 12 2M19 10V12C19 15.3 16.3 18 13 18V22H11V18C7.7 18 5 15.3 5 12V10H7V12C7 14.2 8.8 16 11 16H13C15.2 16 17 14.2 17 12V10H19Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Stop -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'stop'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M18,18H6V6H18V18Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Play -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'play'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Send -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'send'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Trash -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'trash'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Warning -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'warning'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Info -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'info'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Camera -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'camera'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M4,4H7L9,2H15L17,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Upload -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'upload'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Close -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'close'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Search -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'search'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Plus -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'plus'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Settings -->
 | 
				
			||||||
 | 
					    <svg v-else-if="name === 'settings'" viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Default fallback -->
 | 
				
			||||||
 | 
					    <svg v-else viewBox="0 0 24 24" fill="currentColor">
 | 
				
			||||||
 | 
					      <path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
 | 
				
			||||||
 | 
					    </svg>
 | 
				
			||||||
 | 
					  </span>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { computed } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  name: string
 | 
				
			||||||
 | 
					  size?: 'sm' | 'md' | 'lg' | 'xl'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  size: 'md'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const sizeClass = computed(() => `icon-${props.size}`)
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.icon {
 | 
				
			||||||
 | 
					  display: inline-flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.icon svg {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.icon-sm {
 | 
				
			||||||
 | 
					  width: 1rem;
 | 
				
			||||||
 | 
					  height: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.icon-md {
 | 
				
			||||||
 | 
					  width: 1.25rem;
 | 
				
			||||||
 | 
					  height: 1.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.icon-lg {
 | 
				
			||||||
 | 
					  width: 1.5rem;
 | 
				
			||||||
 | 
					  height: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.icon-xl {
 | 
				
			||||||
 | 
					  width: 2rem;
 | 
				
			||||||
 | 
					  height: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										70
									
								
								frontend-vue/src/components/chat/ChatHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								frontend-vue/src/components/chat/ChatHeader.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <header class="chat-header">
 | 
				
			||||||
 | 
					    <h2 class="chat-title">{{ channelName }}</h2>
 | 
				
			||||||
 | 
					    <div class="chat-actions">
 | 
				
			||||||
 | 
					      <BaseButton
 | 
				
			||||||
 | 
					        variant="ghost"
 | 
				
			||||||
 | 
					        size="sm"
 | 
				
			||||||
 | 
					        @click="$emit('search')"
 | 
				
			||||||
 | 
					        aria-label="Search messages"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        🔍
 | 
				
			||||||
 | 
					      </BaseButton>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </header>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  channelName: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineEmits<{
 | 
				
			||||||
 | 
					  search: []
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.chat-header {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  padding: 1rem 1.5rem;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  min-height: 64px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chat-title {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.chat-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .chat-header {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					    border-bottom-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .chat-title {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										106
									
								
								frontend-vue/src/components/chat/FileAttachment.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								frontend-vue/src/components/chat/FileAttachment.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="file-attachment">
 | 
				
			||||||
 | 
					    <!-- Image files -->
 | 
				
			||||||
 | 
					    <ImageMessage 
 | 
				
			||||||
 | 
					      v-if="isImageFile" 
 | 
				
			||||||
 | 
					      :file="file" 
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Audio/voice files -->
 | 
				
			||||||
 | 
					    <VoiceMessage 
 | 
				
			||||||
 | 
					      v-else-if="isAudioFile" 
 | 
				
			||||||
 | 
					      :file="file" 
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Other files -->
 | 
				
			||||||
 | 
					    <FileMessage 
 | 
				
			||||||
 | 
					      v-else 
 | 
				
			||||||
 | 
					      :file="file" 
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { computed } from 'vue'
 | 
				
			||||||
 | 
					import ImageMessage from './ImageMessage.vue'
 | 
				
			||||||
 | 
					import VoiceMessage from './VoiceMessage.vue'
 | 
				
			||||||
 | 
					import FileMessage from './FileMessage.vue'
 | 
				
			||||||
 | 
					import type { FileAttachment } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  file: FileAttachment
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fileExtension = computed(() => {
 | 
				
			||||||
 | 
					  return props.file.original_name.split('.').pop()?.toLowerCase() || ''
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isImageFile = computed(() => {
 | 
				
			||||||
 | 
					  const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
 | 
				
			||||||
 | 
					  return imageExtensions.includes(fileExtension.value)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isAudioFile = computed(() => {
 | 
				
			||||||
 | 
					  const audioExtensions = ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a']
 | 
				
			||||||
 | 
					  return audioExtensions.includes(fileExtension.value)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.file-attachment {
 | 
				
			||||||
 | 
					  margin: 0.25rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-link {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  padding: 0.5rem 0.75rem;
 | 
				
			||||||
 | 
					  background: #f3f4f6;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-link:hover {
 | 
				
			||||||
 | 
					  background: #e5e7eb;
 | 
				
			||||||
 | 
					  border-color: #9ca3af;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-name {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-size {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  font-weight: 400;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .file-link {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-link:hover {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					    border-color: #6b7280;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-size {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										458
									
								
								frontend-vue/src/components/chat/FileMessage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										458
									
								
								frontend-vue/src/components/chat/FileMessage.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,458 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="file-message">
 | 
				
			||||||
 | 
					    <div 
 | 
				
			||||||
 | 
					      class="file-container"
 | 
				
			||||||
 | 
					      @click="handleFileClick"
 | 
				
			||||||
 | 
					      :class="{ 'clickable': isPreviewable }"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div class="file-icon">
 | 
				
			||||||
 | 
					        <Icon :name="fileIcon" size="md" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="file-info">
 | 
				
			||||||
 | 
					        <div class="file-name">{{ file.original_name }}</div>
 | 
				
			||||||
 | 
					        <div class="file-meta">
 | 
				
			||||||
 | 
					          <span class="file-size">{{ formatFileSize(file.file_size) }}</span>
 | 
				
			||||||
 | 
					          <span class="file-type">{{ file.file_type }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div v-if="isPreviewable" class="preview-hint">
 | 
				
			||||||
 | 
					          Click to preview
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <button 
 | 
				
			||||||
 | 
					        @click.stop="downloadFile"
 | 
				
			||||||
 | 
					        class="download-button"
 | 
				
			||||||
 | 
					        title="Download"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Icon name="download" size="sm" />
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- File preview modal -->
 | 
				
			||||||
 | 
					    <teleport to="body">
 | 
				
			||||||
 | 
					      <div 
 | 
				
			||||||
 | 
					        v-if="showPreview" 
 | 
				
			||||||
 | 
					        class="file-modal"
 | 
				
			||||||
 | 
					        @click="showPreview = false"
 | 
				
			||||||
 | 
					        @keydown.escape="showPreview = false"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="modal-content" @click.stop>
 | 
				
			||||||
 | 
					          <div class="modal-header">
 | 
				
			||||||
 | 
					            <h3>{{ file.original_name }}</h3>
 | 
				
			||||||
 | 
					            <button @click="showPreview = false" class="close-button">
 | 
				
			||||||
 | 
					              <Icon name="x" size="sm" />
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <div class="preview-container">
 | 
				
			||||||
 | 
					            <!-- Text preview -->
 | 
				
			||||||
 | 
					            <div v-if="isTextFile && previewContent" class="text-preview">
 | 
				
			||||||
 | 
					              <pre>{{ previewContent }}</pre>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <!-- PDF preview -->
 | 
				
			||||||
 | 
					            <iframe 
 | 
				
			||||||
 | 
					              v-else-if="isPdfFile" 
 | 
				
			||||||
 | 
					              :src="fileUrl"
 | 
				
			||||||
 | 
					              class="pdf-preview"
 | 
				
			||||||
 | 
					            ></iframe>
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            <!-- Generic file info -->
 | 
				
			||||||
 | 
					            <div v-else class="file-details">
 | 
				
			||||||
 | 
					              <Icon :name="fileIcon" size="xl" />
 | 
				
			||||||
 | 
					              <p>Cannot preview this file type</p>
 | 
				
			||||||
 | 
					              <button @click="downloadFile" class="download-file-button">
 | 
				
			||||||
 | 
					                <Icon name="download" size="sm" />
 | 
				
			||||||
 | 
					                Download File
 | 
				
			||||||
 | 
					              </button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </teleport>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import Icon from '@/components/base/Icon.vue'
 | 
				
			||||||
 | 
					import type { FileAttachment } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  file: FileAttachment
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const showPreview = ref(false)
 | 
				
			||||||
 | 
					const previewContent = ref<string>('')
 | 
				
			||||||
 | 
					const loading = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fileExtension = computed(() => {
 | 
				
			||||||
 | 
					  return props.file.original_name.split('.').pop()?.toLowerCase() || ''
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const fileIcon = computed(() => {
 | 
				
			||||||
 | 
					  const ext = fileExtension.value
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (['pdf'].includes(ext)) {
 | 
				
			||||||
 | 
					    return 'file-text'
 | 
				
			||||||
 | 
					  } else if (['doc', 'docx'].includes(ext)) {
 | 
				
			||||||
 | 
					    return 'file-text'
 | 
				
			||||||
 | 
					  } else if (['xls', 'xlsx'].includes(ext)) {
 | 
				
			||||||
 | 
					    return 'table'
 | 
				
			||||||
 | 
					  } else if (['zip', 'rar', '7z'].includes(ext)) {
 | 
				
			||||||
 | 
					    return 'archive'
 | 
				
			||||||
 | 
					  } else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
 | 
				
			||||||
 | 
					    return 'file-text'
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    return 'file'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isTextFile = computed(() => {
 | 
				
			||||||
 | 
					  const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
 | 
				
			||||||
 | 
					  return textExtensions.includes(fileExtension.value)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isPdfFile = computed(() => {
 | 
				
			||||||
 | 
					  return fileExtension.value === 'pdf'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isPreviewable = computed(() => {
 | 
				
			||||||
 | 
					  return isTextFile.value || isPdfFile.value
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleFileClick = async () => {
 | 
				
			||||||
 | 
					  if (!isPreviewable.value) {
 | 
				
			||||||
 | 
					    downloadFile()
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (isTextFile.value && !previewContent.value) {
 | 
				
			||||||
 | 
					    await loadTextPreview()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  showPreview.value = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loadTextPreview = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    loading.value = true
 | 
				
			||||||
 | 
					    const response = await fetch(fileUrl.value)
 | 
				
			||||||
 | 
					    const text = await response.text()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Limit preview size to prevent UI issues
 | 
				
			||||||
 | 
					    if (text.length > 50000) {
 | 
				
			||||||
 | 
					      previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      previewContent.value = text
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to load file preview:', error)
 | 
				
			||||||
 | 
					    previewContent.value = 'Error loading file preview'
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    loading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const downloadFile = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await fetch(fileUrl.value)
 | 
				
			||||||
 | 
					    const blob = await response.blob()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const blobUrl = URL.createObjectURL(blob)
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = blobUrl
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Clean up the blob URL after download
 | 
				
			||||||
 | 
					    setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to download file:', error)
 | 
				
			||||||
 | 
					    // Fallback to direct link
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = fileUrl.value
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.target = '_blank'
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formatFileSize = (bytes: number): string => {
 | 
				
			||||||
 | 
					  if (bytes === 0) return '0 B'
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const k = 1024
 | 
				
			||||||
 | 
					  const sizes = ['B', 'KB', 'MB', 'GB']
 | 
				
			||||||
 | 
					  const i = Math.floor(Math.log(bytes) / Math.log(k))
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.file-message {
 | 
				
			||||||
 | 
					  margin: 0.5rem 0;
 | 
				
			||||||
 | 
					  max-width: 400px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-container {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  background: #f3f4f6;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 0.75rem;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-container.clickable {
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-container.clickable:hover {
 | 
				
			||||||
 | 
					  background: #e5e7eb;
 | 
				
			||||||
 | 
					  border-color: #3b82f6;
 | 
				
			||||||
 | 
					  transform: translateY(-1px);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-icon {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  width: 48px;
 | 
				
			||||||
 | 
					  height: 48px;
 | 
				
			||||||
 | 
					  background: #3b82f6;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-info {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  min-width: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-name {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					  margin-bottom: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-meta {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.preview-hint {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #3b82f6;
 | 
				
			||||||
 | 
					  margin-top: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-button {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  width: 36px;
 | 
				
			||||||
 | 
					  height: 36px;
 | 
				
			||||||
 | 
					  background: transparent;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-button:hover {
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  border-color: #9ca3af;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Modal styles */
 | 
				
			||||||
 | 
					.file-modal {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  right: 0;
 | 
				
			||||||
 | 
					  bottom: 0;
 | 
				
			||||||
 | 
					  background: rgba(0, 0, 0, 0.7);
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  z-index: 1000;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-content {
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  max-width: 90vw;
 | 
				
			||||||
 | 
					  max-height: 90vh;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  cursor: default;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-header {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-header h3 {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.close-button {
 | 
				
			||||||
 | 
					  background: transparent;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  padding: 0.25rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.close-button:hover {
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.preview-container {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  overflow: auto;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-preview {
 | 
				
			||||||
 | 
					  max-height: 70vh;
 | 
				
			||||||
 | 
					  overflow: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.text-preview pre {
 | 
				
			||||||
 | 
					  white-space: pre-wrap;
 | 
				
			||||||
 | 
					  word-wrap: break-word;
 | 
				
			||||||
 | 
					  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  line-height: 1.4;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.pdf-preview {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 70vh;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-details {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  padding: 2rem;
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-file-button {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  background: #3b82f6;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  padding: 0.75rem 1.5rem;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  margin-top: 1rem;
 | 
				
			||||||
 | 
					  transition: background 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-file-button:hover {
 | 
				
			||||||
 | 
					  background: #2563eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .file-container {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-container.clickable:hover {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					    border-color: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-name {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-meta {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .preview-hint {
 | 
				
			||||||
 | 
					    color: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .download-button {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .download-button:hover {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #6b7280;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .modal-content {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .modal-header {
 | 
				
			||||||
 | 
					    border-bottom-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .modal-header h3 {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .close-button {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .close-button:hover {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .text-preview pre {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-details {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										256
									
								
								frontend-vue/src/components/chat/ImageMessage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								frontend-vue/src/components/chat/ImageMessage.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,256 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="image-message">
 | 
				
			||||||
 | 
					    <div 
 | 
				
			||||||
 | 
					      class="image-thumbnail"
 | 
				
			||||||
 | 
					      @click="showFullSize = true"
 | 
				
			||||||
 | 
					      :style="{ cursor: 'pointer' }"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <img 
 | 
				
			||||||
 | 
					        :src="imageUrl" 
 | 
				
			||||||
 | 
					        :alt="file.original_name"
 | 
				
			||||||
 | 
					        class="thumbnail"
 | 
				
			||||||
 | 
					        @error="imageError = true"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <div class="image-overlay">
 | 
				
			||||||
 | 
					        <Icon name="search" size="sm" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="image-info">
 | 
				
			||||||
 | 
					      <span class="image-name">{{ file.original_name }}</span>
 | 
				
			||||||
 | 
					      <span class="image-size">{{ formatFileSize(file.file_size) }}</span>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Full-size image modal -->
 | 
				
			||||||
 | 
					    <teleport to="body">
 | 
				
			||||||
 | 
					      <div 
 | 
				
			||||||
 | 
					        v-if="showFullSize" 
 | 
				
			||||||
 | 
					        class="image-modal"
 | 
				
			||||||
 | 
					        @click="showFullSize = false"
 | 
				
			||||||
 | 
					        @keydown.escape="showFullSize = false"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="modal-content" @click.stop>
 | 
				
			||||||
 | 
					          <img 
 | 
				
			||||||
 | 
					            :src="imageUrl" 
 | 
				
			||||||
 | 
					            :alt="file.original_name"
 | 
				
			||||||
 | 
					            class="full-image"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <div class="modal-actions">
 | 
				
			||||||
 | 
					            <button @click="downloadImage" class="action-button">
 | 
				
			||||||
 | 
					              <Icon name="download" size="sm" />
 | 
				
			||||||
 | 
					              Download
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button @click="showFullSize = false" class="action-button">
 | 
				
			||||||
 | 
					              <Icon name="x" size="sm" />
 | 
				
			||||||
 | 
					              Close
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </teleport>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import Icon from '@/components/base/Icon.vue'
 | 
				
			||||||
 | 
					import type { FileAttachment } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  file: FileAttachment
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const showFullSize = ref(false)
 | 
				
			||||||
 | 
					const imageError = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formatFileSize = (bytes: number): string => {
 | 
				
			||||||
 | 
					  if (bytes === 0) return '0 B'
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const k = 1024
 | 
				
			||||||
 | 
					  const sizes = ['B', 'KB', 'MB', 'GB']
 | 
				
			||||||
 | 
					  const i = Math.floor(Math.log(bytes) / Math.log(k))
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const downloadImage = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await fetch(imageUrl.value)
 | 
				
			||||||
 | 
					    const blob = await response.blob()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const blobUrl = URL.createObjectURL(blob)
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = blobUrl
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Clean up the blob URL after download
 | 
				
			||||||
 | 
					    setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to download image:', error)
 | 
				
			||||||
 | 
					    // Fallback to direct link
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = imageUrl.value
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.target = '_blank'
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Close modal on escape key
 | 
				
			||||||
 | 
					document.addEventListener('keydown', (e) => {
 | 
				
			||||||
 | 
					  if (e.key === 'Escape' && showFullSize.value) {
 | 
				
			||||||
 | 
					    showFullSize.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.image-message {
 | 
				
			||||||
 | 
					  margin: 0.5rem 0;
 | 
				
			||||||
 | 
					  max-width: 300px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-thumbnail {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  border: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-thumbnail:hover {
 | 
				
			||||||
 | 
					  border-color: #3b82f6;
 | 
				
			||||||
 | 
					  transform: scale(1.02);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-thumbnail:hover .image-overlay {
 | 
				
			||||||
 | 
					  opacity: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.thumbnail {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: auto;
 | 
				
			||||||
 | 
					  max-height: 200px;
 | 
				
			||||||
 | 
					  object-fit: cover;
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-overlay {
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 50%;
 | 
				
			||||||
 | 
					  left: 50%;
 | 
				
			||||||
 | 
					  transform: translate(-50%, -50%);
 | 
				
			||||||
 | 
					  background: rgba(0, 0, 0, 0.7);
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  opacity: 0;
 | 
				
			||||||
 | 
					  transition: opacity 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-info {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-name {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.image-size {
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  margin-left: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Modal styles */
 | 
				
			||||||
 | 
					.image-modal {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  right: 0;
 | 
				
			||||||
 | 
					  bottom: 0;
 | 
				
			||||||
 | 
					  background: rgba(0, 0, 0, 0.9);
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  z-index: 1000;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-content {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  max-width: 90vw;
 | 
				
			||||||
 | 
					  max-height: 90vh;
 | 
				
			||||||
 | 
					  cursor: default;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.full-image {
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  max-height: 80vh;
 | 
				
			||||||
 | 
					  object-fit: contain;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.modal-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  margin-top: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.action-button {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  background: rgba(255, 255, 255, 0.1);
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  border: 1px solid rgba(255, 255, 255, 0.2);
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  padding: 0.5rem 1rem;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.action-button:hover {
 | 
				
			||||||
 | 
					  background: rgba(255, 255, 255, 0.2);
 | 
				
			||||||
 | 
					  border-color: rgba(255, 255, 255, 0.3);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .image-thumbnail {
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .image-thumbnail:hover {
 | 
				
			||||||
 | 
					    border-color: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .image-info {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .image-name {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .image-size {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										73
									
								
								frontend-vue/src/components/chat/InputActions.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend-vue/src/components/chat/InputActions.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="input-actions">
 | 
				
			||||||
 | 
					    <BaseButton
 | 
				
			||||||
 | 
					      variant="ghost"
 | 
				
			||||||
 | 
					      size="sm"
 | 
				
			||||||
 | 
					      @click="$emit('file-upload')"
 | 
				
			||||||
 | 
					      aria-label="Upload file"
 | 
				
			||||||
 | 
					      :disabled="disabled"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      📎
 | 
				
			||||||
 | 
					    </BaseButton>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <BaseButton
 | 
				
			||||||
 | 
					      variant="ghost"
 | 
				
			||||||
 | 
					      size="sm"
 | 
				
			||||||
 | 
					      @click="$emit('camera')"
 | 
				
			||||||
 | 
					      aria-label="Take photo"
 | 
				
			||||||
 | 
					      :disabled="disabled"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      📷
 | 
				
			||||||
 | 
					    </BaseButton>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <BaseButton
 | 
				
			||||||
 | 
					      variant="ghost"
 | 
				
			||||||
 | 
					      size="sm"
 | 
				
			||||||
 | 
					      @click="$emit('voice')"
 | 
				
			||||||
 | 
					      aria-label="Record voice message"
 | 
				
			||||||
 | 
					      :disabled="disabled"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      🎤
 | 
				
			||||||
 | 
					    </BaseButton>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <BaseButton
 | 
				
			||||||
 | 
					      variant="primary"
 | 
				
			||||||
 | 
					      size="sm"
 | 
				
			||||||
 | 
					      @click="$emit('send')"
 | 
				
			||||||
 | 
					      :disabled="!canSend || disabled"
 | 
				
			||||||
 | 
					      aria-label="Send message"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      Send
 | 
				
			||||||
 | 
					    </BaseButton>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  disabled?: boolean
 | 
				
			||||||
 | 
					  canSend?: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					withDefaults(defineProps<Props>(), {
 | 
				
			||||||
 | 
					  disabled: false,
 | 
				
			||||||
 | 
					  canSend: false
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineEmits<{
 | 
				
			||||||
 | 
					  'file-upload': []
 | 
				
			||||||
 | 
					  'camera': []
 | 
				
			||||||
 | 
					  'voice': []
 | 
				
			||||||
 | 
					  'send': []
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.input-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										97
									
								
								frontend-vue/src/components/chat/MessageInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								frontend-vue/src/components/chat/MessageInput.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,97 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="message-input-container">
 | 
				
			||||||
 | 
					    <div class="message-input">
 | 
				
			||||||
 | 
					      <BaseTextarea
 | 
				
			||||||
 | 
					        v-model="messageText"
 | 
				
			||||||
 | 
					        placeholder="Type a message..."
 | 
				
			||||||
 | 
					        :rows="1"
 | 
				
			||||||
 | 
					        auto-resize
 | 
				
			||||||
 | 
					        @keydown="handleInputKeydown"
 | 
				
			||||||
 | 
					        @submit="handleSubmit"
 | 
				
			||||||
 | 
					        ref="textareaRef"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <InputActions
 | 
				
			||||||
 | 
					        :disabled="isDisabled"
 | 
				
			||||||
 | 
					        :can-send="canSend"
 | 
				
			||||||
 | 
					        @file-upload="$emit('file-upload')"
 | 
				
			||||||
 | 
					        @camera="$emit('camera')"
 | 
				
			||||||
 | 
					        @voice="$emit('voice')"
 | 
				
			||||||
 | 
					        @send="handleSubmit"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
 | 
					import BaseTextarea from '@/components/base/BaseTextarea.vue'
 | 
				
			||||||
 | 
					import InputActions from './InputActions.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  'send-message': [content: string]
 | 
				
			||||||
 | 
					  'file-upload': []
 | 
				
			||||||
 | 
					  'camera': []
 | 
				
			||||||
 | 
					  'voice': []
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const { playWater, playSent } = useAudio()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const messageText = ref('')
 | 
				
			||||||
 | 
					const textareaRef = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const currentChannelId = computed(() => appStore.currentChannelId)
 | 
				
			||||||
 | 
					const isDisabled = computed(() => !currentChannelId.value)
 | 
				
			||||||
 | 
					const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleInputKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  if (event.key === 'Enter' && !event.shiftKey) {
 | 
				
			||||||
 | 
					    event.preventDefault()
 | 
				
			||||||
 | 
					    handleSubmit()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleSubmit = () => {
 | 
				
			||||||
 | 
					  if (!canSend.value) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const content = messageText.value.trim()
 | 
				
			||||||
 | 
					  messageText.value = ''
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  playWater()
 | 
				
			||||||
 | 
					  emit('send-message', content)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const focus = () => {
 | 
				
			||||||
 | 
					  textareaRef.value?.focus()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({
 | 
				
			||||||
 | 
					  focus
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.message-input-container {
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-top: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.message-input {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .message-input-container {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					    border-top-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										228
									
								
								frontend-vue/src/components/chat/MessageItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								frontend-vue/src/components/chat/MessageItem.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										250
									
								
								frontend-vue/src/components/chat/MessagesContainer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								frontend-vue/src/components/chat/MessagesContainer.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,250 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div 
 | 
				
			||||||
 | 
					    class="messages-container" 
 | 
				
			||||||
 | 
					    ref="containerRef"
 | 
				
			||||||
 | 
					    @keydown="handleKeydown"
 | 
				
			||||||
 | 
					    tabindex="0"
 | 
				
			||||||
 | 
					    role="list"
 | 
				
			||||||
 | 
					    :aria-label="messagesAriaLabel"
 | 
				
			||||||
 | 
					    :aria-description="navigationHint"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <div class="messages" role="presentation">
 | 
				
			||||||
 | 
					      <!-- Regular Messages -->
 | 
				
			||||||
 | 
					      <MessageItem
 | 
				
			||||||
 | 
					        v-for="(message, index) in messages"
 | 
				
			||||||
 | 
					        :key="message.id"
 | 
				
			||||||
 | 
					        :message="message"
 | 
				
			||||||
 | 
					        :tabindex="index === focusedMessageIndex ? 0 : -1"
 | 
				
			||||||
 | 
					        :data-message-index="index"
 | 
				
			||||||
 | 
					        @focus="focusedMessageIndex = index"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <!-- Unsent Messages -->
 | 
				
			||||||
 | 
					      <MessageItem
 | 
				
			||||||
 | 
					        v-for="(unsentMsg, index) in unsentMessages"
 | 
				
			||||||
 | 
					        :key="unsentMsg.id"
 | 
				
			||||||
 | 
					        :message="unsentMsg"
 | 
				
			||||||
 | 
					        :is-unsent="true"
 | 
				
			||||||
 | 
					        :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
 | 
				
			||||||
 | 
					        :data-message-index="messages.length + index"
 | 
				
			||||||
 | 
					        @focus="focusedMessageIndex = messages.length + index"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted, nextTick, watch, computed } from 'vue'
 | 
				
			||||||
 | 
					import MessageItem from './MessageItem.vue'
 | 
				
			||||||
 | 
					import type { ExtendedMessage, UnsentMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  messages: ExtendedMessage[]
 | 
				
			||||||
 | 
					  unsentMessages: UnsentMessage[]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const containerRef = ref<HTMLElement>()
 | 
				
			||||||
 | 
					const focusedMessageIndex = ref(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Combined messages array for easier navigation
 | 
				
			||||||
 | 
					const allMessages = computed(() => [...props.messages, ...props.unsentMessages])
 | 
				
			||||||
 | 
					const totalMessages = computed(() => allMessages.value.length)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ARIA labels for screen readers
 | 
				
			||||||
 | 
					const messagesAriaLabel = computed(() => {
 | 
				
			||||||
 | 
					  const total = totalMessages.value
 | 
				
			||||||
 | 
					  const current = focusedMessageIndex.value + 1
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (total === 0) {
 | 
				
			||||||
 | 
					    return 'Messages list, no messages'
 | 
				
			||||||
 | 
					  } else if (total === 1) {
 | 
				
			||||||
 | 
					    return 'Messages list, 1 message'
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 messages, Home/End for first/last, Enter to select'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Keyboard navigation
 | 
				
			||||||
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  if (totalMessages.value === 0) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  let newIndex = focusedMessageIndex.value
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  switch (event.key) {
 | 
				
			||||||
 | 
					    case 'ArrowUp':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = Math.max(0, focusedMessageIndex.value - 1)
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'ArrowDown':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'PageUp':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = Math.max(0, focusedMessageIndex.value - 10)
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'PageDown':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'Home':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = 0
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'End':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      newIndex = totalMessages.value - 1
 | 
				
			||||||
 | 
					      break
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    case 'Enter':
 | 
				
			||||||
 | 
					    case ' ':
 | 
				
			||||||
 | 
					      event.preventDefault()
 | 
				
			||||||
 | 
					      selectCurrentMessage()
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    default:
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (newIndex !== focusedMessageIndex.value) {
 | 
				
			||||||
 | 
					    focusMessage(newIndex)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const focusMessage = (index: number) => {
 | 
				
			||||||
 | 
					  focusedMessageIndex.value = index
 | 
				
			||||||
 | 
					  nextTick(() => {
 | 
				
			||||||
 | 
					    const messageElement = containerRef.value?.querySelector(`[data-message-index="${index}"]`) as HTMLElement
 | 
				
			||||||
 | 
					    if (messageElement) {
 | 
				
			||||||
 | 
					      messageElement.focus()
 | 
				
			||||||
 | 
					      messageElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const selectCurrentMessage = () => {
 | 
				
			||||||
 | 
					  const currentMessage = allMessages.value[focusedMessageIndex.value]
 | 
				
			||||||
 | 
					  if (currentMessage) {
 | 
				
			||||||
 | 
					    emit('message-selected', currentMessage, focusedMessageIndex.value)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Method to focus a specific message (for external use, like search results)
 | 
				
			||||||
 | 
					const focusMessageById = (messageId: string | number) => {
 | 
				
			||||||
 | 
					  const index = allMessages.value.findIndex(msg => msg.id === messageId)
 | 
				
			||||||
 | 
					  if (index !== -1) {
 | 
				
			||||||
 | 
					    focusMessage(index)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const scrollToBottom = () => {
 | 
				
			||||||
 | 
					  nextTick(() => {
 | 
				
			||||||
 | 
					    if (containerRef.value) {
 | 
				
			||||||
 | 
					      containerRef.value.scrollTop = containerRef.value.scrollHeight
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Watch for new messages and auto-scroll
 | 
				
			||||||
 | 
					watch(() => [props.messages.length, props.unsentMessages.length], () => {
 | 
				
			||||||
 | 
					  // When new messages arrive, focus the last message and scroll to bottom
 | 
				
			||||||
 | 
					  if (totalMessages.value > 0) {
 | 
				
			||||||
 | 
					    focusedMessageIndex.value = totalMessages.value - 1
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  scrollToBottom()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Reset focus when messages change significantly
 | 
				
			||||||
 | 
					watch(() => totalMessages.value, (newTotal) => {
 | 
				
			||||||
 | 
					  if (focusedMessageIndex.value >= newTotal) {
 | 
				
			||||||
 | 
					    focusedMessageIndex.value = Math.max(0, newTotal - 1)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  scrollToBottom()
 | 
				
			||||||
 | 
					  // Focus the last message on mount
 | 
				
			||||||
 | 
					  if (totalMessages.value > 0) {
 | 
				
			||||||
 | 
					    focusedMessageIndex.value = totalMessages.value - 1
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({
 | 
				
			||||||
 | 
					  scrollToBottom,
 | 
				
			||||||
 | 
					  focusMessageById
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.messages-container {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  background: #fafafa;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.messages-container:focus {
 | 
				
			||||||
 | 
					  outline: 2px solid #3b82f6;
 | 
				
			||||||
 | 
					  outline-offset: -2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.messages {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  min-height: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Scrollbar styling */
 | 
				
			||||||
 | 
					.messages-container::-webkit-scrollbar {
 | 
				
			||||||
 | 
					  width: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.messages-container::-webkit-scrollbar-track {
 | 
				
			||||||
 | 
					  background: #f1f5f9;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.messages-container::-webkit-scrollbar-thumb {
 | 
				
			||||||
 | 
					  background: #cbd5e1;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.messages-container::-webkit-scrollbar-thumb:hover {
 | 
				
			||||||
 | 
					  background: #94a3b8;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .messages-container {
 | 
				
			||||||
 | 
					    background: #111827;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .messages-container:focus {
 | 
				
			||||||
 | 
					    outline-color: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .messages-container::-webkit-scrollbar-track {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .messages-container::-webkit-scrollbar-thumb {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .messages-container::-webkit-scrollbar-thumb:hover {
 | 
				
			||||||
 | 
					    background: #6b7280;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										318
									
								
								frontend-vue/src/components/chat/VoiceMessage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										318
									
								
								frontend-vue/src/components/chat/VoiceMessage.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,318 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="voice-message">
 | 
				
			||||||
 | 
					    <div class="voice-player">
 | 
				
			||||||
 | 
					      <button 
 | 
				
			||||||
 | 
					        @click="togglePlayback"
 | 
				
			||||||
 | 
					        class="play-button"
 | 
				
			||||||
 | 
					        :disabled="loading"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="voice-info">
 | 
				
			||||||
 | 
					        <div class="voice-waveform">
 | 
				
			||||||
 | 
					          <div class="progress-bar">
 | 
				
			||||||
 | 
					            <div 
 | 
				
			||||||
 | 
					              class="progress" 
 | 
				
			||||||
 | 
					              :style="{ width: `${progress}%` }"
 | 
				
			||||||
 | 
					            ></div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="voice-meta" aria-live="off">
 | 
				
			||||||
 | 
					          <span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
 | 
				
			||||||
 | 
					          <span class="file-size">{{ formatFileSize(file.file_size) }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <button 
 | 
				
			||||||
 | 
					        @click="downloadVoice"
 | 
				
			||||||
 | 
					        class="download-button"
 | 
				
			||||||
 | 
					        title="Download"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Icon name="download" size="sm" />
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="voice-filename">
 | 
				
			||||||
 | 
					      {{ file.original_name }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed, onUnmounted } from 'vue'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import Icon from '@/components/base/Icon.vue'
 | 
				
			||||||
 | 
					import type { FileAttachment } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  file: FileAttachment
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isPlaying = ref(false)
 | 
				
			||||||
 | 
					const loading = ref(false)
 | 
				
			||||||
 | 
					const currentTime = ref(0)
 | 
				
			||||||
 | 
					const duration = ref(0)
 | 
				
			||||||
 | 
					let audio: HTMLAudioElement | null = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const progress = computed(() => {
 | 
				
			||||||
 | 
					  return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const togglePlayback = async () => {
 | 
				
			||||||
 | 
					  if (!audio) {
 | 
				
			||||||
 | 
					    await initAudio()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (!audio) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (isPlaying.value) {
 | 
				
			||||||
 | 
					    audio.pause()
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    await audio.play()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const initAudio = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    loading.value = true
 | 
				
			||||||
 | 
					    audio = new Audio(audioUrl.value)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    audio.addEventListener('loadedmetadata', () => {
 | 
				
			||||||
 | 
					      const audioDuration = audio!.duration
 | 
				
			||||||
 | 
					      duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    audio.addEventListener('timeupdate', () => {
 | 
				
			||||||
 | 
					      currentTime.value = audio!.currentTime
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    audio.addEventListener('play', () => {
 | 
				
			||||||
 | 
					      isPlaying.value = true
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    audio.addEventListener('pause', () => {
 | 
				
			||||||
 | 
					      isPlaying.value = false
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    audio.addEventListener('ended', () => {
 | 
				
			||||||
 | 
					      isPlaying.value = false
 | 
				
			||||||
 | 
					      currentTime.value = 0
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    await audio.load()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to load audio:', error)
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    loading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formatTime = (seconds: number): string => {
 | 
				
			||||||
 | 
					  if (!isFinite(seconds) || isNaN(seconds)) {
 | 
				
			||||||
 | 
					    return '0:00'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const minutes = Math.floor(seconds / 60)
 | 
				
			||||||
 | 
					  const remainingSeconds = Math.floor(seconds % 60)
 | 
				
			||||||
 | 
					  return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formatFileSize = (bytes: number): string => {
 | 
				
			||||||
 | 
					  if (bytes === 0) return '0 B'
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const k = 1024
 | 
				
			||||||
 | 
					  const sizes = ['B', 'KB', 'MB', 'GB']
 | 
				
			||||||
 | 
					  const i = Math.floor(Math.log(bytes) / Math.log(k))
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const downloadVoice = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await fetch(audioUrl.value)
 | 
				
			||||||
 | 
					    const blob = await response.blob()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const blobUrl = URL.createObjectURL(blob)
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = blobUrl
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Clean up the blob URL after download
 | 
				
			||||||
 | 
					    setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to download voice message:', error)
 | 
				
			||||||
 | 
					    // Fallback to direct link
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = audioUrl.value
 | 
				
			||||||
 | 
					    link.download = props.file.original_name
 | 
				
			||||||
 | 
					    link.target = '_blank'
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Cleanup on component unmount
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  if (audio) {
 | 
				
			||||||
 | 
					    audio.pause()
 | 
				
			||||||
 | 
					    audio.src = ''
 | 
				
			||||||
 | 
					    audio = null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.voice-message {
 | 
				
			||||||
 | 
					  margin: 0.5rem 0;
 | 
				
			||||||
 | 
					  max-width: 350px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.voice-player {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  background: #f3f4f6;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.play-button {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  width: 40px;
 | 
				
			||||||
 | 
					  height: 40px;
 | 
				
			||||||
 | 
					  background: #3b82f6;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.play-button:hover:not(:disabled) {
 | 
				
			||||||
 | 
					  background: #2563eb;
 | 
				
			||||||
 | 
					  transform: scale(1.05);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.play-button:disabled {
 | 
				
			||||||
 | 
					  background: #9ca3af;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.voice-info {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  min-width: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.voice-waveform {
 | 
				
			||||||
 | 
					  margin-bottom: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.progress-bar {
 | 
				
			||||||
 | 
					  height: 4px;
 | 
				
			||||||
 | 
					  background: #e5e7eb;
 | 
				
			||||||
 | 
					  border-radius: 2px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.progress {
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  background: #3b82f6;
 | 
				
			||||||
 | 
					  transition: width 0.1s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.voice-meta {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.duration {
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-size {
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-button {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  width: 32px;
 | 
				
			||||||
 | 
					  height: 32px;
 | 
				
			||||||
 | 
					  background: transparent;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.download-button:hover {
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  border-color: #9ca3af;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.voice-filename {
 | 
				
			||||||
 | 
					  margin-top: 0.5rem;
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .voice-player {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .progress-bar {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .progress {
 | 
				
			||||||
 | 
					    background: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .duration {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .file-size {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .download-button {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .download-button:hover {
 | 
				
			||||||
 | 
					    background: #4b5563;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #6b7280;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .voice-filename {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.6);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										526
									
								
								frontend-vue/src/components/dialogs/CameraCaptureDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										526
									
								
								frontend-vue/src/components/dialogs/CameraCaptureDialog.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										465
									
								
								frontend-vue/src/components/dialogs/ChannelInfoDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										465
									
								
								frontend-vue/src/components/dialogs/ChannelInfoDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,465 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="channel-info-dialog">
 | 
				
			||||||
 | 
					    <div class="info-section">
 | 
				
			||||||
 | 
					      <BaseInput
 | 
				
			||||||
 | 
					        v-model="channelName"
 | 
				
			||||||
 | 
					        label="Channel name"
 | 
				
			||||||
 | 
					        placeholder="Enter channel name"
 | 
				
			||||||
 | 
					        ref="nameInput"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <BaseInput
 | 
				
			||||||
 | 
					        v-model="channelIdDisplay"
 | 
				
			||||||
 | 
					        label="Channel ID (for API use)"
 | 
				
			||||||
 | 
					        readonly
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="actions-section">
 | 
				
			||||||
 | 
					      <div class="action-group">
 | 
				
			||||||
 | 
					        <h3>Channel Actions</h3>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          @click="makeDefault"
 | 
				
			||||||
 | 
					          variant="secondary"
 | 
				
			||||||
 | 
					          :disabled="isDefault"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {{ isDefault ? 'Already Default' : 'Make Default Channel' }}
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          @click="showMergeDialog = true"
 | 
				
			||||||
 | 
					          variant="secondary"
 | 
				
			||||||
 | 
					          :disabled="availableChannels.length === 0"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Merge Channel
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          @click="showDeleteConfirm = true"
 | 
				
			||||||
 | 
					          variant="danger"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Delete Channel
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="dialog-actions">
 | 
				
			||||||
 | 
					      <BaseButton @click="cancel" variant="secondary">
 | 
				
			||||||
 | 
					        Cancel
 | 
				
			||||||
 | 
					      </BaseButton>
 | 
				
			||||||
 | 
					      <BaseButton @click="save" :loading="saving">
 | 
				
			||||||
 | 
					        Save Changes
 | 
				
			||||||
 | 
					      </BaseButton>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Merge Channel Dialog -->
 | 
				
			||||||
 | 
					    <BaseDialog v-model:show="showMergeDialog" title="Merge Channel" size="md">
 | 
				
			||||||
 | 
					      <div class="merge-dialog">
 | 
				
			||||||
 | 
					        <p class="merge-warning">
 | 
				
			||||||
 | 
					          This will move all messages from "{{ channel.name }}" into the selected target channel, 
 | 
				
			||||||
 | 
					          then delete this channel. This action cannot be undone.
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="merge-form">
 | 
				
			||||||
 | 
					          <label for="target-channel">Merge into:</label>
 | 
				
			||||||
 | 
					          <select 
 | 
				
			||||||
 | 
					            id="target-channel" 
 | 
				
			||||||
 | 
					            v-model="selectedTargetChannel" 
 | 
				
			||||||
 | 
					            class="target-select"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <option value="">Select target channel...</option>
 | 
				
			||||||
 | 
					            <option 
 | 
				
			||||||
 | 
					              v-for="ch in availableChannels" 
 | 
				
			||||||
 | 
					              :key="ch.id" 
 | 
				
			||||||
 | 
					              :value="ch.id"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {{ ch.name }}
 | 
				
			||||||
 | 
					            </option>
 | 
				
			||||||
 | 
					          </select>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="merge-actions">
 | 
				
			||||||
 | 
					          <BaseButton @click="showMergeDialog = false" variant="secondary">
 | 
				
			||||||
 | 
					            Cancel
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="performMerge" 
 | 
				
			||||||
 | 
					            variant="danger"
 | 
				
			||||||
 | 
					            :disabled="!selectedTargetChannel"
 | 
				
			||||||
 | 
					            :loading="merging"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Merge Channels
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </BaseDialog>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Delete Confirmation Dialog -->
 | 
				
			||||||
 | 
					    <BaseDialog v-model:show="showDeleteConfirm" title="Delete Channel" size="md">
 | 
				
			||||||
 | 
					      <div class="delete-dialog">
 | 
				
			||||||
 | 
					        <p class="delete-warning">
 | 
				
			||||||
 | 
					          Are you sure you want to delete "{{ channel.name }}"? 
 | 
				
			||||||
 | 
					          This will permanently delete all messages in this channel.
 | 
				
			||||||
 | 
					          This action cannot be undone.
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="delete-actions">
 | 
				
			||||||
 | 
					          <BaseButton @click="showDeleteConfirm = false" variant="secondary">
 | 
				
			||||||
 | 
					            Cancel
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="performDelete" 
 | 
				
			||||||
 | 
					            variant="danger"
 | 
				
			||||||
 | 
					            :loading="deleting"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Delete Channel
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </BaseDialog>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import { syncService } from '@/services/sync'
 | 
				
			||||||
 | 
					import BaseInput from '@/components/base/BaseInput.vue'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					import BaseDialog from '@/components/base/BaseDialog.vue'
 | 
				
			||||||
 | 
					import type { Channel } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  channel: Channel
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  close: []
 | 
				
			||||||
 | 
					  'channel-updated': [channel: Channel]
 | 
				
			||||||
 | 
					  'channel-deleted': [channelId: number]
 | 
				
			||||||
 | 
					  'channel-merged': [sourceId: number, targetId: number]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Form state
 | 
				
			||||||
 | 
					const channelName = ref(props.channel.name)
 | 
				
			||||||
 | 
					const channelIdDisplay = ref(props.channel.id.toString())
 | 
				
			||||||
 | 
					const saving = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Dialog states
 | 
				
			||||||
 | 
					const showMergeDialog = ref(false)
 | 
				
			||||||
 | 
					const showDeleteConfirm = ref(false)
 | 
				
			||||||
 | 
					const selectedTargetChannel = ref<number | null>(null)
 | 
				
			||||||
 | 
					const merging = ref(false)
 | 
				
			||||||
 | 
					const deleting = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Input ref for focus
 | 
				
			||||||
 | 
					const nameInput = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Computed properties
 | 
				
			||||||
 | 
					const isDefault = computed(() => 
 | 
				
			||||||
 | 
					  appStore.settings.defaultChannelId === props.channel.id
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const availableChannels = computed(() => 
 | 
				
			||||||
 | 
					  appStore.channels.filter(ch => ch.id !== props.channel.id)
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Actions
 | 
				
			||||||
 | 
					const makeDefault = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await appStore.updateSettings({ defaultChannelId: props.channel.id })
 | 
				
			||||||
 | 
					    toastStore.success(`${props.channel.name} is now the default channel`)
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to set default channel:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to set default channel')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const save = async () => {
 | 
				
			||||||
 | 
					  if (!channelName.value.trim()) {
 | 
				
			||||||
 | 
					    toastStore.error('Channel name is required')
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    saving.value = true
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Try online update first
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await apiService.updateChannel(props.channel.id, channelName.value.trim())
 | 
				
			||||||
 | 
					      // Update local store
 | 
				
			||||||
 | 
					      const updatedChannel = { ...props.channel, name: channelName.value.trim() }
 | 
				
			||||||
 | 
					      const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
 | 
				
			||||||
 | 
					      if (channelIndex !== -1) {
 | 
				
			||||||
 | 
					        appStore.channels[channelIndex] = updatedChannel
 | 
				
			||||||
 | 
					        await appStore.saveState()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      emit('channel-updated', updatedChannel)
 | 
				
			||||||
 | 
					      toastStore.success('Channel updated successfully')
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      // Offline fallback - update locally only
 | 
				
			||||||
 | 
					      console.log('Offline mode: updating channel locally')
 | 
				
			||||||
 | 
					      const updatedChannel = { ...props.channel, name: channelName.value.trim() }
 | 
				
			||||||
 | 
					      const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
 | 
				
			||||||
 | 
					      if (channelIndex !== -1) {
 | 
				
			||||||
 | 
					        appStore.channels[channelIndex] = updatedChannel
 | 
				
			||||||
 | 
					        await appStore.saveState()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      emit('channel-updated', updatedChannel)
 | 
				
			||||||
 | 
					      toastStore.success('Channel updated locally (will sync when online)')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    emit('close')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to update channel:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to update channel')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    saving.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const performMerge = async () => {
 | 
				
			||||||
 | 
					  if (!selectedTargetChannel.value) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    merging.value = true
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Try online merge first
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await apiService.mergeChannels(props.channel.id, selectedTargetChannel.value)
 | 
				
			||||||
 | 
					      // Remove source channel from local store
 | 
				
			||||||
 | 
					      appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
 | 
				
			||||||
 | 
					      // Clear messages for the merged channel
 | 
				
			||||||
 | 
					      delete appStore.messages[props.channel.id]
 | 
				
			||||||
 | 
					      await appStore.saveState()
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      emit('channel-merged', props.channel.id, selectedTargetChannel.value)
 | 
				
			||||||
 | 
					      toastStore.success('Channels merged successfully')
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Switch to target channel if we were on the source channel
 | 
				
			||||||
 | 
					      if (appStore.currentChannelId === props.channel.id) {
 | 
				
			||||||
 | 
					        await appStore.setCurrentChannel(selectedTargetChannel.value)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      // For merge, we can't do offline fallback easily since it affects multiple channels
 | 
				
			||||||
 | 
					      console.error('Failed to merge channels:', error)
 | 
				
			||||||
 | 
					      toastStore.error('Failed to merge channels - this requires an internet connection')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    showMergeDialog.value = false
 | 
				
			||||||
 | 
					    emit('close')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to merge channels:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to merge channels')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    merging.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const performDelete = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    deleting.value = true
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Try online delete first
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await apiService.deleteChannel(props.channel.id)
 | 
				
			||||||
 | 
					      // Remove from local store
 | 
				
			||||||
 | 
					      appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
 | 
				
			||||||
 | 
					      delete appStore.messages[props.channel.id]
 | 
				
			||||||
 | 
					      await appStore.saveState()
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      emit('channel-deleted', props.channel.id)
 | 
				
			||||||
 | 
					      toastStore.success('Channel deleted successfully')
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Switch to first available channel if we were on the deleted channel
 | 
				
			||||||
 | 
					      if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
 | 
				
			||||||
 | 
					        await appStore.setCurrentChannel(appStore.channels[0].id)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      // For delete, we can't do offline fallback easily since it affects server state
 | 
				
			||||||
 | 
					      console.error('Failed to delete channel:', error)
 | 
				
			||||||
 | 
					      toastStore.error('Failed to delete channel - this requires an internet connection')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    showDeleteConfirm.value = false
 | 
				
			||||||
 | 
					    emit('close')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to delete channel:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to delete channel')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    deleting.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cancel = () => {
 | 
				
			||||||
 | 
					  emit('close')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  nameInput.value?.focus()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.channel-info-dialog {
 | 
				
			||||||
 | 
					  padding: 1rem 0;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 2rem;
 | 
				
			||||||
 | 
					  min-width: 400px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.info-section {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.actions-section {
 | 
				
			||||||
 | 
					  border-top: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  padding-top: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.action-group h3 {
 | 
				
			||||||
 | 
					  margin: 0 0 1rem 0;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.action-group {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  border-top: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  padding-top: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Merge Dialog Styles */
 | 
				
			||||||
 | 
					.merge-dialog {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.merge-warning {
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  background: #fef3c7;
 | 
				
			||||||
 | 
					  border: 1px solid #f59e0b;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  color: #92400e;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.merge-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.merge-form label {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.target-select {
 | 
				
			||||||
 | 
					  padding: 0.75rem;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.target-select:focus {
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  border-color: #3b82f6;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.merge-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Delete Dialog Styles */
 | 
				
			||||||
 | 
					.delete-dialog {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.delete-warning {
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  background: #fef2f2;
 | 
				
			||||||
 | 
					  border: 1px solid #fca5a5;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  color: #dc2626;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.delete-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .actions-section {
 | 
				
			||||||
 | 
					    border-top-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .action-group h3 {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog-actions {
 | 
				
			||||||
 | 
					    border-top-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .merge-warning {
 | 
				
			||||||
 | 
					    background: #451a03;
 | 
				
			||||||
 | 
					    border-color: #92400e;
 | 
				
			||||||
 | 
					    color: #fbbf24;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .delete-warning {
 | 
				
			||||||
 | 
					    background: #450a0a;
 | 
				
			||||||
 | 
					    border-color: #dc2626;
 | 
				
			||||||
 | 
					    color: #fca5a5;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .merge-form label {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .target-select {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .target-select:focus {
 | 
				
			||||||
 | 
					    border-color: #60a5fa;
 | 
				
			||||||
 | 
					    box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										96
									
								
								frontend-vue/src/components/dialogs/CreateChannelDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								frontend-vue/src/components/dialogs/CreateChannelDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="create-channel-dialog">
 | 
				
			||||||
 | 
					    <form @submit.prevent="handleSubmit" class="channel-form">
 | 
				
			||||||
 | 
					      <BaseInput
 | 
				
			||||||
 | 
					        v-model="channelName"
 | 
				
			||||||
 | 
					        label="Channel Name"
 | 
				
			||||||
 | 
					        placeholder="Enter channel name"
 | 
				
			||||||
 | 
					        required
 | 
				
			||||||
 | 
					        :error="error"
 | 
				
			||||||
 | 
					        :disabled="isLoading"
 | 
				
			||||||
 | 
					        ref="nameInput"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="form-actions">
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          type="button"
 | 
				
			||||||
 | 
					          variant="secondary"
 | 
				
			||||||
 | 
					          @click="$emit('cancel')"
 | 
				
			||||||
 | 
					          :disabled="isLoading"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Cancel
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          type="submit"
 | 
				
			||||||
 | 
					          :loading="isLoading"
 | 
				
			||||||
 | 
					          :disabled="!channelName.trim()"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Create Channel
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import BaseInput from '@/components/base/BaseInput.vue'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  cancel: []
 | 
				
			||||||
 | 
					  created: [channelId: number]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const channelName = ref('')
 | 
				
			||||||
 | 
					const error = ref('')
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const nameInput = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleSubmit = async () => {
 | 
				
			||||||
 | 
					  if (!channelName.value.trim()) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  isLoading.value = true
 | 
				
			||||||
 | 
					  error.value = ''
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const newChannel = await apiService.createChannel(channelName.value.trim())
 | 
				
			||||||
 | 
					    appStore.addChannel(newChannel)
 | 
				
			||||||
 | 
					    toastStore.success(`Channel "${newChannel.name}" created successfully!`)
 | 
				
			||||||
 | 
					    emit('created', newChannel.id)
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    console.error('Failed to create channel:', err)
 | 
				
			||||||
 | 
					    error.value = 'Failed to create channel. Please try again.'
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isLoading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  nameInput.value?.focus()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.create-channel-dialog {
 | 
				
			||||||
 | 
					  padding: 1rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channel-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										368
									
								
								frontend-vue/src/components/dialogs/FileUploadDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										368
									
								
								frontend-vue/src/components/dialogs/FileUploadDialog.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										308
									
								
								frontend-vue/src/components/dialogs/SearchDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								frontend-vue/src/components/dialogs/SearchDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,308 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="search-dialog">
 | 
				
			||||||
 | 
					    <div class="search-form">
 | 
				
			||||||
 | 
					      <BaseInput
 | 
				
			||||||
 | 
					        v-model="searchQuery"
 | 
				
			||||||
 | 
					        placeholder="Search messages..."
 | 
				
			||||||
 | 
					        @keydown.enter="performSearch"
 | 
				
			||||||
 | 
					        ref="searchInput"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="search-filters">
 | 
				
			||||||
 | 
					        <select 
 | 
				
			||||||
 | 
					          v-model="selectedChannelId" 
 | 
				
			||||||
 | 
					          class="channel-filter"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <option :value="null">All channels</option>
 | 
				
			||||||
 | 
					          <option 
 | 
				
			||||||
 | 
					            v-for="channel in appStore.channels" 
 | 
				
			||||||
 | 
					            :key="channel.id"
 | 
				
			||||||
 | 
					            :value="channel.id"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {{ channel.name }}
 | 
				
			||||||
 | 
					          </option>
 | 
				
			||||||
 | 
					        </select>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          @click="performSearch"
 | 
				
			||||||
 | 
					          :loading="isSearching"
 | 
				
			||||||
 | 
					          :disabled="!searchQuery.trim()"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Search
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-if="isSearching" class="search-loading">
 | 
				
			||||||
 | 
					      Searching...
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-else-if="searchResults.length > 0" class="search-results">
 | 
				
			||||||
 | 
					      <div class="results-header">
 | 
				
			||||||
 | 
					        Found {{ searchResults.length }} result{{ searchResults.length === 1 ? '' : 's' }}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="results-list">
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          v-for="result in searchResults"
 | 
				
			||||||
 | 
					          :key="`${result.channel_id}-${result.id}`"
 | 
				
			||||||
 | 
					          class="result-item"
 | 
				
			||||||
 | 
					          @click="goToMessage(result)"
 | 
				
			||||||
 | 
					          tabindex="0"
 | 
				
			||||||
 | 
					          @keydown.enter="goToMessage(result)"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <div class="result-channel">
 | 
				
			||||||
 | 
					            {{ getChannelName(result.channel_id) }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="result-content">
 | 
				
			||||||
 | 
					            {{ result.content }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="result-time">
 | 
				
			||||||
 | 
					            {{ formatTime(result.created_at) }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-else-if="hasSearched && searchResults.length === 0" class="no-results">
 | 
				
			||||||
 | 
					      No messages found for "{{ searchQuery }}"
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div v-if="error" class="search-error">
 | 
				
			||||||
 | 
					      {{ error }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import BaseInput from '@/components/base/BaseInput.vue'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					import type { Message, ExtendedMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  close: []
 | 
				
			||||||
 | 
					  'select-message': [message: ExtendedMessage]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const searchQuery = ref('')
 | 
				
			||||||
 | 
					const selectedChannelId = ref<number | null>(null)
 | 
				
			||||||
 | 
					const searchResults = ref<ExtendedMessage[]>([])
 | 
				
			||||||
 | 
					const isSearching = ref(false)
 | 
				
			||||||
 | 
					const hasSearched = ref(false)
 | 
				
			||||||
 | 
					const error = ref('')
 | 
				
			||||||
 | 
					const searchInput = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const performSearch = async () => {
 | 
				
			||||||
 | 
					  if (!searchQuery.value.trim()) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  isSearching.value = true
 | 
				
			||||||
 | 
					  error.value = ''
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await apiService.search(
 | 
				
			||||||
 | 
					      searchQuery.value.trim(),
 | 
				
			||||||
 | 
					      selectedChannelId.value || undefined
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Transform search results to match expected format
 | 
				
			||||||
 | 
					    searchResults.value = response.results.map((result: any) => ({
 | 
				
			||||||
 | 
					      ...result,
 | 
				
			||||||
 | 
					      channel_id: result.channelId || result.channel_id,
 | 
				
			||||||
 | 
					      created_at: result.createdAt || result.created_at
 | 
				
			||||||
 | 
					    })) as ExtendedMessage[]
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log('Search results transformed:', searchResults.value)
 | 
				
			||||||
 | 
					    hasSearched.value = true
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    console.error('Search failed:', err)
 | 
				
			||||||
 | 
					    error.value = 'Search failed. Please try again.'
 | 
				
			||||||
 | 
					    toastStore.error('Search failed')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isSearching.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const goToMessage = (message: ExtendedMessage) => {
 | 
				
			||||||
 | 
					  emit('select-message', message)
 | 
				
			||||||
 | 
					  emit('close')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getChannelName = (channelId: number): string => {
 | 
				
			||||||
 | 
					  if (!channelId) return 'Unknown Channel'
 | 
				
			||||||
 | 
					  const channel = appStore.channels.find(c => c.id === channelId)
 | 
				
			||||||
 | 
					  return channel?.name || `Channel ${channelId}`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const formatTime = (timestamp: string): string => {
 | 
				
			||||||
 | 
					  if (!timestamp) return 'Unknown time'
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const date = new Date(timestamp)
 | 
				
			||||||
 | 
					  if (isNaN(date.getTime())) {
 | 
				
			||||||
 | 
					    return 'Invalid date'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  return date.toLocaleString()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  searchInput.value?.focus()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.search-dialog {
 | 
				
			||||||
 | 
					  padding: 1rem 0;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					  min-height: 400px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.search-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.search-filters {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  align-items: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channel-filter {
 | 
				
			||||||
 | 
					  padding: 0.5rem 0.75rem;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  min-width: 150px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channel-filter:focus {
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  border-color: #646cff;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.search-loading {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  padding: 2rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.search-results {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.results-header {
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  padding-bottom: 0.5rem;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.results-list {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  max-height: 300px;
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.result-item {
 | 
				
			||||||
 | 
					  padding: 0.75rem;
 | 
				
			||||||
 | 
					  border: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.result-item:hover,
 | 
				
			||||||
 | 
					.result-item:focus {
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					  border-color: #646cff;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.result-channel {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #646cff;
 | 
				
			||||||
 | 
					  margin-bottom: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.result-content {
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  margin-bottom: 0.5rem;
 | 
				
			||||||
 | 
					  line-height: 1.4;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.result-time {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.no-results {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  font-style: italic;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.search-error {
 | 
				
			||||||
 | 
					  padding: 0.75rem;
 | 
				
			||||||
 | 
					  background: #fef2f2;
 | 
				
			||||||
 | 
					  border: 1px solid #fecaca;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  color: #dc2626;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .channel-filter {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .results-header {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-bottom-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .result-item {
 | 
				
			||||||
 | 
					    border-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .result-item:hover,
 | 
				
			||||||
 | 
					  .result-item:focus {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .result-content {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .search-error {
 | 
				
			||||||
 | 
					    background: #422006;
 | 
				
			||||||
 | 
					    border-color: #92400e;
 | 
				
			||||||
 | 
					    color: #fbbf24;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										364
									
								
								frontend-vue/src/components/dialogs/SettingsDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										364
									
								
								frontend-vue/src/components/dialogs/SettingsDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,364 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="settings-dialog">
 | 
				
			||||||
 | 
					    <form @submit.prevent="handleSave" class="settings-form">
 | 
				
			||||||
 | 
					      <div class="setting-group">
 | 
				
			||||||
 | 
					        <h3>Audio Settings</h3>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <label class="setting-item">
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            type="checkbox" 
 | 
				
			||||||
 | 
					            v-model="localSettings.soundEnabled"
 | 
				
			||||||
 | 
					            class="checkbox"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <span>Enable sound effects</span>
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <label class="setting-item">
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            type="checkbox" 
 | 
				
			||||||
 | 
					            v-model="localSettings.speechEnabled"
 | 
				
			||||||
 | 
					            class="checkbox"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <span>Enable speech synthesis (deprecated)</span>
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="setting-group">
 | 
				
			||||||
 | 
					        <h3>Text-to-Speech</h3>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <label class="setting-item">
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            type="checkbox" 
 | 
				
			||||||
 | 
					            v-model="localSettings.ttsEnabled"
 | 
				
			||||||
 | 
					            class="checkbox"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <span>Enable text-to-speech announcements</span>
 | 
				
			||||||
 | 
					        </label>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item" v-if="localSettings.ttsEnabled">
 | 
				
			||||||
 | 
					          <label for="voice-select">Voice</label>
 | 
				
			||||||
 | 
					          <select 
 | 
				
			||||||
 | 
					            id="voice-select" 
 | 
				
			||||||
 | 
					            v-model="selectedVoiceURI"
 | 
				
			||||||
 | 
					            class="select"
 | 
				
			||||||
 | 
					            @change="handleVoiceChange"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <option value="" disabled>Select a voice...</option>
 | 
				
			||||||
 | 
					            <option 
 | 
				
			||||||
 | 
					              v-for="voice in availableVoices" 
 | 
				
			||||||
 | 
					              :key="voice.voiceURI"
 | 
				
			||||||
 | 
					              :value="voice.voiceURI"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {{ voice.name }} ({{ voice.lang }})
 | 
				
			||||||
 | 
					            </option>
 | 
				
			||||||
 | 
					          </select>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item" v-if="localSettings.ttsEnabled">
 | 
				
			||||||
 | 
					          <label for="rate-slider">Speech Rate: {{ localSettings.ttsRate.toFixed(1) }}</label>
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            id="rate-slider"
 | 
				
			||||||
 | 
					            type="range" 
 | 
				
			||||||
 | 
					            min="0.5" 
 | 
				
			||||||
 | 
					            max="2" 
 | 
				
			||||||
 | 
					            step="0.1"
 | 
				
			||||||
 | 
					            v-model.number="localSettings.ttsRate"
 | 
				
			||||||
 | 
					            class="slider"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item" v-if="localSettings.ttsEnabled">
 | 
				
			||||||
 | 
					          <label for="pitch-slider">Speech Pitch: {{ localSettings.ttsPitch.toFixed(1) }}</label>
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            id="pitch-slider"
 | 
				
			||||||
 | 
					            type="range" 
 | 
				
			||||||
 | 
					            min="0" 
 | 
				
			||||||
 | 
					            max="2" 
 | 
				
			||||||
 | 
					            step="0.1"
 | 
				
			||||||
 | 
					            v-model.number="localSettings.ttsPitch"
 | 
				
			||||||
 | 
					            class="slider"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item" v-if="localSettings.ttsEnabled">
 | 
				
			||||||
 | 
					          <label for="volume-slider">Speech Volume: {{ localSettings.ttsVolume.toFixed(1) }}</label>
 | 
				
			||||||
 | 
					          <input 
 | 
				
			||||||
 | 
					            id="volume-slider"
 | 
				
			||||||
 | 
					            type="range" 
 | 
				
			||||||
 | 
					            min="0" 
 | 
				
			||||||
 | 
					            max="1" 
 | 
				
			||||||
 | 
					            step="0.1"
 | 
				
			||||||
 | 
					            v-model.number="localSettings.ttsVolume"
 | 
				
			||||||
 | 
					            class="slider"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item" v-if="localSettings.ttsEnabled">
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            type="button"
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            @click="testSpeech"
 | 
				
			||||||
 | 
					            :disabled="!selectedVoiceURI"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Test Speech
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="setting-group">
 | 
				
			||||||
 | 
					        <h3>Appearance</h3>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item">
 | 
				
			||||||
 | 
					          <label for="theme-select">Theme</label>
 | 
				
			||||||
 | 
					          <select 
 | 
				
			||||||
 | 
					            id="theme-select" 
 | 
				
			||||||
 | 
					            v-model="localSettings.theme"
 | 
				
			||||||
 | 
					            class="select"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <option value="auto">Auto (System)</option>
 | 
				
			||||||
 | 
					            <option value="light">Light</option>
 | 
				
			||||||
 | 
					            <option value="dark">Dark</option>
 | 
				
			||||||
 | 
					          </select>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="setting-group" v-if="appStore.channels.length > 0">
 | 
				
			||||||
 | 
					        <h3>Default Channel</h3>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="setting-item">
 | 
				
			||||||
 | 
					          <label for="default-channel-select">Default Channel</label>
 | 
				
			||||||
 | 
					          <select 
 | 
				
			||||||
 | 
					            id="default-channel-select" 
 | 
				
			||||||
 | 
					            v-model="localSettings.defaultChannelId"
 | 
				
			||||||
 | 
					            class="select"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <option :value="null">None</option>
 | 
				
			||||||
 | 
					            <option 
 | 
				
			||||||
 | 
					              v-for="channel in appStore.channels" 
 | 
				
			||||||
 | 
					              :key="channel.id"
 | 
				
			||||||
 | 
					              :value="channel.id"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {{ channel.name }}
 | 
				
			||||||
 | 
					            </option>
 | 
				
			||||||
 | 
					          </select>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <div class="form-actions">
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          type="button"
 | 
				
			||||||
 | 
					          variant="secondary"
 | 
				
			||||||
 | 
					          @click="$emit('close')"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Cancel
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          type="submit"
 | 
				
			||||||
 | 
					          :loading="isSaving"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          Save Settings
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, reactive, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					import type { AppSettings } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  close: []
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					const { availableVoices, speak, setVoice } = useAudio()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isSaving = ref(false)
 | 
				
			||||||
 | 
					const selectedVoiceURI = ref('')
 | 
				
			||||||
 | 
					const localSettings = reactive<AppSettings>({
 | 
				
			||||||
 | 
					  soundEnabled: true,
 | 
				
			||||||
 | 
					  speechEnabled: true,
 | 
				
			||||||
 | 
					  ttsEnabled: true,
 | 
				
			||||||
 | 
					  ttsRate: 1,
 | 
				
			||||||
 | 
					  ttsPitch: 1,
 | 
				
			||||||
 | 
					  ttsVolume: 1,
 | 
				
			||||||
 | 
					  selectedVoiceURI: null,
 | 
				
			||||||
 | 
					  defaultChannelId: null,
 | 
				
			||||||
 | 
					  theme: 'auto'
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleVoiceChange = () => {
 | 
				
			||||||
 | 
					  const voice = availableVoices.value.find(v => v.voiceURI === selectedVoiceURI.value)
 | 
				
			||||||
 | 
					  if (voice) {
 | 
				
			||||||
 | 
					    setVoice(voice)
 | 
				
			||||||
 | 
					    localSettings.selectedVoiceURI = voice.voiceURI
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const testSpeech = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await speak('This is a test of the text-to-speech system.', {
 | 
				
			||||||
 | 
					      rate: localSettings.ttsRate,
 | 
				
			||||||
 | 
					      pitch: localSettings.ttsPitch,
 | 
				
			||||||
 | 
					      volume: localSettings.ttsVolume
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    toastStore.error('Speech test failed')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleSave = async () => {
 | 
				
			||||||
 | 
					  isSaving.value = true
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await appStore.updateSettings(localSettings)
 | 
				
			||||||
 | 
					    toastStore.success('Settings saved successfully!')
 | 
				
			||||||
 | 
					    emit('close')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to save settings:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to save settings')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isSaving.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  // Copy current settings to local state
 | 
				
			||||||
 | 
					  Object.assign(localSettings, appStore.settings)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Set up voice selection
 | 
				
			||||||
 | 
					  selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.settings-dialog {
 | 
				
			||||||
 | 
					  padding: 1rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.settings-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.setting-group {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.setting-group h3 {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  padding-bottom: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.setting-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  padding: 0.5rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.setting-item label {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.checkbox {
 | 
				
			||||||
 | 
					  width: 1.25rem;
 | 
				
			||||||
 | 
					  height: 1.25rem;
 | 
				
			||||||
 | 
					  accent-color: #646cff;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.select {
 | 
				
			||||||
 | 
					  padding: 0.5rem 0.75rem;
 | 
				
			||||||
 | 
					  border: 1px solid #d1d5db;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  min-width: 150px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.select:focus {
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  border-color: #646cff;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.slider {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 200px;
 | 
				
			||||||
 | 
					  height: 4px;
 | 
				
			||||||
 | 
					  border-radius: 2px;
 | 
				
			||||||
 | 
					  background: #e5e7eb;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  appearance: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.slider::-webkit-slider-thumb {
 | 
				
			||||||
 | 
					  appearance: none;
 | 
				
			||||||
 | 
					  width: 16px;
 | 
				
			||||||
 | 
					  height: 16px;
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  background: #646cff;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  border: 2px solid white;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.slider::-moz-range-thumb {
 | 
				
			||||||
 | 
					  width: 16px;
 | 
				
			||||||
 | 
					  height: 16px;
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  background: #646cff;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  border: 2px solid white;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					  padding-top: 1rem;
 | 
				
			||||||
 | 
					  border-top: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .setting-group h3 {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-bottom-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .setting-item label {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .select {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .form-actions {
 | 
				
			||||||
 | 
					    border-top-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										479
									
								
								frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										479
									
								
								frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										78
									
								
								frontend-vue/src/components/sidebar/ChannelList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								frontend-vue/src/components/sidebar/ChannelList.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										191
									
								
								frontend-vue/src/components/sidebar/ChannelListItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								frontend-vue/src/components/sidebar/ChannelListItem.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										138
									
								
								frontend-vue/src/components/sidebar/Sidebar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								frontend-vue/src/components/sidebar/Sidebar.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										467
									
								
								frontend-vue/src/composables/useAudio.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										467
									
								
								frontend-vue/src/composables/useAudio.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,467 @@
 | 
				
			|||||||
 | 
					import { ref, computed, readonly } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface AudioRecording {
 | 
				
			||||||
 | 
					  blob: Blob | null
 | 
				
			||||||
 | 
					  duration: number
 | 
				
			||||||
 | 
					  isRecording: boolean
 | 
				
			||||||
 | 
					  isPlaying: boolean
 | 
				
			||||||
 | 
					  currentTime: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Global audio state to ensure singleton behavior
 | 
				
			||||||
 | 
					let audioSystemInitialized = false
 | 
				
			||||||
 | 
					let soundsLoaded = false
 | 
				
			||||||
 | 
					let globalAudioContext: AudioContext | null = null
 | 
				
			||||||
 | 
					let globalSoundBuffers = new Map<string, AudioBuffer>()
 | 
				
			||||||
 | 
					let globalWaterSounds: AudioBuffer[] = []
 | 
				
			||||||
 | 
					let globalSentSounds: AudioBuffer[] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function useAudio() {
 | 
				
			||||||
 | 
					  const appStore = useAppStore()
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Audio Context (use global instance)
 | 
				
			||||||
 | 
					  const audioContext = ref<AudioContext | null>(globalAudioContext)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Sound buffers (use global arrays)
 | 
				
			||||||
 | 
					  const soundBuffers = ref<Map<string, AudioBuffer>>(globalSoundBuffers)
 | 
				
			||||||
 | 
					  const waterSounds = ref<AudioBuffer[]>(globalWaterSounds)
 | 
				
			||||||
 | 
					  const sentSounds = ref<AudioBuffer[]>(globalSentSounds)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Recording state
 | 
				
			||||||
 | 
					  const recording = ref<AudioRecording>({
 | 
				
			||||||
 | 
					    blob: null,
 | 
				
			||||||
 | 
					    duration: 0,
 | 
				
			||||||
 | 
					    isRecording: false,
 | 
				
			||||||
 | 
					    isPlaying: false,
 | 
				
			||||||
 | 
					    currentTime: 0
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Media recorder
 | 
				
			||||||
 | 
					  let mediaRecorder: MediaRecorder | null = null
 | 
				
			||||||
 | 
					  let recordingChunks: Blob[] = []
 | 
				
			||||||
 | 
					  let recordingStartTime: number = 0
 | 
				
			||||||
 | 
					  let recordingInterval: number | null = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Text-to-speech state
 | 
				
			||||||
 | 
					  const isSpeaking = ref(false)
 | 
				
			||||||
 | 
					  const availableVoices = ref<SpeechSynthesisVoice[]>([])
 | 
				
			||||||
 | 
					  const selectedVoice = ref<SpeechSynthesisVoice | null>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Initialize audio context
 | 
				
			||||||
 | 
					  const initAudioContext = async () => {
 | 
				
			||||||
 | 
					    if (!globalAudioContext) {
 | 
				
			||||||
 | 
					      globalAudioContext = new AudioContext()
 | 
				
			||||||
 | 
					      audioContext.value = globalAudioContext
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (globalAudioContext.state === 'suspended') {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await globalAudioContext.resume()
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.warn('AudioContext resume failed, user interaction required:', error)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Load a single sound file
 | 
				
			||||||
 | 
					  const loadSound = async (url: string): Promise<AudioBuffer | null> => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (!audioContext.value) {
 | 
				
			||||||
 | 
					        await initAudioContext()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      if (!audioContext.value) {
 | 
				
			||||||
 | 
					        // AudioContext creation failed (probably no user interaction yet)
 | 
				
			||||||
 | 
					        return null
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      const response = await fetch(url)
 | 
				
			||||||
 | 
					      const arrayBuffer = await response.arrayBuffer()
 | 
				
			||||||
 | 
					      return await audioContext.value.decodeAudioData(arrayBuffer)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.warn(`Failed to load sound ${url}:`, error)
 | 
				
			||||||
 | 
					      return null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Load all sound files
 | 
				
			||||||
 | 
					  const loadAllSounds = async () => {
 | 
				
			||||||
 | 
					    if (soundsLoaded) {
 | 
				
			||||||
 | 
					      console.log('Sounds already loaded, skipping...')
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      console.log('Starting to load all sounds...')
 | 
				
			||||||
 | 
					      soundsLoaded = true
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Load basic sounds
 | 
				
			||||||
 | 
					      const basicSounds = {
 | 
				
			||||||
 | 
					        intro: '/sounds/intro.wav',
 | 
				
			||||||
 | 
					        login: '/sounds/login.wav', 
 | 
				
			||||||
 | 
					        copy: '/sounds/copy.wav',
 | 
				
			||||||
 | 
					        uploadFailed: '/sounds/uploadfail.wav'
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (const [name, url] of Object.entries(basicSounds)) {
 | 
				
			||||||
 | 
					        const buffer = await loadSound(url)
 | 
				
			||||||
 | 
					        if (buffer) {
 | 
				
			||||||
 | 
					          globalSoundBuffers.set(name, buffer)
 | 
				
			||||||
 | 
					          soundBuffers.value.set(name, buffer)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Load water sounds (1-10)
 | 
				
			||||||
 | 
					      console.log('Loading water sounds...')
 | 
				
			||||||
 | 
					      for (let i = 1; i <= 10; i++) {
 | 
				
			||||||
 | 
					        const buffer = await loadSound(`/sounds/water${i}.wav`)
 | 
				
			||||||
 | 
					        if (buffer) {
 | 
				
			||||||
 | 
					          globalWaterSounds.push(buffer)
 | 
				
			||||||
 | 
					          waterSounds.value.push(buffer)
 | 
				
			||||||
 | 
					          console.log(`Loaded water sound ${i}`)
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          console.warn(`Failed to load water sound ${i}`)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      console.log(`Water sounds loaded: ${globalWaterSounds.length}/10, reactive: ${waterSounds.value.length}/10`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Load sent sounds (1-6)  
 | 
				
			||||||
 | 
					      for (let i = 1; i <= 6; i++) {
 | 
				
			||||||
 | 
					        const buffer = await loadSound(`/sounds/sent${i}.wav`)
 | 
				
			||||||
 | 
					        if (buffer) {
 | 
				
			||||||
 | 
					          globalSentSounds.push(buffer)
 | 
				
			||||||
 | 
					          sentSounds.value.push(buffer)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      console.log('All sounds loaded and ready to play')
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Error loading sounds:', error)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Play a sound buffer
 | 
				
			||||||
 | 
					  const playSoundBuffer = async (buffer: AudioBuffer) => {
 | 
				
			||||||
 | 
					    if (!appStore.settings.soundEnabled) return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await initAudioContext()
 | 
				
			||||||
 | 
					      if (!globalAudioContext) {
 | 
				
			||||||
 | 
					        console.error('AudioContext not initialized')
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const source = globalAudioContext.createBufferSource()
 | 
				
			||||||
 | 
					      source.buffer = buffer
 | 
				
			||||||
 | 
					      source.connect(globalAudioContext.destination)
 | 
				
			||||||
 | 
					      source.start(0)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Error playing sound:', error)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Play specific sounds
 | 
				
			||||||
 | 
					  const playSound = async (name: string) => {
 | 
				
			||||||
 | 
					    const buffer = globalSoundBuffers.get(name)
 | 
				
			||||||
 | 
					    if (buffer) {
 | 
				
			||||||
 | 
					      await playSoundBuffer(buffer)
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      console.warn(`Sound ${name} not loaded`)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const playWater = async () => {
 | 
				
			||||||
 | 
					    console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
 | 
				
			||||||
 | 
					    if (globalWaterSounds.length > 0) {
 | 
				
			||||||
 | 
					      const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
 | 
				
			||||||
 | 
					      await playSoundBuffer(globalWaterSounds[randomIndex])
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      console.warn('Water sounds not loaded - trying to load them now')
 | 
				
			||||||
 | 
					      if (globalAudioContext) {
 | 
				
			||||||
 | 
					        await loadAllSounds()
 | 
				
			||||||
 | 
					        if (globalWaterSounds.length > 0) {
 | 
				
			||||||
 | 
					          const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
 | 
				
			||||||
 | 
					          await playSoundBuffer(globalWaterSounds[randomIndex])
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const playSent = async () => {
 | 
				
			||||||
 | 
					    if (globalSentSounds.length > 0) {
 | 
				
			||||||
 | 
					      const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
 | 
				
			||||||
 | 
					      await playSoundBuffer(globalSentSounds[randomIndex])
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      console.warn('Sent sounds not loaded')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Voice recording
 | 
				
			||||||
 | 
					  const startRecording = async (): Promise<boolean> => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const stream = await navigator.mediaDevices.getUserMedia({ 
 | 
				
			||||||
 | 
					        audio: {
 | 
				
			||||||
 | 
					          echoCancellation: false,
 | 
				
			||||||
 | 
					          noiseSuppression: false,
 | 
				
			||||||
 | 
					          autoGainControl: true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder = new MediaRecorder(stream, {
 | 
				
			||||||
 | 
					        mimeType: 'audio/webm;codecs=opus'
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      recordingChunks = []
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      mediaRecorder.ondataavailable = (event) => {
 | 
				
			||||||
 | 
					        if (event.data.size > 0) {
 | 
				
			||||||
 | 
					          recordingChunks.push(event.data)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder.onstop = () => {
 | 
				
			||||||
 | 
					        const blob = new Blob(recordingChunks, { type: 'audio/webm;codecs=opus' })
 | 
				
			||||||
 | 
					        recording.value.blob = blob
 | 
				
			||||||
 | 
					        recording.value.isRecording = false
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (recordingInterval) {
 | 
				
			||||||
 | 
					          clearInterval(recordingInterval)
 | 
				
			||||||
 | 
					          recordingInterval = null
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Stop all tracks to release microphone
 | 
				
			||||||
 | 
					        stream.getTracks().forEach(track => track.stop())
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder.start()
 | 
				
			||||||
 | 
					      recording.value.isRecording = true
 | 
				
			||||||
 | 
					      recording.value.duration = 0
 | 
				
			||||||
 | 
					      recordingStartTime = Date.now()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Update duration every 100ms
 | 
				
			||||||
 | 
					      recordingInterval = setInterval(() => {
 | 
				
			||||||
 | 
					        recording.value.duration = (Date.now() - recordingStartTime) / 1000
 | 
				
			||||||
 | 
					      }, 100)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return true
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Failed to start recording:', error)
 | 
				
			||||||
 | 
					      recording.value.isRecording = false
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stopRecording = () => {
 | 
				
			||||||
 | 
					    if (mediaRecorder && recording.value.isRecording) {
 | 
				
			||||||
 | 
					      mediaRecorder.stop()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const playRecording = async () => {
 | 
				
			||||||
 | 
					    if (!recording.value.blob) return false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const audio = new Audio(URL.createObjectURL(recording.value.blob))
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      recording.value.isPlaying = true
 | 
				
			||||||
 | 
					      recording.value.currentTime = 0
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      audio.ontimeupdate = () => {
 | 
				
			||||||
 | 
					        recording.value.currentTime = audio.currentTime
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      audio.onended = () => {
 | 
				
			||||||
 | 
					        recording.value.isPlaying = false
 | 
				
			||||||
 | 
					        recording.value.currentTime = 0
 | 
				
			||||||
 | 
					        URL.revokeObjectURL(audio.src)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      await audio.play()
 | 
				
			||||||
 | 
					      return true
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Failed to play recording:', error)
 | 
				
			||||||
 | 
					      recording.value.isPlaying = false
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clearRecording = () => {
 | 
				
			||||||
 | 
					    if (recording.value.blob) {
 | 
				
			||||||
 | 
					      URL.revokeObjectURL(URL.createObjectURL(recording.value.blob))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    recording.value.blob = null
 | 
				
			||||||
 | 
					    recording.value.duration = 0
 | 
				
			||||||
 | 
					    recording.value.isPlaying = false
 | 
				
			||||||
 | 
					    recording.value.currentTime = 0
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Text-to-speech functions
 | 
				
			||||||
 | 
					  const loadVoices = () => {
 | 
				
			||||||
 | 
					    const voices = speechSynthesis.getVoices()
 | 
				
			||||||
 | 
					    availableVoices.value = voices
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Select default voice (prefer English voices)
 | 
				
			||||||
 | 
					    if (!selectedVoice.value && voices.length > 0) {
 | 
				
			||||||
 | 
					      const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
 | 
				
			||||||
 | 
					      selectedVoice.value = englishVoice || voices[0]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const speak = (text: string, options: { rate?: number, pitch?: number, volume?: number } = {}) => {
 | 
				
			||||||
 | 
					    if (!appStore.settings.ttsEnabled) return Promise.resolve()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return new Promise<void>((resolve, reject) => {
 | 
				
			||||||
 | 
					      if ('speechSynthesis' in window) {
 | 
				
			||||||
 | 
					        // Stop any current speech
 | 
				
			||||||
 | 
					        speechSynthesis.cancel()
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        const utterance = new SpeechSynthesisUtterance(text)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Set voice if available
 | 
				
			||||||
 | 
					        if (selectedVoice.value) {
 | 
				
			||||||
 | 
					          utterance.voice = selectedVoice.value
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Apply options
 | 
				
			||||||
 | 
					        utterance.rate = options.rate || appStore.settings.ttsRate || 1
 | 
				
			||||||
 | 
					        utterance.pitch = options.pitch || appStore.settings.ttsPitch || 1
 | 
				
			||||||
 | 
					        utterance.volume = options.volume || appStore.settings.ttsVolume || 1
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        utterance.onstart = () => {
 | 
				
			||||||
 | 
					          isSpeaking.value = true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        utterance.onend = () => {
 | 
				
			||||||
 | 
					          isSpeaking.value = false
 | 
				
			||||||
 | 
					          resolve()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        utterance.onerror = (event) => {
 | 
				
			||||||
 | 
					          isSpeaking.value = false
 | 
				
			||||||
 | 
					          console.error('Speech synthesis error:', event.error)
 | 
				
			||||||
 | 
					          reject(new Error(`Speech synthesis failed: ${event.error}`))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        speechSynthesis.speak(utterance)
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        reject(new Error('Speech synthesis not supported'))
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stopSpeaking = () => {
 | 
				
			||||||
 | 
					    if ('speechSynthesis' in window) {
 | 
				
			||||||
 | 
					      speechSynthesis.cancel()
 | 
				
			||||||
 | 
					      isSpeaking.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const setVoice = (voice: SpeechSynthesisVoice) => {
 | 
				
			||||||
 | 
					    selectedVoice.value = voice
 | 
				
			||||||
 | 
					    appStore.updateSettings({ selectedVoiceURI: voice.voiceURI })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Announce message for accessibility
 | 
				
			||||||
 | 
					  const announceMessage = async (content: string, channel?: string) => {
 | 
				
			||||||
 | 
					    if (!appStore.settings.ttsEnabled) return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    let textToSpeak = content
 | 
				
			||||||
 | 
					    if (channel) {
 | 
				
			||||||
 | 
					      textToSpeak = `New message in ${channel}: ${content}`
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await speak(textToSpeak)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Failed to announce message:', error)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Computed
 | 
				
			||||||
 | 
					  const canRecord = computed(() => {
 | 
				
			||||||
 | 
					    return navigator.mediaDevices && navigator.mediaDevices.getUserMedia
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const recordingDurationFormatted = computed(() => {
 | 
				
			||||||
 | 
					    const duration = recording.value.duration
 | 
				
			||||||
 | 
					    const minutes = Math.floor(duration / 60)
 | 
				
			||||||
 | 
					    const seconds = Math.floor(duration % 60)
 | 
				
			||||||
 | 
					    return `${minutes}:${seconds.toString().padStart(2, '0')}`
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Initialize audio on first user interaction
 | 
				
			||||||
 | 
					  const initAudioOnUserGesture = async () => {
 | 
				
			||||||
 | 
					    if (!audioContext.value || audioContext.value.state === 'suspended') {
 | 
				
			||||||
 | 
					      await initAudioContext()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Initialize audio system (only once)
 | 
				
			||||||
 | 
					  const initializeAudioSystem = () => {
 | 
				
			||||||
 | 
					    if (!audioSystemInitialized) {
 | 
				
			||||||
 | 
					      audioSystemInitialized = true
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Set up user gesture listeners to initialize audio and load sounds
 | 
				
			||||||
 | 
					      const initializeAudio = async () => {
 | 
				
			||||||
 | 
					        console.log('User interaction detected, initializing audio system...')
 | 
				
			||||||
 | 
					        await initAudioOnUserGesture()
 | 
				
			||||||
 | 
					        await loadAllSounds() // Load sounds after user interaction
 | 
				
			||||||
 | 
					        console.log('Audio system initialized')
 | 
				
			||||||
 | 
					        document.removeEventListener('click', initializeAudio)
 | 
				
			||||||
 | 
					        document.removeEventListener('keydown', initializeAudio)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      document.addEventListener('click', initializeAudio, { once: true })
 | 
				
			||||||
 | 
					      document.addEventListener('keydown', initializeAudio, { once: true })
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Initialize voices for speech synthesis
 | 
				
			||||||
 | 
					      if ('speechSynthesis' in window) {
 | 
				
			||||||
 | 
					        loadVoices()
 | 
				
			||||||
 | 
					        // Voices may not be immediately available
 | 
				
			||||||
 | 
					        speechSynthesis.addEventListener('voiceschanged', loadVoices)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Restore selected voice from settings
 | 
				
			||||||
 | 
					        if (appStore.settings.selectedVoiceURI) {
 | 
				
			||||||
 | 
					          const voices = speechSynthesis.getVoices()
 | 
				
			||||||
 | 
					          const savedVoice = voices.find(v => v.voiceURI === appStore.settings.selectedVoiceURI)
 | 
				
			||||||
 | 
					          if (savedVoice) {
 | 
				
			||||||
 | 
					            selectedVoice.value = savedVoice
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Initialize audio system when composable is first used
 | 
				
			||||||
 | 
					  initializeAudioSystem()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    // State
 | 
				
			||||||
 | 
					    recording,
 | 
				
			||||||
 | 
					    canRecord,
 | 
				
			||||||
 | 
					    recordingDurationFormatted,
 | 
				
			||||||
 | 
					    isSpeaking: readonly(isSpeaking),
 | 
				
			||||||
 | 
					    availableVoices: readonly(availableVoices),
 | 
				
			||||||
 | 
					    selectedVoice: readonly(selectedVoice),
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Audio playback
 | 
				
			||||||
 | 
					    playSound,
 | 
				
			||||||
 | 
					    playWater,
 | 
				
			||||||
 | 
					    playSent,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Voice recording
 | 
				
			||||||
 | 
					    startRecording,
 | 
				
			||||||
 | 
					    stopRecording,
 | 
				
			||||||
 | 
					    playRecording,
 | 
				
			||||||
 | 
					    clearRecording,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Text-to-speech
 | 
				
			||||||
 | 
					    speak,
 | 
				
			||||||
 | 
					    stopSpeaking,
 | 
				
			||||||
 | 
					    setVoice,
 | 
				
			||||||
 | 
					    announceMessage,
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Audio context
 | 
				
			||||||
 | 
					    initAudioContext
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										93
									
								
								frontend-vue/src/composables/useKeyboardShortcuts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								frontend-vue/src/composables/useKeyboardShortcuts.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
				
			|||||||
 | 
					import { onMounted, onUnmounted, ref, readonly } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ShortcutConfig {
 | 
				
			||||||
 | 
					  key: string
 | 
				
			||||||
 | 
					  ctrlKey?: boolean
 | 
				
			||||||
 | 
					  shiftKey?: boolean
 | 
				
			||||||
 | 
					  altKey?: boolean
 | 
				
			||||||
 | 
					  metaKey?: boolean
 | 
				
			||||||
 | 
					  handler: () => void
 | 
				
			||||||
 | 
					  preventDefault?: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function useKeyboardShortcuts() {
 | 
				
			||||||
 | 
					  const shortcuts = ref<Map<string, ShortcutConfig>>(new Map())
 | 
				
			||||||
 | 
					  const isListening = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const getShortcutKey = (config: ShortcutConfig): string => {
 | 
				
			||||||
 | 
					    const parts = []
 | 
				
			||||||
 | 
					    if (config.ctrlKey) parts.push('ctrl')
 | 
				
			||||||
 | 
					    if (config.shiftKey) parts.push('shift') 
 | 
				
			||||||
 | 
					    if (config.altKey) parts.push('alt')
 | 
				
			||||||
 | 
					    if (config.metaKey) parts.push('meta')
 | 
				
			||||||
 | 
					    parts.push(config.key.toLowerCase())
 | 
				
			||||||
 | 
					    return parts.join('+')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    // Skip shortcuts when focused on input/textarea elements
 | 
				
			||||||
 | 
					    const target = event.target as HTMLElement
 | 
				
			||||||
 | 
					    if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const config: ShortcutConfig = {
 | 
				
			||||||
 | 
					      key: event.key.toLowerCase(),
 | 
				
			||||||
 | 
					      ctrlKey: event.ctrlKey,
 | 
				
			||||||
 | 
					      shiftKey: event.shiftKey,
 | 
				
			||||||
 | 
					      altKey: event.altKey,
 | 
				
			||||||
 | 
					      metaKey: event.metaKey,
 | 
				
			||||||
 | 
					      handler: () => {}
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const shortcutKey = getShortcutKey(config)
 | 
				
			||||||
 | 
					    const shortcut = shortcuts.value.get(shortcutKey)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (shortcut) {
 | 
				
			||||||
 | 
					      if (shortcut.preventDefault !== false) {
 | 
				
			||||||
 | 
					        event.preventDefault()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      shortcut.handler()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addShortcut = (config: ShortcutConfig) => {
 | 
				
			||||||
 | 
					    const key = getShortcutKey(config)
 | 
				
			||||||
 | 
					    shortcuts.value.set(key, config)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeShortcut = (config: Omit<ShortcutConfig, 'handler'>) => {
 | 
				
			||||||
 | 
					    const key = getShortcutKey(config as ShortcutConfig)
 | 
				
			||||||
 | 
					    shortcuts.value.delete(key)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const startListening = () => {
 | 
				
			||||||
 | 
					    if (!isListening.value) {
 | 
				
			||||||
 | 
					      document.addEventListener('keydown', handleKeydown)
 | 
				
			||||||
 | 
					      isListening.value = true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stopListening = () => {
 | 
				
			||||||
 | 
					    if (isListening.value) {
 | 
				
			||||||
 | 
					      document.removeEventListener('keydown', handleKeydown)
 | 
				
			||||||
 | 
					      isListening.value = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onMounted(() => {
 | 
				
			||||||
 | 
					    startListening()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onUnmounted(() => {
 | 
				
			||||||
 | 
					    stopListening()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    addShortcut,
 | 
				
			||||||
 | 
					    removeShortcut,
 | 
				
			||||||
 | 
					    startListening,
 | 
				
			||||||
 | 
					    stopListening,
 | 
				
			||||||
 | 
					    isListening
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										178
									
								
								frontend-vue/src/composables/useOfflineSync.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								frontend-vue/src/composables/useOfflineSync.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
				
			|||||||
 | 
					import { ref, onMounted, onUnmounted, readonly } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import type { UnsentMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function useOfflineSync() {
 | 
				
			||||||
 | 
					  const appStore = useAppStore()
 | 
				
			||||||
 | 
					  const toastStore = useToastStore()
 | 
				
			||||||
 | 
					  const isOnline = ref(navigator.onLine)
 | 
				
			||||||
 | 
					  const isSyncing = ref(false)
 | 
				
			||||||
 | 
					  let syncInterval: number | null = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Monitor online status
 | 
				
			||||||
 | 
					  const updateOnlineStatus = () => {
 | 
				
			||||||
 | 
					    const wasOnline = isOnline.value
 | 
				
			||||||
 | 
					    isOnline.value = navigator.onLine
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!wasOnline && isOnline.value) {
 | 
				
			||||||
 | 
					      toastStore.success('Back online - syncing data...')
 | 
				
			||||||
 | 
					      syncUnsentMessages()
 | 
				
			||||||
 | 
					    } else if (wasOnline && !isOnline.value) {
 | 
				
			||||||
 | 
					      toastStore.info('You are offline - messages will be queued')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Sync unsent messages when online
 | 
				
			||||||
 | 
					  const syncUnsentMessages = async () => {
 | 
				
			||||||
 | 
					    if (!isOnline.value || isSyncing.value || appStore.unsentMessages.length === 0) {
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isSyncing.value = true
 | 
				
			||||||
 | 
					    const failedMessages: UnsentMessage[] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const unsentMessage of appStore.unsentMessages) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const response = await apiService.createMessage(
 | 
				
			||||||
 | 
					          unsentMessage.channelId, 
 | 
				
			||||||
 | 
					          unsentMessage.content
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Message sent successfully - remove from unsent queue
 | 
				
			||||||
 | 
					        appStore.removeUnsentMessage(unsentMessage.id)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Add to messages (will be handled by WebSocket event too)
 | 
				
			||||||
 | 
					        appStore.addMessage(response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('Failed to sync message:', error)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Increment retry count (create mutable copy)
 | 
				
			||||||
 | 
					        const mutableMessage = { ...unsentMessage, retries: unsentMessage.retries + 1 }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // If too many retries, give up
 | 
				
			||||||
 | 
					        if (mutableMessage.retries >= 3) {
 | 
				
			||||||
 | 
					          toastStore.error(`Failed to send message after 3 attempts: "${unsentMessage.content.substring(0, 50)}..."`)
 | 
				
			||||||
 | 
					          appStore.removeUnsentMessage(unsentMessage.id)
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          failedMessages.push(mutableMessage)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update unsent messages with failed ones
 | 
				
			||||||
 | 
					    if (failedMessages.length > 0) {
 | 
				
			||||||
 | 
					      toastStore.error(`${failedMessages.length} messages failed to sync. Will retry...`)
 | 
				
			||||||
 | 
					    } else if (appStore.unsentMessages.length > 0) {
 | 
				
			||||||
 | 
					      toastStore.success('All offline messages synced!')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isSyncing.value = false
 | 
				
			||||||
 | 
					    await appStore.saveState()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Queue message for sending when offline
 | 
				
			||||||
 | 
					  const queueMessage = async (channelId: number, content: string): Promise<string> => {
 | 
				
			||||||
 | 
					    const unsentMessage: UnsentMessage = {
 | 
				
			||||||
 | 
					      id: `unsent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
 | 
				
			||||||
 | 
					      channelId,
 | 
				
			||||||
 | 
					      content,
 | 
				
			||||||
 | 
					      timestamp: Date.now(),
 | 
				
			||||||
 | 
					      retries: 0
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    appStore.addUnsentMessage(unsentMessage)
 | 
				
			||||||
 | 
					    await appStore.saveState()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Try to send immediately if online
 | 
				
			||||||
 | 
					    if (isOnline.value) {
 | 
				
			||||||
 | 
					      syncUnsentMessages()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return unsentMessage.id
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Send message (online or queue for offline)
 | 
				
			||||||
 | 
					  const sendMessage = async (channelId: number, content: string): Promise<boolean> => {
 | 
				
			||||||
 | 
					    if (isOnline.value) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const response = await apiService.createMessage(channelId, content)
 | 
				
			||||||
 | 
					        appStore.addMessage(response)
 | 
				
			||||||
 | 
					        return true
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('Failed to send message online:', error)
 | 
				
			||||||
 | 
					        // Fall back to queuing
 | 
				
			||||||
 | 
					        await queueMessage(channelId, content)
 | 
				
			||||||
 | 
					        toastStore.error('Failed to send message - queued for later')
 | 
				
			||||||
 | 
					        return false
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      await queueMessage(channelId, content)
 | 
				
			||||||
 | 
					      toastStore.info('Message queued for sending when online')
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Auto-save state periodically
 | 
				
			||||||
 | 
					  const startAutoSave = () => {
 | 
				
			||||||
 | 
					    if (syncInterval) clearInterval(syncInterval)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    syncInterval = setInterval(async () => {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await appStore.saveState()
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Try to sync unsent messages if online
 | 
				
			||||||
 | 
					        if (isOnline.value && appStore.unsentMessages.length > 0) {
 | 
				
			||||||
 | 
					          syncUnsentMessages()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('Auto-save failed:', error)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }, 10000) // Save every 10 seconds
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stopAutoSave = () => {
 | 
				
			||||||
 | 
					    if (syncInterval) {
 | 
				
			||||||
 | 
					      clearInterval(syncInterval)
 | 
				
			||||||
 | 
					      syncInterval = null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Handle beforeunload to save state
 | 
				
			||||||
 | 
					  const handleBeforeUnload = () => {
 | 
				
			||||||
 | 
					    appStore.saveState()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onMounted(() => {
 | 
				
			||||||
 | 
					    // Add event listeners
 | 
				
			||||||
 | 
					    window.addEventListener('online', updateOnlineStatus)
 | 
				
			||||||
 | 
					    window.addEventListener('offline', updateOnlineStatus)
 | 
				
			||||||
 | 
					    window.addEventListener('beforeunload', handleBeforeUnload)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Start auto-save
 | 
				
			||||||
 | 
					    startAutoSave()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Initial sync if online and has unsent messages
 | 
				
			||||||
 | 
					    if (isOnline.value && appStore.unsentMessages.length > 0) {
 | 
				
			||||||
 | 
					      syncUnsentMessages()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onUnmounted(() => {
 | 
				
			||||||
 | 
					    // Clean up
 | 
				
			||||||
 | 
					    window.removeEventListener('online', updateOnlineStatus)
 | 
				
			||||||
 | 
					    window.removeEventListener('offline', updateOnlineStatus) 
 | 
				
			||||||
 | 
					    window.removeEventListener('beforeunload', handleBeforeUnload)
 | 
				
			||||||
 | 
					    stopAutoSave()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    isOnline,
 | 
				
			||||||
 | 
					    isSyncing,
 | 
				
			||||||
 | 
					    sendMessage,
 | 
				
			||||||
 | 
					    syncUnsentMessages,
 | 
				
			||||||
 | 
					    queueMessage
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										174
									
								
								frontend-vue/src/composables/useWebSocket.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								frontend-vue/src/composables/useWebSocket.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										48
									
								
								frontend-vue/src/main.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					import { createApp } from 'vue'
 | 
				
			||||||
 | 
					import { createPinia } from 'pinia'
 | 
				
			||||||
 | 
					import { createRouter, createWebHistory } from 'vue-router'
 | 
				
			||||||
 | 
					import App from './App.vue'
 | 
				
			||||||
 | 
					import './style.css'
 | 
				
			||||||
 | 
					import { apiService } from './services/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Import routes
 | 
				
			||||||
 | 
					import { routes } from './router/index'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const app = createApp(App)
 | 
				
			||||||
 | 
					const pinia = createPinia()
 | 
				
			||||||
 | 
					const router = createRouter({
 | 
				
			||||||
 | 
					  history: createWebHistory(),
 | 
				
			||||||
 | 
					  routes
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Router guard to ensure API service has proper token
 | 
				
			||||||
 | 
					router.beforeEach(async (to, from, next) => {
 | 
				
			||||||
 | 
					  const { useAuthStore } = await import('./stores/auth')
 | 
				
			||||||
 | 
					  const authStore = useAuthStore()
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Check authentication first
 | 
				
			||||||
 | 
					  await authStore.checkAuth()
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Check if going to protected route
 | 
				
			||||||
 | 
					  if (to.meta.requiresAuth && !authStore.isAuthenticated) {
 | 
				
			||||||
 | 
					    next('/auth')
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // If authenticated but going to auth page, redirect to main
 | 
				
			||||||
 | 
					  if (authStore.isAuthenticated && to.name === 'auth') {
 | 
				
			||||||
 | 
					    next('/')
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Set token for API service if authenticated
 | 
				
			||||||
 | 
					  if (authStore.isAuthenticated && authStore.token) {
 | 
				
			||||||
 | 
					    apiService.setToken(authStore.token)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  next()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.use(pinia)
 | 
				
			||||||
 | 
					app.use(router)
 | 
				
			||||||
 | 
					app.mount('#app')
 | 
				
			||||||
							
								
								
									
										17
									
								
								frontend-vue/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend-vue/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					import type { RouteRecordRaw } from 'vue-router'
 | 
				
			||||||
 | 
					import MainView from '@/views/MainView.vue'
 | 
				
			||||||
 | 
					import AuthView from '@/views/AuthView.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const routes: RouteRecordRaw[] = [
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    path: '/',
 | 
				
			||||||
 | 
					    name: 'main',
 | 
				
			||||||
 | 
					    component: MainView,
 | 
				
			||||||
 | 
					    meta: { requiresAuth: true }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    path: '/auth',
 | 
				
			||||||
 | 
					    name: 'auth', 
 | 
				
			||||||
 | 
					    component: AuthView
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
							
								
								
									
										153
									
								
								frontend-vue/src/services/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								frontend-vue/src/services/api.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,153 @@
 | 
				
			|||||||
 | 
					import type { Channel, Message, ExtendedMessage, FileAttachment } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ApiService {
 | 
				
			||||||
 | 
					  private baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
 | 
				
			||||||
 | 
					  private token = ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setToken(token: string) {
 | 
				
			||||||
 | 
					    this.token = token
 | 
				
			||||||
 | 
					    console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getHeaders(): HeadersInit {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      'Authorization': this.token,
 | 
				
			||||||
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getFormHeaders(): HeadersInit {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      'Authorization': this.token
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
 | 
				
			||||||
 | 
					    const url = `${this.baseUrl}${endpoint}`
 | 
				
			||||||
 | 
					    const headers = {
 | 
				
			||||||
 | 
					      ...this.getHeaders(),
 | 
				
			||||||
 | 
					      ...options.headers
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log('Making API request to:', url, 'with headers:', headers)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const response = await fetch(url, {
 | 
				
			||||||
 | 
					      ...options,
 | 
				
			||||||
 | 
					      headers
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response.ok) {
 | 
				
			||||||
 | 
					      console.error('API request failed:', response.status, response.statusText)
 | 
				
			||||||
 | 
					      throw new Error(`API request failed: ${response.status} ${response.statusText}`)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return response.json()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Authentication
 | 
				
			||||||
 | 
					  async checkToken(): Promise<boolean> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch(`${this.baseUrl}/check-token`, {
 | 
				
			||||||
 | 
					        headers: { Authorization: this.token }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      return response.ok
 | 
				
			||||||
 | 
					    } catch {
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Channels
 | 
				
			||||||
 | 
					  async getChannels(): Promise<{ channels: Channel[] }> {
 | 
				
			||||||
 | 
					    return this.request('/channels')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async createChannel(name: string): Promise<Channel> {
 | 
				
			||||||
 | 
					    return this.request('/channels', {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ name })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async updateChannel(channelId: number, name: string): Promise<{ message: string }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}`, {
 | 
				
			||||||
 | 
					      method: 'PUT',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ name })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async deleteChannel(channelId: number): Promise<{ message: string }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}`, {
 | 
				
			||||||
 | 
					      method: 'DELETE'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async mergeChannels(sourceChannelId: number, targetChannelId: number): Promise<{ message: string }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${sourceChannelId}/merge`, {
 | 
				
			||||||
 | 
					      method: 'PUT',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ targetChannelId: targetChannelId.toString() })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Messages
 | 
				
			||||||
 | 
					  async getMessages(channelId: number): Promise<{ messages: ExtendedMessage[] }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages`)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async createMessage(channelId: number, content: string): Promise<Message> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages`, {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ content })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async updateMessage(channelId: number, messageId: number, content: string): Promise<{ id: string, content: string }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages/${messageId}`, {
 | 
				
			||||||
 | 
					      method: 'PUT',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ content })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async deleteMessage(channelId: number, messageId: number): Promise<{ message: string }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages/${messageId}`, {
 | 
				
			||||||
 | 
					      method: 'DELETE'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Files
 | 
				
			||||||
 | 
					  async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
 | 
				
			||||||
 | 
					    const formData = new FormData()
 | 
				
			||||||
 | 
					    formData.append('file', file)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const response = await fetch(`${this.baseUrl}/channels/${channelId}/messages/${messageId}/files`, {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      headers: this.getFormHeaders(),
 | 
				
			||||||
 | 
					      body: formData
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response.ok) {
 | 
				
			||||||
 | 
					      throw new Error(`File upload failed: ${response.status} ${response.statusText}`)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return response.json()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getFiles(channelId: number, messageId: number): Promise<{ files: FileAttachment[] }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages/${messageId}/files`)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Search
 | 
				
			||||||
 | 
					  async search(query: string, channelId?: number): Promise<{ results: Message[] }> {
 | 
				
			||||||
 | 
					    const params = new URLSearchParams({ query })
 | 
				
			||||||
 | 
					    if (channelId) {
 | 
				
			||||||
 | 
					      params.append('channelId', channelId.toString())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return this.request(`/search?${params.toString()}`)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // File URL helper
 | 
				
			||||||
 | 
					  getFileUrl(filePath: string): string {
 | 
				
			||||||
 | 
					    return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const apiService = new ApiService()
 | 
				
			||||||
							
								
								
									
										206
									
								
								frontend-vue/src/services/sync.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								frontend-vue/src/services/sync.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,206 @@
 | 
				
			|||||||
 | 
					import { apiService } from './api'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import type { ExtendedMessage, UnsentMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class SyncService {
 | 
				
			||||||
 | 
					  private getAppStore() {
 | 
				
			||||||
 | 
					    return useAppStore()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Sync messages for a channel: merge server data with local data
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async syncChannelMessages(channelId: number): Promise<void> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      console.log(`Syncing messages for channel ${channelId}`)
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      const appStore = this.getAppStore()
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Get server messages
 | 
				
			||||||
 | 
					      const serverResponse = await apiService.getMessages(channelId)
 | 
				
			||||||
 | 
					      const serverMessages = serverResponse.messages
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Get local messages
 | 
				
			||||||
 | 
					      const localMessages = appStore.messages[channelId] || []
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Merge messages using a simple strategy:
 | 
				
			||||||
 | 
					      // 1. Create a map of all messages by ID
 | 
				
			||||||
 | 
					      // 2. Server messages take precedence (they may have been updated)
 | 
				
			||||||
 | 
					      // 3. Keep local messages that don't exist on server (may be unsent)
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      const messageMap = new Map<number, ExtendedMessage>()
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Add local messages first
 | 
				
			||||||
 | 
					      localMessages.forEach(msg => {
 | 
				
			||||||
 | 
					        if (typeof msg.id === 'number') {
 | 
				
			||||||
 | 
					          messageMap.set(msg.id, msg)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Add/update with server messages (server wins for conflicts)
 | 
				
			||||||
 | 
					      serverMessages.forEach((msg: 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()
 | 
				
			||||||
							
								
								
									
										134
									
								
								frontend-vue/src/services/websocket.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								frontend-vue/src/services/websocket.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
				
			|||||||
 | 
					import type { WebSocketEvent } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class WebSocketService {
 | 
				
			||||||
 | 
					  private ws: WebSocket | null = null
 | 
				
			||||||
 | 
					  private reconnectAttempts = 0
 | 
				
			||||||
 | 
					  private maxReconnectAttempts = 5
 | 
				
			||||||
 | 
					  private reconnectInterval = 1000
 | 
				
			||||||
 | 
					  private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  connect() {
 | 
				
			||||||
 | 
					    if (this.ws?.readyState === WebSocket.OPEN) {
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // In development, connect to backend server (port 3000)
 | 
				
			||||||
 | 
					    // In production, use same host as frontend
 | 
				
			||||||
 | 
					    const isDev = import.meta.env.DEV
 | 
				
			||||||
 | 
					    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
 | 
				
			||||||
 | 
					    const host = isDev ? 'localhost:3000' : window.location.host
 | 
				
			||||||
 | 
					    const wsUrl = `${protocol}//${host}`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      this.ws = new WebSocket(wsUrl)
 | 
				
			||||||
 | 
					      this.setupEventListeners()
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('WebSocket connection failed:', error)
 | 
				
			||||||
 | 
					      this.scheduleReconnect()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private setupEventListeners() {
 | 
				
			||||||
 | 
					    if (!this.ws) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.ws.onopen = () => {
 | 
				
			||||||
 | 
					      console.log('WebSocket connected')
 | 
				
			||||||
 | 
					      this.reconnectAttempts = 0
 | 
				
			||||||
 | 
					      this.emit('connected', null)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.ws.onmessage = (event) => {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const data: WebSocketEvent = JSON.parse(event.data)
 | 
				
			||||||
 | 
					        console.log('WebSocket raw message received:', event.data)
 | 
				
			||||||
 | 
					        console.log('Parsed WebSocket data:', data)
 | 
				
			||||||
 | 
					        this.emit(data.type, data.data)
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('Failed to parse WebSocket message:', error, 'Raw data:', event.data)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.ws.onclose = (event) => {
 | 
				
			||||||
 | 
					      console.log('WebSocket disconnected:', event.code, event.reason)
 | 
				
			||||||
 | 
					      this.emit('disconnected', { code: event.code, reason: event.reason })
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
 | 
				
			||||||
 | 
					        this.scheduleReconnect()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.ws.onerror = (error) => {
 | 
				
			||||||
 | 
					      console.error('WebSocket error:', error)
 | 
				
			||||||
 | 
					      this.emit('error', error)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private scheduleReconnect() {
 | 
				
			||||||
 | 
					    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
 | 
				
			||||||
 | 
					      console.error('Max reconnection attempts reached')
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.reconnectAttempts++
 | 
				
			||||||
 | 
					    const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    setTimeout(() => {
 | 
				
			||||||
 | 
					      this.connect()
 | 
				
			||||||
 | 
					    }, delay)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  on(event: string, handler: (data: any) => void) {
 | 
				
			||||||
 | 
					    if (!this.eventHandlers.has(event)) {
 | 
				
			||||||
 | 
					      this.eventHandlers.set(event, [])
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.eventHandlers.get(event)!.push(handler)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  off(event: string, handler: (data: any) => void) {
 | 
				
			||||||
 | 
					    const handlers = this.eventHandlers.get(event)
 | 
				
			||||||
 | 
					    if (handlers) {
 | 
				
			||||||
 | 
					      const index = handlers.indexOf(handler)
 | 
				
			||||||
 | 
					      if (index !== -1) {
 | 
				
			||||||
 | 
					        handlers.splice(index, 1)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private emit(event: string, data: any) {
 | 
				
			||||||
 | 
					    const handlers = this.eventHandlers.get(event)
 | 
				
			||||||
 | 
					    if (handlers) {
 | 
				
			||||||
 | 
					      handlers.forEach(handler => {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          handler(data)
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					          console.error(`Error in WebSocket event handler for ${event}:`, error)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  send(message: any) {
 | 
				
			||||||
 | 
					    if (this.ws?.readyState === WebSocket.OPEN) {
 | 
				
			||||||
 | 
					      this.ws.send(JSON.stringify(message))
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      console.warn('WebSocket not connected, cannot send message')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  disconnect() {
 | 
				
			||||||
 | 
					    if (this.ws) {
 | 
				
			||||||
 | 
					      this.ws.close(1000, 'Client disconnect')
 | 
				
			||||||
 | 
					      this.ws = null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.eventHandlers.clear()
 | 
				
			||||||
 | 
					    this.reconnectAttempts = 0
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  get isConnected(): boolean {
 | 
				
			||||||
 | 
					    return this.ws?.readyState === WebSocket.OPEN
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const websocketService = new WebSocketService()
 | 
				
			||||||
							
								
								
									
										178
									
								
								frontend-vue/src/stores/app.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								frontend-vue/src/stores/app.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
				
			|||||||
 | 
					import { defineStore } from 'pinia'
 | 
				
			||||||
 | 
					import { ref, computed } from 'vue'
 | 
				
			||||||
 | 
					import { get, set } from 'idb-keyval'
 | 
				
			||||||
 | 
					import type { Channel, ExtendedMessage, UnsentMessage, AppSettings } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useAppStore = defineStore('app', () => {
 | 
				
			||||||
 | 
					  // State
 | 
				
			||||||
 | 
					  const channels = ref<Channel[]>([])
 | 
				
			||||||
 | 
					  const currentChannelId = ref<number | null>(null)
 | 
				
			||||||
 | 
					  const messages = ref<Record<number, ExtendedMessage[]>>({})
 | 
				
			||||||
 | 
					  const unsentMessages = ref<UnsentMessage[]>([])
 | 
				
			||||||
 | 
					  const settings = ref<AppSettings>({
 | 
				
			||||||
 | 
					    soundEnabled: true,
 | 
				
			||||||
 | 
					    speechEnabled: true,
 | 
				
			||||||
 | 
					    ttsEnabled: true,
 | 
				
			||||||
 | 
					    ttsRate: 1,
 | 
				
			||||||
 | 
					    ttsPitch: 1,
 | 
				
			||||||
 | 
					    ttsVolume: 1,
 | 
				
			||||||
 | 
					    selectedVoiceURI: null,
 | 
				
			||||||
 | 
					    defaultChannelId: null,
 | 
				
			||||||
 | 
					    theme: 'auto'
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Computed
 | 
				
			||||||
 | 
					  const currentChannel = computed(() => 
 | 
				
			||||||
 | 
					    channels.value.find(c => c.id === currentChannelId.value) || null
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const currentMessages = computed(() => {
 | 
				
			||||||
 | 
					    const channelId = currentChannelId.value
 | 
				
			||||||
 | 
					    const channelMessages = channelId ? messages.value[channelId] || [] : []
 | 
				
			||||||
 | 
					    return channelMessages
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const unsentMessagesForChannel = computed(() => 
 | 
				
			||||||
 | 
					    currentChannelId.value 
 | 
				
			||||||
 | 
					      ? unsentMessages.value.filter(msg => msg.channelId === currentChannelId.value)
 | 
				
			||||||
 | 
					      : []
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Actions
 | 
				
			||||||
 | 
					  const setChannels = (newChannels: Channel[]) => {
 | 
				
			||||||
 | 
					    channels.value = newChannels
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addChannel = (channel: Channel) => {
 | 
				
			||||||
 | 
					    channels.value.push(channel)
 | 
				
			||||||
 | 
					    messages.value[channel.id] = []
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeChannel = (channelId: number) => {
 | 
				
			||||||
 | 
					    channels.value = channels.value.filter(c => c.id !== channelId)
 | 
				
			||||||
 | 
					    delete messages.value[channelId]
 | 
				
			||||||
 | 
					    if (currentChannelId.value === channelId) {
 | 
				
			||||||
 | 
					      currentChannelId.value = channels.value[0]?.id || null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const setCurrentChannel = async (channelId: number | null) => {
 | 
				
			||||||
 | 
					    currentChannelId.value = channelId
 | 
				
			||||||
 | 
					    await set('current_channel_id', channelId)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const setMessages = (channelId: number, channelMessages: ExtendedMessage[]) => {
 | 
				
			||||||
 | 
					    console.log('Store: Setting messages for channel', channelId, ':', channelMessages.length, 'messages')
 | 
				
			||||||
 | 
					    messages.value[channelId] = channelMessages
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addMessage = (message: ExtendedMessage) => {
 | 
				
			||||||
 | 
					    console.log('Store: Adding message to channel', message.channel_id, ':', message)
 | 
				
			||||||
 | 
					    if (!messages.value[message.channel_id]) {
 | 
				
			||||||
 | 
					      messages.value[message.channel_id] = []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    messages.value[message.channel_id].push(message)
 | 
				
			||||||
 | 
					    console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages')
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Note: Auto-save is now handled by the sync service to avoid excessive I/O
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
 | 
				
			||||||
 | 
					    for (const channelId in messages.value) {
 | 
				
			||||||
 | 
					      const channelMessages = messages.value[parseInt(channelId)]
 | 
				
			||||||
 | 
					      const messageIndex = channelMessages.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					      if (messageIndex !== -1) {
 | 
				
			||||||
 | 
					        channelMessages[messageIndex] = { ...channelMessages[messageIndex], ...updates }
 | 
				
			||||||
 | 
					        break
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeMessage = (messageId: number) => {
 | 
				
			||||||
 | 
					    for (const channelId in messages.value) {
 | 
				
			||||||
 | 
					      const channelMessages = messages.value[parseInt(channelId)]
 | 
				
			||||||
 | 
					      const messageIndex = channelMessages.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					      if (messageIndex !== -1) {
 | 
				
			||||||
 | 
					        channelMessages.splice(messageIndex, 1)
 | 
				
			||||||
 | 
					        break
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addUnsentMessage = (message: UnsentMessage) => {
 | 
				
			||||||
 | 
					    unsentMessages.value.push(message)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeUnsentMessage = (messageId: string) => {
 | 
				
			||||||
 | 
					    const index = unsentMessages.value.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					    if (index !== -1) {
 | 
				
			||||||
 | 
					      unsentMessages.value.splice(index, 1)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const updateSettings = async (newSettings: Partial<AppSettings>) => {
 | 
				
			||||||
 | 
					    settings.value = { ...settings.value, ...newSettings }
 | 
				
			||||||
 | 
					    await set('app_settings', 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
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										74
									
								
								frontend-vue/src/stores/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								frontend-vue/src/stores/auth.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
				
			|||||||
 | 
					import { defineStore } from 'pinia'
 | 
				
			||||||
 | 
					import { ref, readonly } from 'vue'
 | 
				
			||||||
 | 
					import { get, set } from 'idb-keyval'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useAuthStore = defineStore('auth', () => {
 | 
				
			||||||
 | 
					  const token = ref<string | null>(null)
 | 
				
			||||||
 | 
					  const isAuthenticated = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const setToken = async (newToken: string) => {
 | 
				
			||||||
 | 
					    token.value = newToken
 | 
				
			||||||
 | 
					    isAuthenticated.value = true
 | 
				
			||||||
 | 
					    await set('auth_token', newToken)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clearAuth = async () => {
 | 
				
			||||||
 | 
					    token.value = null
 | 
				
			||||||
 | 
					    isAuthenticated.value = false
 | 
				
			||||||
 | 
					    await set('auth_token', null)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const checkAuth = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const storedToken = await get('auth_token')
 | 
				
			||||||
 | 
					      if (storedToken) {
 | 
				
			||||||
 | 
					        // Verify token with backend
 | 
				
			||||||
 | 
					        const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
 | 
				
			||||||
 | 
					        const response = await fetch(`${baseUrl}/check-token`, {
 | 
				
			||||||
 | 
					          headers: { Authorization: storedToken }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (response.ok) {
 | 
				
			||||||
 | 
					          token.value = storedToken
 | 
				
			||||||
 | 
					          isAuthenticated.value = true
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          console.warn('Stored token is invalid, clearing auth')
 | 
				
			||||||
 | 
					          await clearAuth()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Auth check failed:', error)
 | 
				
			||||||
 | 
					      await clearAuth()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const authenticate = async (authToken: string): Promise<boolean> => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
 | 
				
			||||||
 | 
					      const response = await fetch(`${baseUrl}/check-token`, {
 | 
				
			||||||
 | 
					        headers: { Authorization: authToken }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (response.ok) {
 | 
				
			||||||
 | 
					        await setToken(authToken)
 | 
				
			||||||
 | 
					        return true
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        await clearAuth()
 | 
				
			||||||
 | 
					        return false
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error('Authentication failed:', error)
 | 
				
			||||||
 | 
					      await clearAuth()
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    token,
 | 
				
			||||||
 | 
					    isAuthenticated,
 | 
				
			||||||
 | 
					    setToken,
 | 
				
			||||||
 | 
					    clearAuth,
 | 
				
			||||||
 | 
					    checkAuth,
 | 
				
			||||||
 | 
					    authenticate
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										52
									
								
								frontend-vue/src/stores/toast.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend-vue/src/stores/toast.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					import { defineStore } from 'pinia'
 | 
				
			||||||
 | 
					import { ref, readonly } from 'vue'
 | 
				
			||||||
 | 
					import type { ToastMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useToastStore = defineStore('toast', () => {
 | 
				
			||||||
 | 
					  const toasts = ref<ToastMessage[]>([])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addToast = (message: string, type: ToastMessage['type'] = 'info', duration = 3000) => {
 | 
				
			||||||
 | 
					    const id = Date.now().toString()
 | 
				
			||||||
 | 
					    const toast: ToastMessage = {
 | 
				
			||||||
 | 
					      id,
 | 
				
			||||||
 | 
					      message,
 | 
				
			||||||
 | 
					      type,
 | 
				
			||||||
 | 
					      duration
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    toasts.value.push(toast)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (duration > 0) {
 | 
				
			||||||
 | 
					      setTimeout(() => {
 | 
				
			||||||
 | 
					        removeToast(id)
 | 
				
			||||||
 | 
					      }, duration)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return id
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const removeToast = (id: string) => {
 | 
				
			||||||
 | 
					    const index = toasts.value.findIndex(toast => toast.id === id)
 | 
				
			||||||
 | 
					    if (index > -1) {
 | 
				
			||||||
 | 
					      toasts.value.splice(index, 1)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clearToasts = () => {
 | 
				
			||||||
 | 
					    toasts.value = []
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const success = (message: string, duration?: number) => addToast(message, 'success', duration)
 | 
				
			||||||
 | 
					  const error = (message: string, duration?: number) => addToast(message, 'error', duration)
 | 
				
			||||||
 | 
					  const info = (message: string, duration?: number) => addToast(message, 'info', duration)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    toasts,
 | 
				
			||||||
 | 
					    addToast,
 | 
				
			||||||
 | 
					    removeToast,
 | 
				
			||||||
 | 
					    clearToasts,
 | 
				
			||||||
 | 
					    success,
 | 
				
			||||||
 | 
					    error,
 | 
				
			||||||
 | 
					    info
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										40
									
								
								frontend-vue/src/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								frontend-vue/src/style.css
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										124
									
								
								frontend-vue/src/types/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								frontend-vue/src/types/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
				
			|||||||
 | 
					// API Types matching backend schema
 | 
				
			||||||
 | 
					export interface Channel {
 | 
				
			||||||
 | 
					  id: number
 | 
				
			||||||
 | 
					  name: string
 | 
				
			||||||
 | 
					  created_at: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface Message {
 | 
				
			||||||
 | 
					  id: number
 | 
				
			||||||
 | 
					  channel_id: number
 | 
				
			||||||
 | 
					  content: string
 | 
				
			||||||
 | 
					  created_at: string
 | 
				
			||||||
 | 
					  file_id?: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface MessageWithFile extends Message {
 | 
				
			||||||
 | 
					  fileId?: number
 | 
				
			||||||
 | 
					  filePath?: string
 | 
				
			||||||
 | 
					  fileType?: string
 | 
				
			||||||
 | 
					  fileSize?: number
 | 
				
			||||||
 | 
					  originalName?: string
 | 
				
			||||||
 | 
					  fileCreatedAt?: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface FileAttachment {
 | 
				
			||||||
 | 
					  id: number
 | 
				
			||||||
 | 
					  channel_id: number
 | 
				
			||||||
 | 
					  message_id: number
 | 
				
			||||||
 | 
					  file_path: string
 | 
				
			||||||
 | 
					  file_type: string
 | 
				
			||||||
 | 
					  file_size: number
 | 
				
			||||||
 | 
					  original_name: string
 | 
				
			||||||
 | 
					  created_at: string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// For compatibility, ExtendedMessage now represents the flattened structure from backend
 | 
				
			||||||
 | 
					export interface ExtendedMessage extends MessageWithFile {
 | 
				
			||||||
 | 
					  files?: FileAttachment[] // Keep for backward compatibility but won't be used
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Mutable versions for store operations
 | 
				
			||||||
 | 
					export interface MutableMessage {
 | 
				
			||||||
 | 
					  id: number
 | 
				
			||||||
 | 
					  channel_id: number
 | 
				
			||||||
 | 
					  content: string
 | 
				
			||||||
 | 
					  created_at: string
 | 
				
			||||||
 | 
					  file_id?: number
 | 
				
			||||||
 | 
					  files?: FileAttachment[]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// WebSocket Event Types
 | 
				
			||||||
 | 
					export interface WebSocketEvent {
 | 
				
			||||||
 | 
					  type: 'message-created' | 'message-updated' | 'message-deleted' | 
 | 
				
			||||||
 | 
					        'file-uploaded' | 'channel-created' | 'channel-deleted' | 
 | 
				
			||||||
 | 
					        'channel-merged' | 'channel-updated'
 | 
				
			||||||
 | 
					  data: any
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Frontend State Types
 | 
				
			||||||
 | 
					export interface AppState {
 | 
				
			||||||
 | 
					  isAuthenticated: boolean
 | 
				
			||||||
 | 
					  currentChannelId: number | null
 | 
				
			||||||
 | 
					  channels: Channel[]
 | 
				
			||||||
 | 
					  messages: Record<number, ExtendedMessage[]>
 | 
				
			||||||
 | 
					  unsentMessages: UnsentMessage[]
 | 
				
			||||||
 | 
					  settings: AppSettings
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface UnsentMessage {
 | 
				
			||||||
 | 
					  id: string
 | 
				
			||||||
 | 
					  channelId: number
 | 
				
			||||||
 | 
					  content: string
 | 
				
			||||||
 | 
					  timestamp: number
 | 
				
			||||||
 | 
					  retries: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface AppSettings {
 | 
				
			||||||
 | 
					  soundEnabled: boolean
 | 
				
			||||||
 | 
					  speechEnabled: boolean
 | 
				
			||||||
 | 
					  ttsEnabled: boolean
 | 
				
			||||||
 | 
					  ttsRate: number
 | 
				
			||||||
 | 
					  ttsPitch: number
 | 
				
			||||||
 | 
					  ttsVolume: number
 | 
				
			||||||
 | 
					  selectedVoiceURI: string | null
 | 
				
			||||||
 | 
					  defaultChannelId: number | null
 | 
				
			||||||
 | 
					  theme: 'light' | 'dark' | 'auto'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Audio Types
 | 
				
			||||||
 | 
					export interface AudioState {
 | 
				
			||||||
 | 
					  isRecording: boolean
 | 
				
			||||||
 | 
					  recordingTime: number
 | 
				
			||||||
 | 
					  audioBlob: Blob | null
 | 
				
			||||||
 | 
					  isPlaying: boolean
 | 
				
			||||||
 | 
					  playbackTime: number
 | 
				
			||||||
 | 
					  duration: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// UI State Types
 | 
				
			||||||
 | 
					export interface ToastMessage {
 | 
				
			||||||
 | 
					  id: string
 | 
				
			||||||
 | 
					  message: string
 | 
				
			||||||
 | 
					  type: 'success' | 'error' | 'info'
 | 
				
			||||||
 | 
					  duration?: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface DialogState {
 | 
				
			||||||
 | 
					  isOpen: boolean
 | 
				
			||||||
 | 
					  component: string | null
 | 
				
			||||||
 | 
					  props: Record<string, any>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Search Types
 | 
				
			||||||
 | 
					export interface SearchResult {
 | 
				
			||||||
 | 
					  message: ExtendedMessage
 | 
				
			||||||
 | 
					  channel: Channel
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// File Upload Types
 | 
				
			||||||
 | 
					export interface UploadProgress {
 | 
				
			||||||
 | 
					  loaded: number
 | 
				
			||||||
 | 
					  total: number
 | 
				
			||||||
 | 
					  percentage: number
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										146
									
								
								frontend-vue/src/views/AuthView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								frontend-vue/src/views/AuthView.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,146 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="auth-view">
 | 
				
			||||||
 | 
					    <div class="auth-card">
 | 
				
			||||||
 | 
					      <div class="auth-card__header">
 | 
				
			||||||
 | 
					        <h1 class="auth-card__title">Welcome to Notebrook</h1>
 | 
				
			||||||
 | 
					        <p class="auth-card__subtitle">Enter your authentication token to continue</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <form @submit.prevent="handleAuth" class="auth-form">
 | 
				
			||||||
 | 
					        <BaseInput
 | 
				
			||||||
 | 
					          v-model="token"
 | 
				
			||||||
 | 
					          type="password"
 | 
				
			||||||
 | 
					          label="Authentication Token"
 | 
				
			||||||
 | 
					          placeholder="Enter your token"
 | 
				
			||||||
 | 
					          required
 | 
				
			||||||
 | 
					          :error="error"
 | 
				
			||||||
 | 
					          :disabled="isLoading"
 | 
				
			||||||
 | 
					          @keydown.enter="handleAuth"
 | 
				
			||||||
 | 
					          ref="tokenInput"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          type="submit"
 | 
				
			||||||
 | 
					          :loading="isLoading"
 | 
				
			||||||
 | 
					          :disabled="!token.trim()"
 | 
				
			||||||
 | 
					          class="auth-form__submit"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {{ isLoading ? 'Authenticating...' : 'Sign In' }}
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </form>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					import { useAuthStore } from '@/stores/auth'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
 | 
					import BaseInput from '@/components/base/BaseInput.vue'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					const authStore = useAuthStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					const { playSound } = useAudio()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const token = ref('')
 | 
				
			||||||
 | 
					const error = ref('')
 | 
				
			||||||
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const tokenInput = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleAuth = async () => {
 | 
				
			||||||
 | 
					  if (!token.value.trim()) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  isLoading.value = true
 | 
				
			||||||
 | 
					  error.value = ''
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const success = await authStore.authenticate(token.value.trim())
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (success) {
 | 
				
			||||||
 | 
					      await playSound('login')
 | 
				
			||||||
 | 
					      toastStore.success('Authentication successful!')
 | 
				
			||||||
 | 
					      router.push('/')
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      error.value = 'Invalid authentication token'
 | 
				
			||||||
 | 
					      tokenInput.value?.focus()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    error.value = 'Authentication failed. Please try again.'
 | 
				
			||||||
 | 
					    console.error('Auth error:', err)
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isLoading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  tokenInput.value?.focus()
 | 
				
			||||||
 | 
					  playSound('intro')
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.auth-view {
 | 
				
			||||||
 | 
					  height: 100vh;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-card {
 | 
				
			||||||
 | 
					  background: #ffffff;
 | 
				
			||||||
 | 
					  border-radius: 16px;
 | 
				
			||||||
 | 
					  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  max-width: 400px;
 | 
				
			||||||
 | 
					  padding: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-card__header {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  margin-bottom: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-card__title {
 | 
				
			||||||
 | 
					  font-size: 2rem;
 | 
				
			||||||
 | 
					  font-weight: 700;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					  margin: 0 0 0.5rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-card__subtitle {
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.auth-form__submit {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .auth-card {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .auth-card__title {
 | 
				
			||||||
 | 
					    color: rgba(255, 255, 255, 0.87);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .auth-card__subtitle {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										618
									
								
								frontend-vue/src/views/MainView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										618
									
								
								frontend-vue/src/views/MainView.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										13
									
								
								frontend-vue/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend-vue/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "extends": "@vue/tsconfig/tsconfig.dom.json",
 | 
				
			||||||
 | 
					  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
 | 
				
			||||||
 | 
					  "exclude": ["src/**/__tests__/*"],
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "composite": true,
 | 
				
			||||||
 | 
					    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
 | 
				
			||||||
 | 
					    "baseUrl": ".",
 | 
				
			||||||
 | 
					    "paths": {
 | 
				
			||||||
 | 
					      "@/*": ["./src/*"]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										48
									
								
								frontend-vue/vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend-vue/vite.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					import { defineConfig } from 'vite'
 | 
				
			||||||
 | 
					import vue from '@vitejs/plugin-vue'
 | 
				
			||||||
 | 
					import { VitePWA } from 'vite-plugin-pwa'
 | 
				
			||||||
 | 
					import { fileURLToPath, URL } from 'node:url'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default defineConfig({
 | 
				
			||||||
 | 
					  plugins: [
 | 
				
			||||||
 | 
					    vue(),
 | 
				
			||||||
 | 
					    VitePWA({
 | 
				
			||||||
 | 
					      registerType: 'autoUpdate',
 | 
				
			||||||
 | 
					      workbox: {
 | 
				
			||||||
 | 
					        globPatterns: ['**/*.{js,css,html,ico,png,svg,wav,mp3}']
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'sounds/*.wav'],
 | 
				
			||||||
 | 
					      manifest: {
 | 
				
			||||||
 | 
					        name: 'Notebrook',
 | 
				
			||||||
 | 
					        short_name: 'Notebrook',
 | 
				
			||||||
 | 
					        description: 'Light note taking app in messenger style',
 | 
				
			||||||
 | 
					        theme_color: '#ffffff',
 | 
				
			||||||
 | 
					        background_color: '#ffffff',
 | 
				
			||||||
 | 
					        display: 'standalone',
 | 
				
			||||||
 | 
					        icons: [
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            src: 'pwa-192x192.png',
 | 
				
			||||||
 | 
					            sizes: '192x192',
 | 
				
			||||||
 | 
					            type: 'image/png'
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            src: 'pwa-512x512.png',
 | 
				
			||||||
 | 
					            sizes: '512x512',
 | 
				
			||||||
 | 
					            type: 'image/png'
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  resolve: {
 | 
				
			||||||
 | 
					    alias: {
 | 
				
			||||||
 | 
					      '@': fileURLToPath(new URL('./src', import.meta.url))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  server: {
 | 
				
			||||||
 | 
					    port: 5173
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  build: {
 | 
				
			||||||
 | 
					    outDir: 'dist'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/public/copy.wav
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/copy.wav
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -3,6 +3,7 @@ const audioContext = new AudioContext();
 | 
				
			|||||||
const soundFiles = {
 | 
					const soundFiles = {
 | 
				
			||||||
    intro: 'intro.wav',
 | 
					    intro: 'intro.wav',
 | 
				
			||||||
    login: 'login.wav',
 | 
					    login: 'login.wav',
 | 
				
			||||||
 | 
					    copy: 'copy.wav',
 | 
				
			||||||
    uploadFailed: 'uploadfail.wav'
 | 
					    uploadFailed: 'uploadfail.wav'
 | 
				
			||||||
} as const;
 | 
					} as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -246,7 +246,12 @@ export class MainView extends View {
 | 
				
			|||||||
        itm.onClick(() => {
 | 
					        itm.onClick(() => {
 | 
				
			||||||
            this.openMessageDialog(message);
 | 
					            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") {
 | 
					            if (key === "Delete") {
 | 
				
			||||||
                await this.removeMessage(message.id);
 | 
					                await this.removeMessage(message.id);
 | 
				
			||||||
                if (this.messageList.children.length === 0) {
 | 
					                if (this.messageList.children.length === 0) {
 | 
				
			||||||
@@ -609,6 +614,24 @@ export class MainView extends View {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private handleHotkey(e: KeyboardEvent) {
 | 
					    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) {
 | 
					        if (e.ctrlKey && e.shiftKey) {
 | 
				
			||||||
            const action = this.hotkeyMap.get(e.key.toLowerCase());
 | 
					            const action = this.hotkeyMap.get(e.key.toLowerCase());
 | 
				
			||||||
            if (action) {
 | 
					            if (action) {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user