Compare commits
	
		
			13 Commits
		
	
	
		
			6585ec2abb
			...
			feat/new-f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 60c2a18dbe | |||
| 6286c1e0c9 | |||
| 28f6fad818 | |||
| 5c76c35d81 | |||
| 2b1bf5040f | |||
| 22b8392fd5 | |||
| 9948d1c25b | |||
| cf15a0f9c2 | |||
| 452192d0a9 | |||
| 0d50359dae | |||
| 420ff46f05 | |||
| b312065d3d | |||
| b07916309e | 
@@ -51,4 +51,30 @@ export const getMessages = async (req: Request, res: Response) => {
 | 
				
			|||||||
    const messages = await MessageService.getMessages(channelId);
 | 
					    const messages = await MessageService.getMessages(channelId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.json({ messages });
 | 
					    res.json({ messages });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const moveMessage = async (req: Request, res: Response) => {
 | 
				
			||||||
 | 
					    const { messageId } = req.params;
 | 
				
			||||||
 | 
					    const { targetChannelId } = req.body;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!messageId || !targetChannelId) {
 | 
				
			||||||
 | 
					        return res.status(400).json({ error: 'Message ID and target channel ID are required' });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        const result = await MessageService.moveMessage(messageId, targetChannelId);
 | 
				
			||||||
 | 
					        logger.info(`Message ${messageId} moved to channel ${targetChannelId}`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        res.json({ 
 | 
				
			||||||
 | 
					            message: 'Message moved successfully',
 | 
				
			||||||
 | 
					            messageId: parseInt(messageId),
 | 
				
			||||||
 | 
					            targetChannelId: parseInt(targetChannelId)
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    } catch (error: any) {
 | 
				
			||||||
 | 
					        if (error.message === 'Message not found') {
 | 
				
			||||||
 | 
					            return res.status(404).json({ error: 'Message not found' });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        logger.critical(`Failed to move message ${messageId}:`, error);
 | 
				
			||||||
 | 
					        res.status(500).json({ error: 'Failed to move message' });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => {
 | 
				
			|||||||
    events.on('message-deleted', (id) => {
 | 
					    events.on('message-deleted', (id) => {
 | 
				
			||||||
        ws.send(JSON.stringify({ type: 'message-deleted', data: {id }}));
 | 
					        ws.send(JSON.stringify({ type: 'message-deleted', data: {id }}));
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    events.on('message-moved', (messageId, sourceChannelId, targetChannelId) => {
 | 
				
			||||||
 | 
					        ws.send(JSON.stringify({ type: 'message-moved', data: {messageId, sourceChannelId, targetChannelId }}));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
    events.on('channel-created', (channel) => {
 | 
					    events.on('channel-created', (channel) => {
 | 
				
			||||||
        ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
 | 
					        ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@ export const router = Router({mergeParams: true});
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
router.post('/', authenticate, MessageController.createMessage);
 | 
					router.post('/', authenticate, MessageController.createMessage);
 | 
				
			||||||
router.put('/:messageId', authenticate, MessageController.updateMessage);
 | 
					router.put('/:messageId', authenticate, MessageController.updateMessage);
 | 
				
			||||||
 | 
					router.put('/:messageId/move', authenticate, MessageController.moveMessage);
 | 
				
			||||||
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
 | 
					router.delete('/:messageId', authenticate, MessageController.deleteMessage);
 | 
				
			||||||
router.get('/', authenticate, MessageController.getMessages);
 | 
					router.get('/', authenticate, MessageController.getMessages);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -80,4 +80,28 @@ export const getMessage = async (id: string) => {
 | 
				
			|||||||
      `);
 | 
					      `);
 | 
				
			||||||
  const row = query.get({ id: id });
 | 
					  const row = query.get({ id: id });
 | 
				
			||||||
  return row;
 | 
					  return row;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const moveMessage = async (messageId: string, targetChannelId: string) => {
 | 
				
			||||||
 | 
					  // Get current message to emit proper events
 | 
				
			||||||
 | 
					  const currentMessage = await getMessage(messageId);
 | 
				
			||||||
 | 
					  if (!currentMessage) {
 | 
				
			||||||
 | 
					    throw new Error('Message not found');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const query = db.prepare(`UPDATE messages SET channelId = $targetChannelId WHERE id = $messageId`);
 | 
				
			||||||
 | 
					  const result = query.run({ messageId: messageId, targetChannelId: targetChannelId });
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (result.changes === 0) {
 | 
				
			||||||
 | 
					    throw new Error('Message not found or not updated');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // Update FTS table if enabled
 | 
				
			||||||
 | 
					  if (FTS5Enabled) {
 | 
				
			||||||
 | 
					    // FTS table doesn't need channelId update, just content remains searchable
 | 
				
			||||||
 | 
					    // No additional FTS changes needed since content hasn't changed
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  events.emit('message-moved', messageId, (currentMessage as any).channelId, targetChannelId);
 | 
				
			||||||
 | 
					  return result;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										228
									
								
								frontend-vue/-
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								frontend-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>
 | 
				
			||||||
@@ -3,11 +3,11 @@
 | 
				
			|||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <meta charset="UTF-8">
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
    <link rel="icon" href="/favicon.ico">
 | 
					    <link rel="icon" href="/favicon.ico">
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
 | 
				
			||||||
    <title>Notebrook</title>
 | 
					    <title>Notebrook</title>
 | 
				
			||||||
    <meta name="description" content="Light note taking app in messenger style">
 | 
					    <meta name="description" content="Light note taking app in messenger style">
 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
  <body>
 | 
					  <body role="application">
 | 
				
			||||||
    <div id="app"></div>
 | 
					    <div id="app"></div>
 | 
				
			||||||
    <script type="module" src="/src/main.ts"></script>
 | 
					    <script type="module" src="/src/main.ts"></script>
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,7 @@ const toastStore = useToastStore()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<style>
 | 
					<style>
 | 
				
			||||||
#app {
 | 
					#app {
 | 
				
			||||||
  height: 100vh;
 | 
					  height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
  width: 100vw;
 | 
					  width: 100vw;
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,7 +24,7 @@
 | 
				
			|||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  type?: 'button' | 'submit' | 'reset'
 | 
					  type?: 'button' | 'submit' | 'reset'
 | 
				
			||||||
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
 | 
					  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
 | 
				
			||||||
  size?: 'sm' | 'md' | 'lg'
 | 
					  size?: 'xs' | 'sm' | 'md' | 'lg'
 | 
				
			||||||
  disabled?: boolean
 | 
					  disabled?: boolean
 | 
				
			||||||
  loading?: boolean
 | 
					  loading?: boolean
 | 
				
			||||||
  ariaLabel?: string
 | 
					  ariaLabel?: string
 | 
				
			||||||
@@ -65,6 +65,12 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
  transition: all 0.2s ease;
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
  outline: none;
 | 
					  outline: none;
 | 
				
			||||||
  text-decoration: none;
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  /* iOS-specific optimizations */
 | 
				
			||||||
 | 
					  -webkit-appearance: none;
 | 
				
			||||||
 | 
					  -webkit-tap-highlight-color: transparent;
 | 
				
			||||||
 | 
					  -webkit-touch-callout: none;
 | 
				
			||||||
 | 
					  -webkit-user-select: none;
 | 
				
			||||||
 | 
					  user-select: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-button:focus-visible {
 | 
					.base-button:focus-visible {
 | 
				
			||||||
@@ -78,19 +84,32 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Sizes */
 | 
					/* Sizes */
 | 
				
			||||||
 | 
					.base-button--xs {
 | 
				
			||||||
 | 
					  padding: 0.375rem 0.5rem;
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  min-height: 2.25rem; /* 36px - smaller but still usable */
 | 
				
			||||||
 | 
					  min-width: 2.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-button--sm {
 | 
					.base-button--sm {
 | 
				
			||||||
  padding: 0.5rem 0.75rem;
 | 
					  padding: 0.5rem 0.75rem;
 | 
				
			||||||
  font-size: 0.875rem;
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  min-height: 2.75rem; /* 44px minimum for iOS touch targets */
 | 
				
			||||||
 | 
					  min-width: 2.75rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-button--md {
 | 
					.base-button--md {
 | 
				
			||||||
  padding: 0.75rem 1rem;
 | 
					  padding: 0.75rem 1rem;
 | 
				
			||||||
  font-size: 1rem;
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  min-height: 2.75rem;
 | 
				
			||||||
 | 
					  min-width: 2.75rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-button--lg {
 | 
					.base-button--lg {
 | 
				
			||||||
  padding: 1rem 1.5rem;
 | 
					  padding: 1rem 1.5rem;
 | 
				
			||||||
  font-size: 1.125rem;
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  min-height: 3rem;
 | 
				
			||||||
 | 
					  min-width: 3rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Variants */
 | 
					/* Variants */
 | 
				
			||||||
@@ -126,6 +145,19 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
.base-button--ghost {
 | 
					.base-button--ghost {
 | 
				
			||||||
  background-color: transparent;
 | 
					  background-color: transparent;
 | 
				
			||||||
  color: #646cff;
 | 
					  color: #646cff;
 | 
				
			||||||
 | 
					  /* Ensure ghost buttons always meet minimum touch targets */
 | 
				
			||||||
 | 
					  min-height: 2.75rem;
 | 
				
			||||||
 | 
					  min-width: 2.75rem;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Adjust xs ghost buttons for better emoji display */
 | 
				
			||||||
 | 
					.base-button--ghost.base-button--xs {
 | 
				
			||||||
 | 
					  min-height: 2.25rem;
 | 
				
			||||||
 | 
					  min-width: 2.25rem;
 | 
				
			||||||
 | 
					  padding: 0.25rem; /* Tighter padding for emoji buttons */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-button--ghost:hover:not(:disabled) {
 | 
					.base-button--ghost:hover:not(:disabled) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -157,6 +157,9 @@ defineExpose({
 | 
				
			|||||||
  outline: none;
 | 
					  outline: none;
 | 
				
			||||||
  resize: vertical;
 | 
					  resize: vertical;
 | 
				
			||||||
  min-height: 3rem;
 | 
					  min-height: 3rem;
 | 
				
			||||||
 | 
					  /* iOS-specific optimizations */
 | 
				
			||||||
 | 
					  -webkit-appearance: none;
 | 
				
			||||||
 | 
					  -webkit-border-radius: 8px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.base-textarea__field:focus {
 | 
					.base-textarea__field:focus {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@
 | 
				
			|||||||
  <div class="input-actions">
 | 
					  <div class="input-actions">
 | 
				
			||||||
    <BaseButton
 | 
					    <BaseButton
 | 
				
			||||||
      variant="ghost"
 | 
					      variant="ghost"
 | 
				
			||||||
      size="sm"
 | 
					      size="xs"
 | 
				
			||||||
      @click="$emit('file-upload')"
 | 
					      @click="$emit('file-upload')"
 | 
				
			||||||
      aria-label="Upload file"
 | 
					      aria-label="Upload file"
 | 
				
			||||||
      :disabled="disabled"
 | 
					      :disabled="disabled"
 | 
				
			||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    <BaseButton
 | 
					    <BaseButton
 | 
				
			||||||
      variant="ghost"
 | 
					      variant="ghost"
 | 
				
			||||||
      size="sm"
 | 
					      size="xs"
 | 
				
			||||||
      @click="$emit('camera')"
 | 
					      @click="$emit('camera')"
 | 
				
			||||||
      aria-label="Take photo"
 | 
					      aria-label="Take photo"
 | 
				
			||||||
      :disabled="disabled"
 | 
					      :disabled="disabled"
 | 
				
			||||||
@@ -22,7 +22,7 @@
 | 
				
			|||||||
    
 | 
					    
 | 
				
			||||||
    <BaseButton
 | 
					    <BaseButton
 | 
				
			||||||
      variant="ghost"
 | 
					      variant="ghost"
 | 
				
			||||||
      size="sm"
 | 
					      size="xs"
 | 
				
			||||||
      @click="$emit('voice')"
 | 
					      @click="$emit('voice')"
 | 
				
			||||||
      aria-label="Record voice message"
 | 
					      aria-label="Record voice message"
 | 
				
			||||||
      :disabled="disabled"
 | 
					      :disabled="disabled"
 | 
				
			||||||
@@ -67,7 +67,7 @@ defineEmits<{
 | 
				
			|||||||
.input-actions {
 | 
					.input-actions {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  gap: 0.5rem;
 | 
					  gap: 0.25rem; /* Reduced gap to save space */
 | 
				
			||||||
  flex-shrink: 0;
 | 
					  flex-shrink: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
@@ -76,6 +76,7 @@ defineExpose({
 | 
				
			|||||||
<style scoped>
 | 
					<style scoped>
 | 
				
			||||||
.message-input-container {
 | 
					.message-input-container {
 | 
				
			||||||
  padding: 1rem;
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  padding-bottom: calc(1rem + var(--safe-area-inset-bottom));
 | 
				
			||||||
  background: white;
 | 
					  background: white;
 | 
				
			||||||
  border-top: 1px solid #e5e7eb;
 | 
					  border-top: 1px solid #e5e7eb;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -83,10 +84,35 @@ defineExpose({
 | 
				
			|||||||
.message-input {
 | 
					.message-input {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: flex-end;
 | 
					  align-items: flex-end;
 | 
				
			||||||
  gap: 0.75rem;
 | 
					  gap: 0.5rem; /* Reduced gap to save space */
 | 
				
			||||||
  max-width: 100%;
 | 
					  max-width: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.message-input :deep(.base-textarea) {
 | 
				
			||||||
 | 
					  flex: 1; /* Take all available space */
 | 
				
			||||||
 | 
					  min-width: 200px; /* Ensure minimum usable width */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.message-input :deep(.input-actions) {
 | 
				
			||||||
 | 
					  flex-shrink: 0; /* Don't allow action buttons to shrink */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Mobile responsiveness */
 | 
				
			||||||
 | 
					@media (max-width: 480px) {
 | 
				
			||||||
 | 
					  .message-input-container {
 | 
				
			||||||
 | 
					    padding: 0.75rem; /* Slightly less padding on very small screens */
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .message-input :deep(.base-textarea) {
 | 
				
			||||||
 | 
					    min-width: 150px; /* Allow smaller minimum width on mobile */
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  /* Ensure buttons remain accessible on small screens */
 | 
				
			||||||
 | 
					  .message-input :deep(.input-actions) {
 | 
				
			||||||
 | 
					    gap: 0.125rem; /* Even tighter gap on mobile */
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Dark mode */
 | 
					/* Dark mode */
 | 
				
			||||||
@media (prefers-color-scheme: dark) {
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
  .message-input-container {
 | 
					  .message-input-container {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,11 +4,13 @@
 | 
				
			|||||||
      'message',
 | 
					      'message',
 | 
				
			||||||
      { 'message--unsent': isUnsent }
 | 
					      { 'message--unsent': isUnsent }
 | 
				
			||||||
    ]"
 | 
					    ]"
 | 
				
			||||||
 | 
					    ref="rootEl"
 | 
				
			||||||
    :data-message-id="message.id"
 | 
					    :data-message-id="message.id"
 | 
				
			||||||
    :tabindex="tabindex || 0"
 | 
					    :tabindex="tabindex || -1"
 | 
				
			||||||
    :aria-label="messageAriaLabel"
 | 
					    :aria-label="messageAriaLabel"
 | 
				
			||||||
    role="listitem"
 | 
					    role="option"
 | 
				
			||||||
    @keydown="handleKeydown"
 | 
					    @keydown="handleKeydown"
 | 
				
			||||||
 | 
					    @click="handleClick"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <div class="message__content">
 | 
					    <div class="message__content">
 | 
				
			||||||
      {{ message.content }}
 | 
					      {{ message.content }}
 | 
				
			||||||
@@ -33,10 +35,12 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup lang="ts">
 | 
					<script setup lang="ts">
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { computed, ref, nextTick } from 'vue'
 | 
				
			||||||
import { useAudio } from '@/composables/useAudio'
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
import { useToastStore } from '@/stores/toast'
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
import { useAppStore } from '@/stores/app'
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { apiService } from '@/services/api'
 | 
				
			||||||
 | 
					import { syncService } from '@/services/sync'
 | 
				
			||||||
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
 | 
					import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
 | 
				
			||||||
import FileAttachment from './FileAttachment.vue'
 | 
					import FileAttachment from './FileAttachment.vue'
 | 
				
			||||||
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
 | 
					import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
 | 
				
			||||||
@@ -47,6 +51,10 @@ interface Props {
 | 
				
			|||||||
  tabindex?: number
 | 
					  tabindex?: number
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  'open-dialog': [message: ExtendedMessage | UnsentMessage]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<Props>(), {
 | 
					const props = withDefaults(defineProps<Props>(), {
 | 
				
			||||||
  isUnsent: false
 | 
					  isUnsent: false
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -57,6 +65,17 @@ const { speak, playSound } = useAudio()
 | 
				
			|||||||
const toastStore = useToastStore()
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
const appStore = useAppStore()
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Root element ref for DOM-based focus management
 | 
				
			||||||
 | 
					const rootEl = ref<HTMLElement | null>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Fallback: focus the chat input textarea
 | 
				
			||||||
 | 
					const focusFallbackToInput = () => {
 | 
				
			||||||
 | 
					  const inputEl = document.querySelector('.message-input .base-textarea__field') as HTMLElement | null
 | 
				
			||||||
 | 
					  if (inputEl) {
 | 
				
			||||||
 | 
					    inputEl.focus()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Check if message has a file attachment
 | 
					// Check if message has a file attachment
 | 
				
			||||||
const hasFileAttachment = computed(() => {
 | 
					const hasFileAttachment = computed(() => {
 | 
				
			||||||
  return 'fileId' in props.message && !!props.message.fileId
 | 
					  return 'fileId' in props.message && !!props.message.fileId
 | 
				
			||||||
@@ -141,6 +160,13 @@ const getFileType = (filename: string): string => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleClick = () => {
 | 
				
			||||||
 | 
					  // Only open dialog for sent messages (not unsent ones)
 | 
				
			||||||
 | 
					  if (!props.isUnsent) {
 | 
				
			||||||
 | 
					    emit('open-dialog', props.message)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleKeydown = (event: KeyboardEvent) => {
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
  // Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
 | 
					  // Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
 | 
				
			||||||
  if (event.ctrlKey || event.metaKey || event.altKey) {
 | 
					  if (event.ctrlKey || event.metaKey || event.altKey) {
 | 
				
			||||||
@@ -160,6 +186,92 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      toastStore.info('Text-to-speech is disabled')
 | 
					      toastStore.info('Text-to-speech is disabled')
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					  } else if (event.key === 'Delete') {
 | 
				
			||||||
 | 
					    event.preventDefault()
 | 
				
			||||||
 | 
					    handleDelete()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Delete current message (supports sent and unsent)
 | 
				
			||||||
 | 
					const handleDelete = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Capture neighboring elements before removal
 | 
				
			||||||
 | 
					    const current = rootEl.value
 | 
				
			||||||
 | 
					    const prevEl = (current?.previousElementSibling as HTMLElement | null) || null
 | 
				
			||||||
 | 
					    const nextEl = (current?.nextElementSibling as HTMLElement | null) || null
 | 
				
			||||||
 | 
					    const isFirst = !prevEl
 | 
				
			||||||
 | 
					    const targetToFocus = isFirst ? nextEl : prevEl
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (props.isUnsent) {
 | 
				
			||||||
 | 
					      // Unsent local message
 | 
				
			||||||
 | 
					      const unsent = props.message as UnsentMessage
 | 
				
			||||||
 | 
					      appStore.removeUnsentMessage(unsent.id)
 | 
				
			||||||
 | 
					      toastStore.success('Unsent message removed')
 | 
				
			||||||
 | 
					      // focus the closest message
 | 
				
			||||||
 | 
					      await nextTick()
 | 
				
			||||||
 | 
					      if (targetToFocus && document.contains(targetToFocus)) {
 | 
				
			||||||
 | 
					        if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
				
			||||||
 | 
					        targetToFocus.focus()
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        focusFallbackToInput()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Sent message: optimistic removal, then server delete
 | 
				
			||||||
 | 
					    const msg = props.message as ExtendedMessage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Capture original position for potential rollback
 | 
				
			||||||
 | 
					    const channelMessages = appStore.messages[msg.channel_id] || []
 | 
				
			||||||
 | 
					    const originalIndex = channelMessages.findIndex(m => m.id === msg.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Optimistically remove from local state for snappy UI
 | 
				
			||||||
 | 
					    appStore.removeMessage(msg.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Focus the closest message immediately after local removal
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					    if (targetToFocus && document.contains(targetToFocus)) {
 | 
				
			||||||
 | 
					      if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
				
			||||||
 | 
					      targetToFocus.focus()
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      focusFallbackToInput()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await apiService.deleteMessage(msg.channel_id, msg.id)
 | 
				
			||||||
 | 
					      // Attempt to sync the channel to reconcile with server state
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await syncService.syncChannelMessages(msg.channel_id)
 | 
				
			||||||
 | 
					      } catch (syncError) {
 | 
				
			||||||
 | 
					        console.warn('Post-delete sync failed; continuing with local state.', syncError)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      toastStore.success('Message deleted')
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      // Rollback local removal on failure
 | 
				
			||||||
 | 
					      if (originalIndex !== -1) {
 | 
				
			||||||
 | 
					        const list = appStore.messages[msg.channel_id] || []
 | 
				
			||||||
 | 
					        list.splice(Math.min(originalIndex, list.length), 0, msg)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      await nextTick()
 | 
				
			||||||
 | 
					      const restoredEl = document.querySelector(`[data-message-id="${msg.id}"]`) as HTMLElement | null
 | 
				
			||||||
 | 
					      if (restoredEl) {
 | 
				
			||||||
 | 
					        if (!restoredEl.hasAttribute('tabindex')) restoredEl.setAttribute('tabindex', '-1')
 | 
				
			||||||
 | 
					        restoredEl.focus()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      throw error
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // focus the closest message
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					    if (targetToFocus && document.contains(targetToFocus)) {
 | 
				
			||||||
 | 
					      if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
 | 
				
			||||||
 | 
					      targetToFocus.focus()
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      focusFallbackToInput()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to delete message:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to delete message')
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -171,10 +283,14 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
  border-radius: 8px;
 | 
					  border-radius: 8px;
 | 
				
			||||||
  padding: 12px 16px;
 | 
					  padding: 12px 16px;
 | 
				
			||||||
  margin-bottom: 8px;
 | 
					  margin-bottom: 8px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.message:hover {
 | 
					.message:hover {
 | 
				
			||||||
  background: #f1f3f4;
 | 
					  background: #f1f3f4;
 | 
				
			||||||
 | 
					  border-color: #3b82f6;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.message:focus {
 | 
					.message:focus {
 | 
				
			||||||
@@ -229,6 +345,8 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
  
 | 
					  
 | 
				
			||||||
  .message:hover {
 | 
					  .message:hover {
 | 
				
			||||||
    background: #374151;
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #60a5fa;
 | 
				
			||||||
 | 
					    box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .message__content {
 | 
					  .message__content {
 | 
				
			||||||
@@ -239,4 +357,4 @@ const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			|||||||
    color: #a0aec0;
 | 
					    color: #a0aec0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,33 +1,19 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div 
 | 
					  <div class="messages-container" ref="containerRef" @keydown="handleKeydown" tabindex="0" role="listbox"
 | 
				
			||||||
    class="messages-container" 
 | 
					    :aria-label="messagesAriaLabel">
 | 
				
			||||||
    ref="containerRef"
 | 
					 | 
				
			||||||
    @keydown="handleKeydown"
 | 
					 | 
				
			||||||
    tabindex="0"
 | 
					 | 
				
			||||||
    role="list"
 | 
					 | 
				
			||||||
    :aria-label="messagesAriaLabel"
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    <div class="messages" role="presentation">
 | 
					    <div class="messages" role="presentation">
 | 
				
			||||||
      <!-- Regular Messages -->
 | 
					      <!-- Regular Messages -->
 | 
				
			||||||
      <MessageItem
 | 
					      <MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
 | 
				
			||||||
        v-for="(message, index) in messages"
 | 
					        :tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
 | 
				
			||||||
        :key="message.id"
 | 
					        :aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
 | 
				
			||||||
        :message="message"
 | 
					        @focus="focusedMessageIndex = index" 
 | 
				
			||||||
        :tabindex="index === focusedMessageIndex ? 0 : -1"
 | 
					        @open-dialog="emit('open-message-dialog', $event)" />
 | 
				
			||||||
        :data-message-index="index"
 | 
					
 | 
				
			||||||
        @focus="focusedMessageIndex = index"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      <!-- Unsent Messages -->
 | 
					      <!-- Unsent Messages -->
 | 
				
			||||||
      <MessageItem
 | 
					      <MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
 | 
				
			||||||
        v-for="(unsentMsg, index) in unsentMessages"
 | 
					        :is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
 | 
				
			||||||
        :key="unsentMsg.id"
 | 
					        :data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index" 
 | 
				
			||||||
        :message="unsentMsg"
 | 
					        @open-dialog="emit('open-message-dialog', $event)" />
 | 
				
			||||||
        :is-unsent="true"
 | 
					 | 
				
			||||||
        :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
 | 
					 | 
				
			||||||
        :data-message-index="messages.length + index"
 | 
					 | 
				
			||||||
        @focus="focusedMessageIndex = messages.length + index"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -44,6 +30,7 @@ interface Props {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits<{
 | 
					const emit = defineEmits<{
 | 
				
			||||||
  'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
 | 
					  'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
 | 
				
			||||||
 | 
					  'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
 | 
				
			||||||
}>()
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<Props>()
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
@@ -59,13 +46,13 @@ const totalMessages = computed(() => allMessages.value.length)
 | 
				
			|||||||
const messagesAriaLabel = computed(() => {
 | 
					const messagesAriaLabel = computed(() => {
 | 
				
			||||||
  const total = totalMessages.value
 | 
					  const total = totalMessages.value
 | 
				
			||||||
  const current = focusedMessageIndex.value + 1
 | 
					  const current = focusedMessageIndex.value + 1
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  if (total === 0) {
 | 
					  if (total === 0) {
 | 
				
			||||||
    return 'Messages list, no messages'
 | 
					    return 'Messages list, no messages'
 | 
				
			||||||
  } else if (total === 1) {
 | 
					  } else if (total === 1) {
 | 
				
			||||||
    return 'Messages list, 1 message'
 | 
					    return 'Messages list, 1 message'
 | 
				
			||||||
  } else {
 | 
					  } else {
 | 
				
			||||||
    return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
 | 
					    return `Messages list, ${total} messages`
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -74,50 +61,50 @@ const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 mess
 | 
				
			|||||||
// Keyboard navigation
 | 
					// Keyboard navigation
 | 
				
			||||||
const handleKeydown = (event: KeyboardEvent) => {
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
  if (totalMessages.value === 0) return
 | 
					  if (totalMessages.value === 0) return
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  let newIndex = focusedMessageIndex.value
 | 
					  let newIndex = focusedMessageIndex.value
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  switch (event.key) {
 | 
					  switch (event.key) {
 | 
				
			||||||
    case 'ArrowUp':
 | 
					    case 'ArrowUp':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = Math.max(0, focusedMessageIndex.value - 1)
 | 
					      newIndex = Math.max(0, focusedMessageIndex.value - 1)
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'ArrowDown':
 | 
					    case 'ArrowDown':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
 | 
					      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'PageUp':
 | 
					    case 'PageUp':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = Math.max(0, focusedMessageIndex.value - 10)
 | 
					      newIndex = Math.max(0, focusedMessageIndex.value - 10)
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'PageDown':
 | 
					    case 'PageDown':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
 | 
					      newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'Home':
 | 
					    case 'Home':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = 0
 | 
					      newIndex = 0
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'End':
 | 
					    case 'End':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      newIndex = totalMessages.value - 1
 | 
					      newIndex = totalMessages.value - 1
 | 
				
			||||||
      break
 | 
					      break
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    case 'Enter':
 | 
					    case 'Enter':
 | 
				
			||||||
    case ' ':
 | 
					    case ' ':
 | 
				
			||||||
      event.preventDefault()
 | 
					      event.preventDefault()
 | 
				
			||||||
      selectCurrentMessage()
 | 
					      selectCurrentMessage()
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
    default:
 | 
					    default:
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  if (newIndex !== focusedMessageIndex.value) {
 | 
					  if (newIndex !== focusedMessageIndex.value) {
 | 
				
			||||||
    focusMessage(newIndex)
 | 
					    focusMessage(newIndex)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -181,9 +168,18 @@ onMounted(() => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
 | 
				
			||||||
 | 
					  const messages = allMessages.value
 | 
				
			||||||
 | 
					  if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) {
 | 
				
			||||||
 | 
					    return messages[focusedMessageIndex.value]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineExpose({
 | 
					defineExpose({
 | 
				
			||||||
  scrollToBottom,
 | 
					  scrollToBottom,
 | 
				
			||||||
  focusMessageById
 | 
					  focusMessageById,
 | 
				
			||||||
 | 
					  getFocusedMessage
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -193,6 +189,12 @@ defineExpose({
 | 
				
			|||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
  padding: 1rem;
 | 
					  padding: 1rem;
 | 
				
			||||||
  background: #fafafa;
 | 
					  background: #fafafa;
 | 
				
			||||||
 | 
					  /* iOS-specific scroll optimizations */
 | 
				
			||||||
 | 
					  -webkit-overflow-scrolling: touch;
 | 
				
			||||||
 | 
					  -webkit-scroll-behavior: smooth;
 | 
				
			||||||
 | 
					  scroll-behavior: smooth;
 | 
				
			||||||
 | 
					  scroll-padding-top: 1rem;
 | 
				
			||||||
 | 
					  scroll-padding-bottom: 1rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.messages-container:focus {
 | 
					.messages-container:focus {
 | 
				
			||||||
@@ -229,19 +231,19 @@ defineExpose({
 | 
				
			|||||||
  .messages-container {
 | 
					  .messages-container {
 | 
				
			||||||
    background: #111827;
 | 
					    background: #111827;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  .messages-container:focus {
 | 
					  .messages-container:focus {
 | 
				
			||||||
    outline-color: #60a5fa;
 | 
					    outline-color: #60a5fa;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  .messages-container::-webkit-scrollbar-track {
 | 
					  .messages-container::-webkit-scrollbar-track {
 | 
				
			||||||
    background: #1f2937;
 | 
					    background: #1f2937;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  .messages-container::-webkit-scrollbar-thumb {
 | 
					  .messages-container::-webkit-scrollbar-thumb {
 | 
				
			||||||
    background: #4b5563;
 | 
					    background: #4b5563;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					
 | 
				
			||||||
  .messages-container::-webkit-scrollbar-thumb:hover {
 | 
					  .messages-container::-webkit-scrollbar-thumb:hover {
 | 
				
			||||||
    background: #6b7280;
 | 
					    background: #6b7280;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										720
									
								
								frontend-vue/src/components/dialogs/MessageDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										720
									
								
								frontend-vue/src/components/dialogs/MessageDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,720 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="message-dialog">
 | 
				
			||||||
 | 
					    <div class="dialog-header">
 | 
				
			||||||
 | 
					      <h2>Message Details</h2>
 | 
				
			||||||
 | 
					      <button class="close-button" @click="$emit('close')" aria-label="Close dialog">
 | 
				
			||||||
 | 
					        ✕
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <div class="dialog-content">
 | 
				
			||||||
 | 
					      <!-- Message Info Section -->
 | 
				
			||||||
 | 
					      <div class="info-section">
 | 
				
			||||||
 | 
					        <div class="info-item">
 | 
				
			||||||
 | 
					          <label>Message ID</label>
 | 
				
			||||||
 | 
					          <span>#{{ message.id }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="info-item">
 | 
				
			||||||
 | 
					          <label>Sent</label>
 | 
				
			||||||
 | 
					          <time :datetime="message.created_at">
 | 
				
			||||||
 | 
					            {{ formatTimestampForScreenReader(message.created_at) }}
 | 
				
			||||||
 | 
					          </time>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="info-item">
 | 
				
			||||||
 | 
					          <label>Channel</label>
 | 
				
			||||||
 | 
					          <span>{{ channelName }}</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div v-if="hasFileAttachment" class="info-item">
 | 
				
			||||||
 | 
					          <label>Attachment</label>
 | 
				
			||||||
 | 
					          <div class="file-info">
 | 
				
			||||||
 | 
					            <span class="file-name">{{ message.originalName }}</span>
 | 
				
			||||||
 | 
					            <span class="file-size">({{ formatFileSize(message.fileSize || 0) }})</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <!-- Content Section -->
 | 
				
			||||||
 | 
					      <div class="content-section">
 | 
				
			||||||
 | 
					        <label for="message-content">Message Content</label>
 | 
				
			||||||
 | 
					        <div v-if="!isEditing" class="content-display">
 | 
				
			||||||
 | 
					          <p>{{ message.content }}</p>
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="startEditing" 
 | 
				
			||||||
 | 
					            variant="secondary" 
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            class="edit-button"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Edit
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div v-else class="content-edit">
 | 
				
			||||||
 | 
					          <BaseTextarea
 | 
				
			||||||
 | 
					            id="message-content"
 | 
				
			||||||
 | 
					            v-model="editedContent"
 | 
				
			||||||
 | 
					            placeholder="Message content..."
 | 
				
			||||||
 | 
					            :rows="4"
 | 
				
			||||||
 | 
					            auto-resize
 | 
				
			||||||
 | 
					            ref="contentTextarea"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <div class="edit-actions">
 | 
				
			||||||
 | 
					            <BaseButton @click="saveEdit" :disabled="!canSave" :loading="isSaving">
 | 
				
			||||||
 | 
					              Save
 | 
				
			||||||
 | 
					            </BaseButton>
 | 
				
			||||||
 | 
					            <BaseButton @click="cancelEdit" variant="secondary">
 | 
				
			||||||
 | 
					              Cancel
 | 
				
			||||||
 | 
					            </BaseButton>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <!-- File Actions Section (if file attachment exists) -->
 | 
				
			||||||
 | 
					      <div v-if="hasFileAttachment" class="file-actions-section">
 | 
				
			||||||
 | 
					        <h3>File Actions</h3>
 | 
				
			||||||
 | 
					        <div class="action-buttons">
 | 
				
			||||||
 | 
					          <BaseButton @click="downloadFile" variant="secondary">
 | 
				
			||||||
 | 
					            Download {{ message.originalName }}
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            v-if="isImageFile" 
 | 
				
			||||||
 | 
					            @click="viewImage" 
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            View Image
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            v-if="isAudioFile" 
 | 
				
			||||||
 | 
					            @click="playAudio" 
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {{ isPlaying ? 'Stop' : 'Play' }} Audio
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      <!-- Message Actions Section -->
 | 
				
			||||||
 | 
					      <div class="actions-section">
 | 
				
			||||||
 | 
					        <h3>Message Actions</h3>
 | 
				
			||||||
 | 
					        <div class="action-buttons">
 | 
				
			||||||
 | 
					          <BaseButton @click="copyMessage" variant="secondary">
 | 
				
			||||||
 | 
					            Copy Content
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="readAloud" 
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					            :disabled="!ttsEnabled"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Read Aloud
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="showDeleteConfirm = true" 
 | 
				
			||||||
 | 
					            variant="danger"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Delete Message
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <BaseButton 
 | 
				
			||||||
 | 
					            @click="showMoveDialog = true" 
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Move Message
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Delete Confirmation Dialog -->
 | 
				
			||||||
 | 
					    <div v-if="showDeleteConfirm" class="confirm-overlay">
 | 
				
			||||||
 | 
					      <div class="confirm-dialog">
 | 
				
			||||||
 | 
					        <h3>Delete Message</h3>
 | 
				
			||||||
 | 
					        <p>Are you sure you want to delete this message? This cannot be undone.</p>
 | 
				
			||||||
 | 
					        <div class="confirm-actions">
 | 
				
			||||||
 | 
					          <BaseButton
 | 
				
			||||||
 | 
					            @click="showDeleteConfirm = false"
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Cancel
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          <BaseButton
 | 
				
			||||||
 | 
					            @click="deleteMessage"
 | 
				
			||||||
 | 
					            variant="danger"
 | 
				
			||||||
 | 
					            :loading="isDeleting"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Delete
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    <!-- Move Message Dialog -->
 | 
				
			||||||
 | 
					    <div v-if="showMoveDialog" class="confirm-overlay">
 | 
				
			||||||
 | 
					      <div class="confirm-dialog">
 | 
				
			||||||
 | 
					        <h3>Move Message</h3>
 | 
				
			||||||
 | 
					        <p>Select the channel to move this message to:</p>
 | 
				
			||||||
 | 
					        <select v-model="selectedTargetChannelId" class="channel-select">
 | 
				
			||||||
 | 
					          <option value="">Select a channel...</option>
 | 
				
			||||||
 | 
					          <option 
 | 
				
			||||||
 | 
					            v-for="channel in availableChannels" 
 | 
				
			||||||
 | 
					            :key="channel.id" 
 | 
				
			||||||
 | 
					            :value="channel.id"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {{ channel.name }}
 | 
				
			||||||
 | 
					          </option>
 | 
				
			||||||
 | 
					        </select>
 | 
				
			||||||
 | 
					        <div class="confirm-actions">
 | 
				
			||||||
 | 
					          <BaseButton
 | 
				
			||||||
 | 
					            @click="showMoveDialog = false"
 | 
				
			||||||
 | 
					            variant="secondary"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Cancel
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					          <BaseButton
 | 
				
			||||||
 | 
					            @click="moveMessage"
 | 
				
			||||||
 | 
					            variant="primary"
 | 
				
			||||||
 | 
					            :loading="isMoving"
 | 
				
			||||||
 | 
					            :disabled="!selectedTargetChannelId || selectedTargetChannelId === message.channel_id"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Move
 | 
				
			||||||
 | 
					          </BaseButton>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed, nextTick, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useAppStore } from '@/stores/app'
 | 
				
			||||||
 | 
					import { useToastStore } from '@/stores/toast'
 | 
				
			||||||
 | 
					import { useAudio } from '@/composables/useAudio'
 | 
				
			||||||
 | 
					import { formatTimestampForScreenReader } from '@/utils/time'
 | 
				
			||||||
 | 
					import BaseButton from '@/components/base/BaseButton.vue'
 | 
				
			||||||
 | 
					import BaseTextarea from '@/components/base/BaseTextarea.vue'
 | 
				
			||||||
 | 
					import type { ExtendedMessage } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Props {
 | 
				
			||||||
 | 
					  message: ExtendedMessage
 | 
				
			||||||
 | 
					  open: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
					  close: []
 | 
				
			||||||
 | 
					  edit: [messageId: number, content: string]
 | 
				
			||||||
 | 
					  delete: [messageId: number]
 | 
				
			||||||
 | 
					  move: [messageId: number, targetChannelId: number]
 | 
				
			||||||
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<Props>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const appStore = useAppStore()
 | 
				
			||||||
 | 
					const toastStore = useToastStore()
 | 
				
			||||||
 | 
					const { speak, playSound } = useAudio()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Component state
 | 
				
			||||||
 | 
					const isEditing = ref(false)
 | 
				
			||||||
 | 
					const editedContent = ref('')
 | 
				
			||||||
 | 
					const showDeleteConfirm = ref(false)
 | 
				
			||||||
 | 
					const showMoveDialog = ref(false)
 | 
				
			||||||
 | 
					const selectedTargetChannelId = ref<number | ''>('')
 | 
				
			||||||
 | 
					const isSaving = ref(false)
 | 
				
			||||||
 | 
					const isDeleting = ref(false)
 | 
				
			||||||
 | 
					const isMoving = ref(false)
 | 
				
			||||||
 | 
					const isPlaying = ref(false)
 | 
				
			||||||
 | 
					const contentTextarea = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Computed properties
 | 
				
			||||||
 | 
					const channelName = computed(() => {
 | 
				
			||||||
 | 
					  const channel = appStore.channels.find(c => c.id === props.message.channel_id)
 | 
				
			||||||
 | 
					  return channel?.name || `Channel ${props.message.channel_id}`
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const hasFileAttachment = computed(() => {
 | 
				
			||||||
 | 
					  return !!(props.message.fileId && props.message.originalName)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isImageFile = computed(() => {
 | 
				
			||||||
 | 
					  if (!props.message.originalName) return false
 | 
				
			||||||
 | 
					  const ext = props.message.originalName.split('.').pop()?.toLowerCase()
 | 
				
			||||||
 | 
					  return ext && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isAudioFile = computed(() => {
 | 
				
			||||||
 | 
					  if (!props.message.originalName) return false
 | 
				
			||||||
 | 
					  const ext = props.message.originalName.split('.').pop()?.toLowerCase()
 | 
				
			||||||
 | 
					  return ext && ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const canSave = computed(() => {
 | 
				
			||||||
 | 
					  return editedContent.value.trim().length > 0 && 
 | 
				
			||||||
 | 
					         editedContent.value.trim() !== props.message.content
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ttsEnabled = computed(() => appStore.settings.ttsEnabled)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const availableChannels = computed(() => 
 | 
				
			||||||
 | 
					  appStore.channels.filter(channel => channel.id !== props.message.channel_id)
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Methods
 | 
				
			||||||
 | 
					const startEditing = async () => {
 | 
				
			||||||
 | 
					  isEditing.value = true
 | 
				
			||||||
 | 
					  editedContent.value = props.message.content
 | 
				
			||||||
 | 
					  await nextTick()
 | 
				
			||||||
 | 
					  contentTextarea.value?.focus()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cancelEdit = () => {
 | 
				
			||||||
 | 
					  isEditing.value = false
 | 
				
			||||||
 | 
					  editedContent.value = ''
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const saveEdit = async () => {
 | 
				
			||||||
 | 
					  if (!canSave.value) return
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  isSaving.value = true
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    emit('edit', props.message.id, editedContent.value.trim())
 | 
				
			||||||
 | 
					    isEditing.value = false
 | 
				
			||||||
 | 
					    toastStore.success('Message updated successfully')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    toastStore.error('Failed to update message')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isSaving.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteMessage = async () => {
 | 
				
			||||||
 | 
					  isDeleting.value = true
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    emit('delete', props.message.id)
 | 
				
			||||||
 | 
					    showDeleteConfirm.value = false
 | 
				
			||||||
 | 
					    toastStore.success('Message deleted successfully')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    toastStore.error('Failed to delete message')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isDeleting.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const moveMessage = async () => {
 | 
				
			||||||
 | 
					  if (!selectedTargetChannelId.value || selectedTargetChannelId.value === props.message.channel_id) {
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  isMoving.value = true
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    emit('move', props.message.id, selectedTargetChannelId.value as number)
 | 
				
			||||||
 | 
					    showMoveDialog.value = false
 | 
				
			||||||
 | 
					    selectedTargetChannelId.value = ''
 | 
				
			||||||
 | 
					    toastStore.success('Message moved successfully')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    toastStore.error('Failed to move message')
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isMoving.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const copyMessage = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await navigator.clipboard.writeText(props.message.content)
 | 
				
			||||||
 | 
					    playSound('copy')
 | 
				
			||||||
 | 
					    toastStore.success('Message copied to clipboard')
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    toastStore.error('Failed to copy message')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const readAloud = async () => {
 | 
				
			||||||
 | 
					  if (appStore.settings.ttsEnabled) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await speak(props.message.content)
 | 
				
			||||||
 | 
					      toastStore.info('Reading message aloud')
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      toastStore.error('Failed to read message aloud')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    toastStore.info('Text-to-speech is disabled')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const downloadFile = () => {
 | 
				
			||||||
 | 
					  if (props.message.filePath) {
 | 
				
			||||||
 | 
					    const link = document.createElement('a')
 | 
				
			||||||
 | 
					    link.href = `/api/files/${props.message.filePath}`
 | 
				
			||||||
 | 
					    link.download = props.message.originalName || 'download'
 | 
				
			||||||
 | 
					    link.click()
 | 
				
			||||||
 | 
					    toastStore.success('Download started')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const viewImage = () => {
 | 
				
			||||||
 | 
					  if (props.message.filePath) {
 | 
				
			||||||
 | 
					    window.open(`/api/files/${props.message.filePath}`, '_blank')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const playAudio = () => {
 | 
				
			||||||
 | 
					  if (props.message.filePath) {
 | 
				
			||||||
 | 
					    if (isPlaying.value) {
 | 
				
			||||||
 | 
					      // Stop audio (would need audio instance management)
 | 
				
			||||||
 | 
					      isPlaying.value = false
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const audio = new Audio(`/api/files/${props.message.filePath}`)
 | 
				
			||||||
 | 
					      audio.onended = () => { isPlaying.value = false }
 | 
				
			||||||
 | 
					      audio.onerror = () => { 
 | 
				
			||||||
 | 
					        isPlaying.value = false
 | 
				
			||||||
 | 
					        toastStore.error('Failed to play audio file')
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      audio.play()
 | 
				
			||||||
 | 
					      isPlaying.value = true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Handle escape key to close dialog
 | 
				
			||||||
 | 
					const handleKeydown = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					  if (event.key === 'Escape') {
 | 
				
			||||||
 | 
					    if (isEditing.value) {
 | 
				
			||||||
 | 
					      cancelEdit()
 | 
				
			||||||
 | 
					    } else if (showDeleteConfirm.value) {
 | 
				
			||||||
 | 
					      showDeleteConfirm.value = false
 | 
				
			||||||
 | 
					    } else if (showMoveDialog.value) {
 | 
				
			||||||
 | 
					      showMoveDialog.value = false
 | 
				
			||||||
 | 
					      selectedTargetChannelId.value = ''
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      emit('close')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  document.addEventListener('keydown', handleKeydown)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Cleanup on unmount
 | 
				
			||||||
 | 
					const cleanup = () => {
 | 
				
			||||||
 | 
					  document.removeEventListener('keydown', handleKeydown)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({ cleanup })
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.message-dialog {
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  width: 90vw;
 | 
				
			||||||
 | 
					  max-width: 600px;
 | 
				
			||||||
 | 
					  max-height: 80vh;
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-header {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-header h2 {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1.25rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #111827;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.close-button {
 | 
				
			||||||
 | 
					  background: none;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  font-size: 1.5rem;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  padding: 0.25rem;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.close-button:hover {
 | 
				
			||||||
 | 
					  background: #f3f4f6;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dialog-content {
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.info-section {
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 | 
				
			||||||
 | 
					  gap: 1rem;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.info-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.25rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.info-item label {
 | 
				
			||||||
 | 
					  font-size: 0.75rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  text-transform: uppercase;
 | 
				
			||||||
 | 
					  letter-spacing: 0.05em;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.info-item span,
 | 
				
			||||||
 | 
					.info-item time {
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-info {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.125rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-name {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-size {
 | 
				
			||||||
 | 
					  font-size: 0.75rem !important;
 | 
				
			||||||
 | 
					  color: #6b7280 !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-section {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-section > label {
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-display {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  border: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  background: #f9fafb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-display p {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					  white-space: pre-wrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.edit-button {
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0.5rem;
 | 
				
			||||||
 | 
					  right: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content-edit {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.edit-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-actions-section,
 | 
				
			||||||
 | 
					.actions-section {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.file-actions-section h3,
 | 
				
			||||||
 | 
					.actions-section h3 {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #374151;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.action-buttons {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.confirm-overlay {
 | 
				
			||||||
 | 
					  position: fixed;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  right: 0;
 | 
				
			||||||
 | 
					  bottom: 0;
 | 
				
			||||||
 | 
					  background: rgba(0, 0, 0, 0.5);
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  z-index: 1000;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.confirm-dialog {
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  max-width: 400px;
 | 
				
			||||||
 | 
					  margin: 1rem;
 | 
				
			||||||
 | 
					  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.confirm-dialog h3 {
 | 
				
			||||||
 | 
					  margin: 0 0 1rem 0;
 | 
				
			||||||
 | 
					  font-size: 1.125rem;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #dc2626;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.confirm-dialog p {
 | 
				
			||||||
 | 
					  margin: 0 0 1.5rem 0;
 | 
				
			||||||
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.confirm-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					  gap: 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channel-select {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  border: 1px solid #e5e7eb;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  font-size: 0.875rem;
 | 
				
			||||||
 | 
					  margin-bottom: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.channel-select:focus {
 | 
				
			||||||
 | 
					  outline: 2px solid #3b82f6;
 | 
				
			||||||
 | 
					  outline-offset: -2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Dark mode */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
 | 
					  .message-dialog {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog-header {
 | 
				
			||||||
 | 
					    border-bottom-color: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .dialog-header h2 {
 | 
				
			||||||
 | 
					    color: #f9fafb;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .close-button {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .close-button:hover {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    color: #f3f4f6;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .info-section {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .info-item label {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .info-item span,
 | 
				
			||||||
 | 
					  .info-item time {
 | 
				
			||||||
 | 
					    color: #f3f4f6;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .content-section > label,
 | 
				
			||||||
 | 
					  .file-actions-section h3,
 | 
				
			||||||
 | 
					  .actions-section h3 {
 | 
				
			||||||
 | 
					    color: #f3f4f6;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .content-display {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .content-display p {
 | 
				
			||||||
 | 
					    color: #f3f4f6;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .confirm-dialog {
 | 
				
			||||||
 | 
					    background: #1f2937;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .confirm-dialog p {
 | 
				
			||||||
 | 
					    color: #9ca3af;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .channel-select {
 | 
				
			||||||
 | 
					    background: #374151;
 | 
				
			||||||
 | 
					    border-color: #4b5563;
 | 
				
			||||||
 | 
					    color: #f3f4f6;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .channel-select:focus {
 | 
				
			||||||
 | 
					    outline-color: #60a5fa;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Mobile responsiveness */
 | 
				
			||||||
 | 
					@media (max-width: 768px) {
 | 
				
			||||||
 | 
					  .message-dialog {
 | 
				
			||||||
 | 
					    width: 95vw;
 | 
				
			||||||
 | 
					    margin: 1rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .info-section {
 | 
				
			||||||
 | 
					    grid-template-columns: 1fr;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .action-buttons {
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .edit-actions {
 | 
				
			||||||
 | 
					    flex-direction: column-reverse;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -6,6 +6,7 @@
 | 
				
			|||||||
        
 | 
					        
 | 
				
			||||||
        <label class="setting-item">
 | 
					        <label class="setting-item">
 | 
				
			||||||
          <input 
 | 
					          <input 
 | 
				
			||||||
 | 
					          ref="soundInput"
 | 
				
			||||||
            type="checkbox" 
 | 
					            type="checkbox" 
 | 
				
			||||||
            v-model="localSettings.soundEnabled"
 | 
					            v-model="localSettings.soundEnabled"
 | 
				
			||||||
            class="checkbox"
 | 
					            class="checkbox"
 | 
				
			||||||
@@ -245,6 +246,7 @@ const isSaving = ref(false)
 | 
				
			|||||||
const isResetting = ref(false)
 | 
					const isResetting = ref(false)
 | 
				
			||||||
const showResetConfirm = ref(false)
 | 
					const showResetConfirm = ref(false)
 | 
				
			||||||
const selectedVoiceURI = ref('')
 | 
					const selectedVoiceURI = ref('')
 | 
				
			||||||
 | 
					const soundInput = ref()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Computed property for current server URL
 | 
					// Computed property for current server URL
 | 
				
			||||||
const currentServerUrl = computed(() => authStore.serverUrl)
 | 
					const currentServerUrl = computed(() => authStore.serverUrl)
 | 
				
			||||||
@@ -338,6 +340,7 @@ onMounted(() => {
 | 
				
			|||||||
  
 | 
					  
 | 
				
			||||||
  // Set up voice selection
 | 
					  // Set up voice selection
 | 
				
			||||||
  selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
 | 
					  selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
 | 
				
			||||||
 | 
					  soundInput.value.focus();
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="channel-list-container" ref="containerRef">
 | 
					  <div class="channel-list-container" ref="containerRef">
 | 
				
			||||||
    <ul class="channel-list" role="list" aria-label="Channels">
 | 
					    <ul class="channel-list" role="listbox" aria-label="Channels">
 | 
				
			||||||
      <ChannelListItem
 | 
					      <ChannelListItem
 | 
				
			||||||
        v-for="(channel, index) in channels"
 | 
					        v-for="(channel, index) in channels"
 | 
				
			||||||
        :key="channel.id"
 | 
					        :key="channel.id"
 | 
				
			||||||
@@ -160,6 +160,10 @@ defineExpose({
 | 
				
			|||||||
  flex: 1;
 | 
					  flex: 1;
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
  padding: 0.5rem 0;
 | 
					  padding: 0.5rem 0;
 | 
				
			||||||
 | 
					  /* iOS-specific scroll optimizations */
 | 
				
			||||||
 | 
					  -webkit-overflow-scrolling: touch;
 | 
				
			||||||
 | 
					  -webkit-scroll-behavior: smooth;
 | 
				
			||||||
 | 
					  scroll-behavior: smooth;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,9 +12,10 @@
 | 
				
			|||||||
        class="channel-button"
 | 
					        class="channel-button"
 | 
				
			||||||
        @click="$emit('select', channel.id)"
 | 
					        @click="$emit('select', channel.id)"
 | 
				
			||||||
        @focus="handleFocus"
 | 
					        @focus="handleFocus"
 | 
				
			||||||
 | 
					        role="option"
 | 
				
			||||||
 | 
					        :aria-current="isActive"
 | 
				
			||||||
        @keydown="handleKeydown"
 | 
					        @keydown="handleKeydown"
 | 
				
			||||||
        :tabindex="tabindex"
 | 
					        :tabindex="tabindex"
 | 
				
			||||||
        :aria-pressed="isActive"
 | 
					 | 
				
			||||||
        :aria-label="channelAriaLabel"
 | 
					        :aria-label="channelAriaLabel"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <span class="channel-name">{{ channel.name }}</span>
 | 
					        <span class="channel-name">{{ channel.name }}</span>
 | 
				
			||||||
@@ -23,7 +24,7 @@
 | 
				
			|||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
      <button
 | 
					      <button v-if="isActive"
 | 
				
			||||||
        class="channel-info-button"
 | 
					        class="channel-info-button"
 | 
				
			||||||
        @click.stop="$emit('info', channel)"
 | 
					        @click.stop="$emit('info', channel)"
 | 
				
			||||||
        :aria-label="`Channel info for ${channel.name}`"
 | 
					        :aria-label="`Channel info for ${channel.name}`"
 | 
				
			||||||
@@ -58,7 +59,7 @@ const props = defineProps<Props>()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Better ARIA label that announces the channel name and unread count
 | 
					// Better ARIA label that announces the channel name and unread count
 | 
				
			||||||
const channelAriaLabel = computed(() => {
 | 
					const channelAriaLabel = computed(() => {
 | 
				
			||||||
  let label = `${props.channel.name} channel`
 | 
					  let label = `${props.channel.name}`
 | 
				
			||||||
  if (props.unreadCount) {
 | 
					  if (props.unreadCount) {
 | 
				
			||||||
    label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
 | 
					    label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,28 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <aside class="sidebar">
 | 
					  <aside class="sidebar">
 | 
				
			||||||
    <div class="sidebar__header">
 | 
					    <div class="sidebar__header">
 | 
				
			||||||
      <h1 class="sidebar__title">Notebrook</h1>
 | 
					      <div class="sidebar__header-left">
 | 
				
			||||||
      <BaseButton
 | 
					        <h1 class="sidebar__title">Notebrook</h1>
 | 
				
			||||||
        variant="ghost"
 | 
					      </div>
 | 
				
			||||||
        size="sm"
 | 
					      <div class="sidebar__header-right">
 | 
				
			||||||
        @click="$emit('create-channel')"
 | 
					        <BaseButton
 | 
				
			||||||
        aria-label="Create new channel"
 | 
					          variant="ghost"
 | 
				
			||||||
      >
 | 
					          size="sm"
 | 
				
			||||||
        +
 | 
					          @click="$emit('create-channel')"
 | 
				
			||||||
      </BaseButton>
 | 
					          aria-label="Create new channel"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          +
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					        <BaseButton
 | 
				
			||||||
 | 
					          variant="ghost"
 | 
				
			||||||
 | 
					          size="sm"
 | 
				
			||||||
 | 
					          class="sidebar__close-button"
 | 
				
			||||||
 | 
					          @click="$emit('close')"
 | 
				
			||||||
 | 
					          aria-label="Close sidebar"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          ✕
 | 
				
			||||||
 | 
					        </BaseButton>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    <div class="sidebar__content">
 | 
					    <div class="sidebar__content">
 | 
				
			||||||
@@ -53,6 +66,7 @@ defineEmits<{
 | 
				
			|||||||
  'select-channel': [channelId: number]
 | 
					  'select-channel': [channelId: number]
 | 
				
			||||||
  'channel-info': [channel: Channel]
 | 
					  'channel-info': [channel: Channel]
 | 
				
			||||||
  'settings': []
 | 
					  'settings': []
 | 
				
			||||||
 | 
					  'close': []
 | 
				
			||||||
}>()
 | 
					}>()
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -63,7 +77,7 @@ defineEmits<{
 | 
				
			|||||||
  border-right: 1px solid #e5e7eb;
 | 
					  border-right: 1px solid #e5e7eb;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
  height: 100vh;
 | 
					  height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.sidebar__header {
 | 
					.sidebar__header {
 | 
				
			||||||
@@ -76,6 +90,17 @@ defineEmits<{
 | 
				
			|||||||
  flex-shrink: 0;
 | 
					  flex-shrink: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sidebar__header-left {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sidebar__header-right {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.sidebar__title {
 | 
					.sidebar__title {
 | 
				
			||||||
  margin: 0;
 | 
					  margin: 0;
 | 
				
			||||||
  font-size: 1.25rem;
 | 
					  font-size: 1.25rem;
 | 
				
			||||||
@@ -99,6 +124,10 @@ defineEmits<{
 | 
				
			|||||||
  flex-shrink: 0;
 | 
					  flex-shrink: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sidebar__close-button {
 | 
				
			||||||
 | 
					  display: none; /* Hidden by default on desktop */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Dark mode */
 | 
					/* Dark mode */
 | 
				
			||||||
@media (prefers-color-scheme: dark) {
 | 
					@media (prefers-color-scheme: dark) {
 | 
				
			||||||
  .sidebar {
 | 
					  .sidebar {
 | 
				
			||||||
@@ -129,10 +158,15 @@ defineEmits<{
 | 
				
			|||||||
  
 | 
					  
 | 
				
			||||||
  .sidebar__header {
 | 
					  .sidebar__header {
 | 
				
			||||||
    padding: 1rem;
 | 
					    padding: 1rem;
 | 
				
			||||||
 | 
					    padding-top: calc(1rem + var(--safe-area-inset-top));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .sidebar__title {
 | 
					  .sidebar__title {
 | 
				
			||||||
    font-size: 1.125rem;
 | 
					    font-size: 1.125rem;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .sidebar__close-button {
 | 
				
			||||||
 | 
					    display: inline-flex; /* Show on mobile */
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
@@ -64,6 +64,24 @@ export function useWebSocket() {
 | 
				
			|||||||
    appStore.removeMessage(parseInt(data.id))
 | 
					    appStore.removeMessage(parseInt(data.id))
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleMessageMoved = (data: { messageId: string, sourceChannelId: string, targetChannelId: string }) => {
 | 
				
			||||||
 | 
					    console.log('WebSocket: Message moved event received:', data)
 | 
				
			||||||
 | 
					    const messageId = parseInt(data.messageId)
 | 
				
			||||||
 | 
					    const sourceChannelId = parseInt(data.sourceChannelId) 
 | 
				
			||||||
 | 
					    const targetChannelId = parseInt(data.targetChannelId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    appStore.moveMessage(messageId, sourceChannelId, targetChannelId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Show toast notification if the move affects the current view
 | 
				
			||||||
 | 
					    if (appStore.currentChannelId === sourceChannelId || appStore.currentChannelId === targetChannelId) {
 | 
				
			||||||
 | 
					      const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId)
 | 
				
			||||||
 | 
					      const targetChannel = appStore.channels.find(c => c.id === targetChannelId)
 | 
				
			||||||
 | 
					      if (sourceChannel && targetChannel) {
 | 
				
			||||||
 | 
					        toastStore.info(`Message moved from "${sourceChannel.name}" to "${targetChannel.name}"`)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleFileUploaded = (data: any) => {
 | 
					  const handleFileUploaded = (data: any) => {
 | 
				
			||||||
    // Handle file upload events with flattened format
 | 
					    // Handle file upload events with flattened format
 | 
				
			||||||
    const messageUpdate: Partial<ExtendedMessage> = {
 | 
					    const messageUpdate: Partial<ExtendedMessage> = {
 | 
				
			||||||
@@ -127,6 +145,7 @@ export function useWebSocket() {
 | 
				
			|||||||
    websocketService.on('message-created', handleMessageCreated)
 | 
					    websocketService.on('message-created', handleMessageCreated)
 | 
				
			||||||
    websocketService.on('message-updated', handleMessageUpdated)
 | 
					    websocketService.on('message-updated', handleMessageUpdated)
 | 
				
			||||||
    websocketService.on('message-deleted', handleMessageDeleted)
 | 
					    websocketService.on('message-deleted', handleMessageDeleted)
 | 
				
			||||||
 | 
					    websocketService.on('message-moved', handleMessageMoved)
 | 
				
			||||||
    websocketService.on('file-uploaded', handleFileUploaded)
 | 
					    websocketService.on('file-uploaded', handleFileUploaded)
 | 
				
			||||||
    websocketService.on('channel-created', handleChannelCreated)
 | 
					    websocketService.on('channel-created', handleChannelCreated)
 | 
				
			||||||
    websocketService.on('channel-deleted', handleChannelDeleted)
 | 
					    websocketService.on('channel-deleted', handleChannelDeleted)
 | 
				
			||||||
@@ -151,6 +170,7 @@ export function useWebSocket() {
 | 
				
			|||||||
    websocketService.off('message-created', handleMessageCreated)
 | 
					    websocketService.off('message-created', handleMessageCreated)
 | 
				
			||||||
    websocketService.off('message-updated', handleMessageUpdated)
 | 
					    websocketService.off('message-updated', handleMessageUpdated)
 | 
				
			||||||
    websocketService.off('message-deleted', handleMessageDeleted)
 | 
					    websocketService.off('message-deleted', handleMessageDeleted)
 | 
				
			||||||
 | 
					    websocketService.off('message-moved', handleMessageMoved)
 | 
				
			||||||
    websocketService.off('file-uploaded', handleFileUploaded)
 | 
					    websocketService.off('file-uploaded', handleFileUploaded)
 | 
				
			||||||
    websocketService.off('channel-created', handleChannelCreated)
 | 
					    websocketService.off('channel-created', handleChannelCreated)
 | 
				
			||||||
    websocketService.off('channel-deleted', handleChannelDeleted)
 | 
					    websocketService.off('channel-deleted', handleChannelDeleted)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -118,6 +118,13 @@ class ApiService {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async moveMessage(channelId: number, messageId: number, targetChannelId: number): Promise<{ message: string, messageId: number, targetChannelId: number }> {
 | 
				
			||||||
 | 
					    return this.request(`/channels/${channelId}/messages/${messageId}/move`, {
 | 
				
			||||||
 | 
					      method: 'PUT',
 | 
				
			||||||
 | 
					      body: JSON.stringify({ targetChannelId })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Files
 | 
					  // Files
 | 
				
			||||||
  async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
 | 
					  async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
 | 
				
			||||||
    const formData = new FormData()
 | 
					    const formData = new FormData()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,11 @@ export class SyncService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Sync messages for a channel: merge server data with local data
 | 
					   * Sync messages for a channel: replace local data with server data
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * Prunes any local messages that are no longer present on the server
 | 
				
			||||||
 | 
					   * instead of keeping them around. We still keep unsent messages in the
 | 
				
			||||||
 | 
					   * separate unsent queue handled elsewhere.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  async syncChannelMessages(channelId: number): Promise<void> {
 | 
					  async syncChannelMessages(channelId: number): Promise<void> {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
@@ -19,55 +23,35 @@ export class SyncService {
 | 
				
			|||||||
      // Get server messages
 | 
					      // Get server messages
 | 
				
			||||||
      const serverResponse = await apiService.getMessages(channelId)
 | 
					      const serverResponse = await apiService.getMessages(channelId)
 | 
				
			||||||
      const serverMessages = serverResponse.messages
 | 
					      const serverMessages = serverResponse.messages
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      // Get local messages
 | 
					      console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
 | 
				
			||||||
      const localMessages = appStore.messages[channelId] || []
 | 
					
 | 
				
			||||||
      
 | 
					      // Transform and sort server messages only (pruning locals not on server)
 | 
				
			||||||
      console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
 | 
					      const normalizedServerMessages: ExtendedMessage[] = serverMessages
 | 
				
			||||||
      
 | 
					        .map((msg: any) => {
 | 
				
			||||||
      // Merge messages using a simple strategy:
 | 
					          const transformedMsg: ExtendedMessage = {
 | 
				
			||||||
      // 1. Create a map of all messages by ID
 | 
					            id: msg.id,
 | 
				
			||||||
      // 2. Server messages take precedence (they may have been updated)
 | 
					            channel_id: msg.channelId || msg.channel_id,
 | 
				
			||||||
      // 3. Keep local messages that don't exist on server (may be unsent)
 | 
					            content: msg.content,
 | 
				
			||||||
      
 | 
					            created_at: msg.createdAt || msg.created_at,
 | 
				
			||||||
      const messageMap = new Map<number, ExtendedMessage>()
 | 
					            file_id: msg.fileId || msg.file_id,
 | 
				
			||||||
      
 | 
					            // Map the flattened file fields from backend
 | 
				
			||||||
      // Add local messages first
 | 
					            fileId: msg.fileId,
 | 
				
			||||||
      localMessages.forEach(msg => {
 | 
					            filePath: msg.filePath,
 | 
				
			||||||
        if (typeof msg.id === 'number') {
 | 
					            fileType: msg.fileType,
 | 
				
			||||||
          messageMap.set(msg.id, msg)
 | 
					            fileSize: msg.fileSize,
 | 
				
			||||||
        }
 | 
					            originalName: msg.originalName,
 | 
				
			||||||
      })
 | 
					            fileCreatedAt: msg.fileCreatedAt
 | 
				
			||||||
      
 | 
					          }
 | 
				
			||||||
      // Add/update with server messages (server wins for conflicts)
 | 
					          console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
 | 
				
			||||||
      serverMessages.forEach((msg: any) => {
 | 
					          return transformedMsg
 | 
				
			||||||
        // Transform server message format to match our types
 | 
					        })
 | 
				
			||||||
        const transformedMsg: ExtendedMessage = {
 | 
					        .sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
				
			||||||
          id: msg.id,
 | 
					
 | 
				
			||||||
          channel_id: msg.channelId || msg.channel_id,
 | 
					      console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
 | 
				
			||||||
          content: msg.content,
 | 
					
 | 
				
			||||||
          created_at: msg.createdAt || msg.created_at,
 | 
					      // Update local storage with server truth
 | 
				
			||||||
          file_id: msg.fileId || msg.file_id,
 | 
					      appStore.setMessages(channelId, normalizedServerMessages)
 | 
				
			||||||
          // 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()
 | 
					      await appStore.saveState()
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
@@ -302,4 +286,4 @@ export class SyncService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const syncService = new SyncService()
 | 
					export const syncService = new SyncService()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -71,9 +71,22 @@ export const useAppStore = defineStore('app', () => {
 | 
				
			|||||||
    if (!messages.value[message.channel_id]) {
 | 
					    if (!messages.value[message.channel_id]) {
 | 
				
			||||||
      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')
 | 
					    const channelMessages = messages.value[message.channel_id]
 | 
				
			||||||
    
 | 
					    const existingIndex = channelMessages.findIndex(m => m.id === message.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (existingIndex !== -1) {
 | 
				
			||||||
 | 
					      // Upsert: update existing to avoid duplicates from WebSocket vs sync
 | 
				
			||||||
 | 
					      channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...message }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      channelMessages.push(message)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Keep chronological order by created_at
 | 
				
			||||||
 | 
					    channelMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log('Store: Messages for channel', message.channel_id, 'now has', channelMessages.length, 'messages')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Note: Auto-save is now handled by the sync service to avoid excessive I/O
 | 
					    // Note: Auto-save is now handled by the sync service to avoid excessive I/O
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -99,6 +112,35 @@ export const useAppStore = defineStore('app', () => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const moveMessage = (messageId: number, sourceChannelId: number, targetChannelId: number) => {
 | 
				
			||||||
 | 
					    // Find and remove message from source channel
 | 
				
			||||||
 | 
					    const sourceMessages = messages.value[sourceChannelId] || []
 | 
				
			||||||
 | 
					    const messageIndex = sourceMessages.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (messageIndex === -1) {
 | 
				
			||||||
 | 
					      console.warn(`Message ${messageId} not found in source channel ${sourceChannelId}`)
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const message = sourceMessages[messageIndex]
 | 
				
			||||||
 | 
					    sourceMessages.splice(messageIndex, 1)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Update message's channel_id and add to target channel
 | 
				
			||||||
 | 
					    const updatedMessage = { ...message, channel_id: targetChannelId }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!messages.value[targetChannelId]) {
 | 
				
			||||||
 | 
					      messages.value[targetChannelId] = []
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const targetMessages = messages.value[targetChannelId]
 | 
				
			||||||
 | 
					    targetMessages.push(updatedMessage)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Keep chronological order in target channel
 | 
				
			||||||
 | 
					    targetMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log(`Message ${messageId} moved from channel ${sourceChannelId} to ${targetChannelId}`)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const addUnsentMessage = (message: UnsentMessage) => {
 | 
					  const addUnsentMessage = (message: UnsentMessage) => {
 | 
				
			||||||
    unsentMessages.value.push(message)
 | 
					    unsentMessages.value.push(message)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@@ -169,10 +211,11 @@ export const useAppStore = defineStore('app', () => {
 | 
				
			|||||||
    addMessage,
 | 
					    addMessage,
 | 
				
			||||||
    updateMessage,
 | 
					    updateMessage,
 | 
				
			||||||
    removeMessage,
 | 
					    removeMessage,
 | 
				
			||||||
 | 
					    moveMessage,
 | 
				
			||||||
    addUnsentMessage,
 | 
					    addUnsentMessage,
 | 
				
			||||||
    removeUnsentMessage,
 | 
					    removeUnsentMessage,
 | 
				
			||||||
    updateSettings,
 | 
					    updateSettings,
 | 
				
			||||||
    loadState,
 | 
					    loadState,
 | 
				
			||||||
    saveState
 | 
					    saveState
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,26 @@
 | 
				
			|||||||
 | 
					/* CSS Custom Properties for iOS Safe Areas and Dynamic Viewport */
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --safe-area-inset-top: env(safe-area-inset-top, 0);
 | 
				
			||||||
 | 
					  --safe-area-inset-right: env(safe-area-inset-right, 0);
 | 
				
			||||||
 | 
					  --safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
 | 
				
			||||||
 | 
					  --safe-area-inset-left: env(safe-area-inset-left, 0);
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  /* Dynamic viewport height that accounts for iOS Safari UI changes */
 | 
				
			||||||
 | 
					  --vh-actual: 100vh;
 | 
				
			||||||
 | 
					  --vh-small: 100vh; /* Fallback for browsers without svh support */
 | 
				
			||||||
 | 
					  --vh-large: 100vh; /* Fallback for browsers without lvh support */
 | 
				
			||||||
 | 
					  --vh-dynamic: 100vh; /* Fallback for browsers without dvh support */
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  /* Use newer viewport units where supported */
 | 
				
			||||||
 | 
					  --vh-small: 100svh; /* Small viewport height - excludes browser UI */
 | 
				
			||||||
 | 
					  --vh-large: 100lvh; /* Large viewport height - includes browser UI */
 | 
				
			||||||
 | 
					  --vh-dynamic: 100dvh; /* Dynamic viewport height - changes with browser UI */
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  /* Header height calculations */
 | 
				
			||||||
 | 
					  --header-base-height: 4rem; /* Base header height */
 | 
				
			||||||
 | 
					  --header-total-height: calc(var(--header-base-height) + var(--safe-area-inset-top, 0px));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Minimal reset styles only */
 | 
					/* Minimal reset styles only */
 | 
				
			||||||
* {
 | 
					* {
 | 
				
			||||||
  box-sizing: border-box;
 | 
					  box-sizing: border-box;
 | 
				
			||||||
@@ -7,7 +30,7 @@ body {
 | 
				
			|||||||
  margin: 0;
 | 
					  margin: 0;
 | 
				
			||||||
  padding: 0;
 | 
					  padding: 0;
 | 
				
			||||||
  width: 100vw;
 | 
					  width: 100vw;
 | 
				
			||||||
  height: 100vh;
 | 
					  height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
 | 
					  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
 | 
				
			||||||
  line-height: 1.5;
 | 
					  line-height: 1.5;
 | 
				
			||||||
@@ -16,6 +39,9 @@ body {
 | 
				
			|||||||
  text-rendering: optimizeLegibility;
 | 
					  text-rendering: optimizeLegibility;
 | 
				
			||||||
  -webkit-font-smoothing: antialiased;
 | 
					  -webkit-font-smoothing: antialiased;
 | 
				
			||||||
  -moz-osx-font-smoothing: grayscale;
 | 
					  -moz-osx-font-smoothing: grayscale;
 | 
				
			||||||
 | 
					  /* iOS-specific optimizations */
 | 
				
			||||||
 | 
					  -webkit-text-size-adjust: 100%;
 | 
				
			||||||
 | 
					  -webkit-tap-highlight-color: transparent;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#app {
 | 
					#app {
 | 
				
			||||||
@@ -26,6 +52,26 @@ body {
 | 
				
			|||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* iOS-specific touch and interaction optimizations */
 | 
				
			||||||
 | 
					* {
 | 
				
			||||||
 | 
					  /* Disable callouts on iOS for better touch interactions */
 | 
				
			||||||
 | 
					  -webkit-touch-callout: none;
 | 
				
			||||||
 | 
					  /* Enable momentum scrolling globally for iOS */
 | 
				
			||||||
 | 
					  -webkit-overflow-scrolling: touch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Disable text selection only on UI elements, not form elements */
 | 
				
			||||||
 | 
					button, [role="button"], .no-select {
 | 
				
			||||||
 | 
					  -webkit-user-select: none;
 | 
				
			||||||
 | 
					  user-select: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Ensure text selection works in content and form areas */
 | 
				
			||||||
 | 
					input, textarea, [contenteditable="true"], .allow-select, p, span, div:not([role]), article, section {
 | 
				
			||||||
 | 
					  -webkit-user-select: text;
 | 
				
			||||||
 | 
					  user-select: text;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Accessibility helpers */
 | 
					/* Accessibility helpers */
 | 
				
			||||||
.sr-only {
 | 
					.sr-only {
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,7 @@
 | 
				
			|||||||
      <form @submit.prevent="handleAuth" class="auth-form">
 | 
					      <form @submit.prevent="handleAuth" class="auth-form">
 | 
				
			||||||
        <BaseInput
 | 
					        <BaseInput
 | 
				
			||||||
          v-model="serverUrl"
 | 
					          v-model="serverUrl"
 | 
				
			||||||
 | 
					          ref="serverInput"
 | 
				
			||||||
          type="url"
 | 
					          type="url"
 | 
				
			||||||
          label="Server URL (optional)"
 | 
					          label="Server URL (optional)"
 | 
				
			||||||
          :placeholder="defaultServerUrl"
 | 
					          :placeholder="defaultServerUrl"
 | 
				
			||||||
@@ -59,7 +60,7 @@ const serverUrl = ref('')
 | 
				
			|||||||
const error = ref('')
 | 
					const error = ref('')
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
const tokenInput = ref()
 | 
					const tokenInput = ref()
 | 
				
			||||||
 | 
					const serverInput = ref()
 | 
				
			||||||
// Get default server URL for placeholder
 | 
					// Get default server URL for placeholder
 | 
				
			||||||
const defaultServerUrl = authStore.getDefaultServerUrl()
 | 
					const defaultServerUrl = authStore.getDefaultServerUrl()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +81,7 @@ const handleAuth = async () => {
 | 
				
			|||||||
      router.push('/')
 | 
					      router.push('/')
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      error.value = 'Invalid authentication token or server URL'
 | 
					      error.value = 'Invalid authentication token or server URL'
 | 
				
			||||||
      tokenInput.value?.focus()
 | 
					      serverInput.value?.focus()
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (err) {
 | 
					  } catch (err) {
 | 
				
			||||||
    error.value = 'Authentication failed. Please check your token and server URL.'
 | 
					    error.value = 'Authentication failed. Please check your token and server URL.'
 | 
				
			||||||
@@ -91,14 +92,14 @@ const handleAuth = async () => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  tokenInput.value?.focus()
 | 
					  serverInput.value?.focus()
 | 
				
			||||||
  playSound('intro')
 | 
					  playSound('intro')
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<style scoped>
 | 
					<style scoped>
 | 
				
			||||||
.auth-view {
 | 
					.auth-view {
 | 
				
			||||||
  height: 100vh;
 | 
					  height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  justify-content: center;
 | 
					  justify-content: center;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,6 +36,7 @@
 | 
				
			|||||||
      @select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
 | 
					      @select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
 | 
				
			||||||
      @channel-info="handleChannelInfo"
 | 
					      @channel-info="handleChannelInfo"
 | 
				
			||||||
      @settings="showSettings = true"
 | 
					      @settings="showSettings = true"
 | 
				
			||||||
 | 
					      @close="sidebarOpen = false"
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    <!-- Main Content -->
 | 
					    <!-- Main Content -->
 | 
				
			||||||
@@ -53,6 +54,7 @@
 | 
				
			|||||||
          :messages="appStore.currentMessages"
 | 
					          :messages="appStore.currentMessages"
 | 
				
			||||||
          :unsent-messages="appStore.unsentMessagesForChannel"
 | 
					          :unsent-messages="appStore.unsentMessagesForChannel"
 | 
				
			||||||
          ref="messagesContainer"
 | 
					          ref="messagesContainer"
 | 
				
			||||||
 | 
					          @open-message-dialog="handleOpenMessageDialog"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        <!-- Message Input -->
 | 
					        <!-- Message Input -->
 | 
				
			||||||
@@ -117,6 +119,18 @@
 | 
				
			|||||||
        @close="showChannelInfoDialog = false" 
 | 
					        @close="showChannelInfoDialog = false" 
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </BaseDialog>
 | 
					    </BaseDialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <BaseDialog v-model:show="showMessageDialog" title="">
 | 
				
			||||||
 | 
					      <MessageDialog 
 | 
				
			||||||
 | 
					        v-if="selectedMessage"
 | 
				
			||||||
 | 
					        :message="selectedMessage"
 | 
				
			||||||
 | 
					        :open="showMessageDialog"
 | 
				
			||||||
 | 
					        @close="handleCloseMessageDialog" 
 | 
				
			||||||
 | 
					        @edit="handleEditMessage"
 | 
				
			||||||
 | 
					        @delete="handleDeleteMessage"
 | 
				
			||||||
 | 
					        @move="handleMoveMessage"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </BaseDialog>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -148,9 +162,10 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
 | 
				
			|||||||
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
 | 
					import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
 | 
				
			||||||
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
 | 
					import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
 | 
				
			||||||
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
 | 
					import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
 | 
				
			||||||
 | 
					import MessageDialog from '@/components/dialogs/MessageDialog.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Types
 | 
					// Types
 | 
				
			||||||
import type { ExtendedMessage, Channel } from '@/types'
 | 
					import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const appStore = useAppStore()
 | 
					const appStore = useAppStore()
 | 
				
			||||||
@@ -178,7 +193,9 @@ const showSettings = ref(false)
 | 
				
			|||||||
const showSearchDialog = ref(false)
 | 
					const showSearchDialog = ref(false)
 | 
				
			||||||
const showFileDialog = ref(false)
 | 
					const showFileDialog = ref(false)
 | 
				
			||||||
const showVoiceDialog = ref(false)
 | 
					const showVoiceDialog = ref(false)
 | 
				
			||||||
 | 
					const showMessageDialog = ref(false)
 | 
				
			||||||
const showCameraDialog = ref(false)
 | 
					const showCameraDialog = ref(false)
 | 
				
			||||||
 | 
					const selectedMessage = ref<ExtendedMessage | null>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Mobile sidebar state
 | 
					// Mobile sidebar state
 | 
				
			||||||
const sidebarOpen = ref(false)
 | 
					const sidebarOpen = ref(false)
 | 
				
			||||||
@@ -280,6 +297,21 @@ const setupKeyboardShortcuts = () => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
 | 
					  // Shift+Enter - Open message dialog for focused message
 | 
				
			||||||
 | 
					  addShortcut({
 | 
				
			||||||
 | 
					    key: 'enter',
 | 
				
			||||||
 | 
					    shiftKey: true,
 | 
				
			||||||
 | 
					    handler: () => {
 | 
				
			||||||
 | 
					      const focusedMessage = messagesContainer.value?.getFocusedMessage()
 | 
				
			||||||
 | 
					      if (focusedMessage) {
 | 
				
			||||||
 | 
					        handleOpenMessageDialog(focusedMessage)
 | 
				
			||||||
 | 
					        toastStore.info('Opening message dialog')
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        toastStore.info('No message is focused')
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  // Alt+Numbers - Announce last N messages
 | 
					  // Alt+Numbers - Announce last N messages
 | 
				
			||||||
  for (let i = 1; i <= 9; i++) {
 | 
					  for (let i = 1; i <= 9; i++) {
 | 
				
			||||||
    addShortcut({
 | 
					    addShortcut({
 | 
				
			||||||
@@ -410,6 +442,93 @@ const scrollToBottom = () => {
 | 
				
			|||||||
  messagesContainer.value?.scrollToBottom()
 | 
					  messagesContainer.value?.scrollToBottom()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Message dialog handlers
 | 
				
			||||||
 | 
					const handleOpenMessageDialog = (message: ExtendedMessage | UnsentMessage) => {
 | 
				
			||||||
 | 
					  // Only allow dialog for sent messages (ExtendedMessage), not unsent ones
 | 
				
			||||||
 | 
					  if ('created_at' in message) {
 | 
				
			||||||
 | 
					    selectedMessage.value = message as ExtendedMessage
 | 
				
			||||||
 | 
					    showMessageDialog.value = true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleCloseMessageDialog = () => {
 | 
				
			||||||
 | 
					  showMessageDialog.value = false
 | 
				
			||||||
 | 
					  selectedMessage.value = null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleEditMessage = async (messageId: number, content: string) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    if (!appStore.currentChannelId) return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const response = await apiService.updateMessage(appStore.currentChannelId, messageId, content)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Update the message in the local store
 | 
				
			||||||
 | 
					    const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					    if (messageIndex !== -1) {
 | 
				
			||||||
 | 
					      const updatedMessage = { ...appStore.currentMessages[messageIndex], content: content }
 | 
				
			||||||
 | 
					      appStore.updateMessage(messageId, updatedMessage)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Update the selected message for the dialog
 | 
				
			||||||
 | 
					    if (selectedMessage.value && selectedMessage.value.id === messageId) {
 | 
				
			||||||
 | 
					      selectedMessage.value = { ...selectedMessage.value, content: content }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    toastStore.success('Message updated successfully')
 | 
				
			||||||
 | 
					    handleCloseMessageDialog()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to edit message:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to update message')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleDeleteMessage = async (messageId: number) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    if (!appStore.currentChannelId) return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    await apiService.deleteMessage(appStore.currentChannelId, messageId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Remove the message from the local store
 | 
				
			||||||
 | 
					    const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId)
 | 
				
			||||||
 | 
					    if (messageIndex !== -1) {
 | 
				
			||||||
 | 
					      appStore.currentMessages.splice(messageIndex, 1)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    toastStore.success('Message deleted successfully')
 | 
				
			||||||
 | 
					    handleCloseMessageDialog()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to delete message:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to delete message')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleMoveMessage = async (messageId: number, targetChannelId: number) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    if (!appStore.currentChannelId) return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Find the source channel for the message
 | 
				
			||||||
 | 
					    let sourceChannelId = appStore.currentChannelId
 | 
				
			||||||
 | 
					    const currentMessage = appStore.currentMessages.find(m => m.id === messageId)
 | 
				
			||||||
 | 
					    if (currentMessage) {
 | 
				
			||||||
 | 
					      sourceChannelId = currentMessage.channel_id
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    await apiService.moveMessage(sourceChannelId, messageId, targetChannelId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Optimistically update local state
 | 
				
			||||||
 | 
					    appStore.moveMessage(messageId, sourceChannelId, targetChannelId)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    toastStore.success('Message moved successfully')
 | 
				
			||||||
 | 
					    handleCloseMessageDialog()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Failed to move message:', error)
 | 
				
			||||||
 | 
					    toastStore.error('Failed to move message')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleChannelCreated = async (channelId: number) => {
 | 
					const handleChannelCreated = async (channelId: number) => {
 | 
				
			||||||
  showChannelDialog.value = false
 | 
					  showChannelDialog.value = false
 | 
				
			||||||
  await selectChannel(channelId)
 | 
					  await selectChannel(channelId)
 | 
				
			||||||
@@ -482,7 +601,7 @@ onMounted(async () => {
 | 
				
			|||||||
<style scoped>
 | 
					<style scoped>
 | 
				
			||||||
.main-view {
 | 
					.main-view {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  height: 100vh;
 | 
					  height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
  background: #ffffff;
 | 
					  background: #ffffff;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -524,20 +643,34 @@ onMounted(async () => {
 | 
				
			|||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
  justify-content: space-between;
 | 
					  justify-content: space-between;
 | 
				
			||||||
  padding: 1rem;
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  padding-top: calc(1rem + var(--safe-area-inset-top));
 | 
				
			||||||
 | 
					  padding-left: calc(1rem + var(--safe-area-inset-left));
 | 
				
			||||||
 | 
					  padding-right: calc(1rem + var(--safe-area-inset-right));
 | 
				
			||||||
  background: #f9fafb;
 | 
					  background: #f9fafb;
 | 
				
			||||||
  border-bottom: 1px solid #e5e7eb;
 | 
					  border-bottom: 1px solid #e5e7eb;
 | 
				
			||||||
  position: sticky;
 | 
					  position: fixed;
 | 
				
			||||||
  top: 0;
 | 
					  top: 0;
 | 
				
			||||||
  z-index: 100;
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  right: 0;
 | 
				
			||||||
 | 
					  z-index: 500; /* Higher than sidebar to prevent conflicts */
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.mobile-menu-button,
 | 
					.mobile-menu-button,
 | 
				
			||||||
.mobile-search-button {
 | 
					.mobile-search-button {
 | 
				
			||||||
  background: none;
 | 
					  background: none;
 | 
				
			||||||
  border: none;
 | 
					  border: none;
 | 
				
			||||||
  padding: 0.5rem;
 | 
					  padding: 0.75rem;
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
  color: #6b7280;
 | 
					  color: #6b7280;
 | 
				
			||||||
 | 
					  min-height: 2.75rem; /* 44px minimum for iOS */
 | 
				
			||||||
 | 
					  min-width: 2.75rem;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  /* iOS-specific optimizations */
 | 
				
			||||||
 | 
					  -webkit-appearance: none;
 | 
				
			||||||
 | 
					  -webkit-tap-highlight-color: transparent;
 | 
				
			||||||
 | 
					  -webkit-touch-callout: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.mobile-menu-button:hover,
 | 
					.mobile-menu-button:hover,
 | 
				
			||||||
@@ -567,7 +700,7 @@ onMounted(async () => {
 | 
				
			|||||||
@media (max-width: 768px) {
 | 
					@media (max-width: 768px) {
 | 
				
			||||||
  .main-view {
 | 
					  .main-view {
 | 
				
			||||||
    flex-direction: column;
 | 
					    flex-direction: column;
 | 
				
			||||||
    height: 100vh;
 | 
					    height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .mobile-header {
 | 
					  .mobile-header {
 | 
				
			||||||
@@ -579,14 +712,16 @@ onMounted(async () => {
 | 
				
			|||||||
    position: fixed;
 | 
					    position: fixed;
 | 
				
			||||||
    top: 0;
 | 
					    top: 0;
 | 
				
			||||||
    left: 0;
 | 
					    left: 0;
 | 
				
			||||||
    height: 100vh;
 | 
					    height: var(--vh-dynamic, 100vh);
 | 
				
			||||||
    transform: translateX(-100%);
 | 
					    transform: translateX(-100%);
 | 
				
			||||||
    transition: transform 0.3s ease;
 | 
					    transition: transform 0.3s ease, visibility 0.3s ease;
 | 
				
			||||||
    z-index: 300;
 | 
					    z-index: 400; /* Lower than mobile header but higher than overlay */
 | 
				
			||||||
 | 
					    visibility: hidden; /* Completely hide when closed */
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .sidebar.sidebar-open {
 | 
					  .sidebar.sidebar-open {
 | 
				
			||||||
    transform: translateX(0);
 | 
					    transform: translateX(0);
 | 
				
			||||||
 | 
					    visibility: visible;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .sidebar-overlay {
 | 
					  .sidebar-overlay {
 | 
				
			||||||
@@ -596,6 +731,7 @@ onMounted(async () => {
 | 
				
			|||||||
  .main-content {
 | 
					  .main-content {
 | 
				
			||||||
    flex: 1;
 | 
					    flex: 1;
 | 
				
			||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    padding-top: var(--header-total-height); /* Account for fixed header height with safe area */
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  .chat-container {
 | 
					  .chat-container {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "notebrook",
 | 
				
			||||||
 | 
					  "lockfileVersion": 3,
 | 
				
			||||||
 | 
					  "requires": true,
 | 
				
			||||||
 | 
					  "packages": {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user