Compare commits

...

13 Commits

26 changed files with 1607 additions and 139 deletions

View File

@@ -51,4 +51,30 @@ export const getMessages = async (req: Request, res: Response) => {
const messages = await MessageService.getMessages(channelId);
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' });
}
}

View File

@@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => {
events.on('message-deleted', (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) => {
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
});

View File

@@ -6,6 +6,7 @@ export const router = Router({mergeParams: true});
router.post('/', authenticate, MessageController.createMessage);
router.put('/:messageId', authenticate, MessageController.updateMessage);
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
router.get('/', authenticate, MessageController.getMessages);

View File

@@ -80,4 +80,28 @@ export const getMessage = async (id: string) => {
`);
const row = query.get({ id: id });
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
View File

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

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<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>
<meta name="description" content="Light note taking app in messenger style">
</head>
<body>
<body role="application">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -25,7 +25,7 @@ const toastStore = useToastStore()
<style>
#app {
height: 100vh;
height: var(--vh-dynamic, 100vh);
width: 100vw;
overflow: hidden;
}

View File

@@ -24,7 +24,7 @@
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
ariaLabel?: string
@@ -65,6 +65,12 @@ const handleKeydown = (event: KeyboardEvent) => {
transition: all 0.2s ease;
outline: 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 {
@@ -78,19 +84,32 @@ const handleKeydown = (event: KeyboardEvent) => {
}
/* 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 {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
min-height: 2.75rem; /* 44px minimum for iOS touch targets */
min-width: 2.75rem;
}
.base-button--md {
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 2.75rem;
min-width: 2.75rem;
}
.base-button--lg {
padding: 1rem 1.5rem;
font-size: 1.125rem;
min-height: 3rem;
min-width: 3rem;
}
/* Variants */
@@ -126,6 +145,19 @@ const handleKeydown = (event: KeyboardEvent) => {
.base-button--ghost {
background-color: transparent;
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) {

View File

@@ -157,6 +157,9 @@ defineExpose({
outline: none;
resize: vertical;
min-height: 3rem;
/* iOS-specific optimizations */
-webkit-appearance: none;
-webkit-border-radius: 8px;
}
.base-textarea__field:focus {

View File

@@ -2,7 +2,7 @@
<div class="input-actions">
<BaseButton
variant="ghost"
size="sm"
size="xs"
@click="$emit('file-upload')"
aria-label="Upload file"
:disabled="disabled"
@@ -12,7 +12,7 @@
<BaseButton
variant="ghost"
size="sm"
size="xs"
@click="$emit('camera')"
aria-label="Take photo"
:disabled="disabled"
@@ -22,7 +22,7 @@
<BaseButton
variant="ghost"
size="sm"
size="xs"
@click="$emit('voice')"
aria-label="Record voice message"
:disabled="disabled"
@@ -67,7 +67,7 @@ defineEmits<{
.input-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.25rem; /* Reduced gap to save space */
flex-shrink: 0;
}
</style>

View File

@@ -76,6 +76,7 @@ defineExpose({
<style scoped>
.message-input-container {
padding: 1rem;
padding-bottom: calc(1rem + var(--safe-area-inset-bottom));
background: white;
border-top: 1px solid #e5e7eb;
}
@@ -83,10 +84,35 @@ defineExpose({
.message-input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
gap: 0.5rem; /* Reduced gap to save space */
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 */
@media (prefers-color-scheme: dark) {
.message-input-container {

View File

@@ -4,11 +4,13 @@
'message',
{ 'message--unsent': isUnsent }
]"
ref="rootEl"
:data-message-id="message.id"
:tabindex="tabindex || 0"
:tabindex="tabindex || -1"
:aria-label="messageAriaLabel"
role="listitem"
role="option"
@keydown="handleKeydown"
@click="handleClick"
>
<div class="message__content">
{{ message.content }}
@@ -33,10 +35,12 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, nextTick } from 'vue'
import { useAudio } from '@/composables/useAudio'
import { useToastStore } from '@/stores/toast'
import { useAppStore } from '@/stores/app'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
import { formatSmartTimestamp, formatTimestampForScreenReader } from '@/utils/time'
import FileAttachment from './FileAttachment.vue'
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
@@ -47,6 +51,10 @@ interface Props {
tabindex?: number
}
const emit = defineEmits<{
'open-dialog': [message: ExtendedMessage | UnsentMessage]
}>()
const props = withDefaults(defineProps<Props>(), {
isUnsent: false
})
@@ -57,6 +65,17 @@ const { speak, playSound } = useAudio()
const toastStore = useToastStore()
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
const hasFileAttachment = computed(() => {
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) => {
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
if (event.ctrlKey || event.metaKey || event.altKey) {
@@ -160,6 +186,92 @@ const handleKeydown = (event: KeyboardEvent) => {
} else {
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>
@@ -171,10 +283,14 @@ const handleKeydown = (event: KeyboardEvent) => {
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.message:hover {
background: #f1f3f4;
border-color: #3b82f6;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
}
.message:focus {
@@ -229,6 +345,8 @@ const handleKeydown = (event: KeyboardEvent) => {
.message:hover {
background: #374151;
border-color: #60a5fa;
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1);
}
.message__content {
@@ -239,4 +357,4 @@ const handleKeydown = (event: KeyboardEvent) => {
color: #a0aec0;
}
}
</style>
</style>

View File

@@ -1,33 +1,19 @@
<template>
<div
class="messages-container"
ref="containerRef"
@keydown="handleKeydown"
tabindex="0"
role="list"
:aria-label="messagesAriaLabel"
>
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" tabindex="0" role="listbox"
:aria-label="messagesAriaLabel">
<div class="messages" role="presentation">
<!-- Regular Messages -->
<MessageItem
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:tabindex="index === focusedMessageIndex ? 0 : -1"
:data-message-index="index"
@focus="focusedMessageIndex = index"
/>
<MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
@focus="focusedMessageIndex = index"
@open-dialog="emit('open-message-dialog', $event)" />
<!-- Unsent Messages -->
<MessageItem
v-for="(unsentMsg, index) in unsentMessages"
:key="unsentMsg.id"
:message="unsentMsg"
:is-unsent="true"
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
:data-message-index="messages.length + index"
@focus="focusedMessageIndex = messages.length + index"
/>
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
@open-dialog="emit('open-message-dialog', $event)" />
</div>
</div>
</template>
@@ -44,6 +30,7 @@ interface Props {
const emit = defineEmits<{
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
}>()
const props = defineProps<Props>()
@@ -59,13 +46,13 @@ const totalMessages = computed(() => allMessages.value.length)
const messagesAriaLabel = computed(() => {
const total = totalMessages.value
const current = focusedMessageIndex.value + 1
if (total === 0) {
return 'Messages list, no messages'
} else if (total === 1) {
return 'Messages list, 1 message'
} else {
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
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
const handleKeydown = (event: KeyboardEvent) => {
if (totalMessages.value === 0) return
let newIndex = focusedMessageIndex.value
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 1)
break
case 'ArrowDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
break
case 'PageUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 10)
break
case 'PageDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = totalMessages.value - 1
break
case 'Enter':
case ' ':
event.preventDefault()
selectCurrentMessage()
return
default:
return
}
if (newIndex !== focusedMessageIndex.value) {
focusMessage(newIndex)
}
@@ -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({
scrollToBottom,
focusMessageById
focusMessageById,
getFocusedMessage
})
</script>
@@ -193,6 +189,12 @@ defineExpose({
overflow-y: auto;
padding: 1rem;
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 {
@@ -229,19 +231,19 @@ defineExpose({
.messages-container {
background: #111827;
}
.messages-container:focus {
outline-color: #60a5fa;
}
.messages-container::-webkit-scrollbar-track {
background: #1f2937;
}
.messages-container::-webkit-scrollbar-thumb {
background: #4b5563;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}

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

View File

@@ -6,6 +6,7 @@
<label class="setting-item">
<input
ref="soundInput"
type="checkbox"
v-model="localSettings.soundEnabled"
class="checkbox"
@@ -245,6 +246,7 @@ const isSaving = ref(false)
const isResetting = ref(false)
const showResetConfirm = ref(false)
const selectedVoiceURI = ref('')
const soundInput = ref()
// Computed property for current server URL
const currentServerUrl = computed(() => authStore.serverUrl)
@@ -338,6 +340,7 @@ onMounted(() => {
// Set up voice selection
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
soundInput.value.focus();
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<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
v-for="(channel, index) in channels"
:key="channel.id"
@@ -160,6 +160,10 @@ defineExpose({
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
/* iOS-specific scroll optimizations */
-webkit-overflow-scrolling: touch;
-webkit-scroll-behavior: smooth;
scroll-behavior: smooth;
}

View File

@@ -12,9 +12,10 @@
class="channel-button"
@click="$emit('select', channel.id)"
@focus="handleFocus"
role="option"
:aria-current="isActive"
@keydown="handleKeydown"
:tabindex="tabindex"
:aria-pressed="isActive"
:aria-label="channelAriaLabel"
>
<span class="channel-name">{{ channel.name }}</span>
@@ -23,7 +24,7 @@
</span>
</button>
<button
<button v-if="isActive"
class="channel-info-button"
@click.stop="$emit('info', channel)"
: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
const channelAriaLabel = computed(() => {
let label = `${props.channel.name} channel`
let label = `${props.channel.name}`
if (props.unreadCount) {
label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
}

View File

@@ -1,15 +1,28 @@
<template>
<aside class="sidebar">
<div class="sidebar__header">
<h1 class="sidebar__title">Notebrook</h1>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('create-channel')"
aria-label="Create new channel"
>
+
</BaseButton>
<div class="sidebar__header-left">
<h1 class="sidebar__title">Notebrook</h1>
</div>
<div class="sidebar__header-right">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('create-channel')"
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 class="sidebar__content">
@@ -53,6 +66,7 @@ defineEmits<{
'select-channel': [channelId: number]
'channel-info': [channel: Channel]
'settings': []
'close': []
}>()
</script>
@@ -63,7 +77,7 @@ defineEmits<{
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
height: 100vh;
height: var(--vh-dynamic, 100vh);
}
.sidebar__header {
@@ -76,6 +90,17 @@ defineEmits<{
flex-shrink: 0;
}
.sidebar__header-left {
display: flex;
align-items: center;
}
.sidebar__header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar__title {
margin: 0;
font-size: 1.25rem;
@@ -99,6 +124,10 @@ defineEmits<{
flex-shrink: 0;
}
.sidebar__close-button {
display: none; /* Hidden by default on desktop */
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.sidebar {
@@ -129,10 +158,15 @@ defineEmits<{
.sidebar__header {
padding: 1rem;
padding-top: calc(1rem + var(--safe-area-inset-top));
}
.sidebar__title {
font-size: 1.125rem;
}
.sidebar__close-button {
display: inline-flex; /* Show on mobile */
}
}
</style>

View File

@@ -64,6 +64,24 @@ export function useWebSocket() {
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) => {
// Handle file upload events with flattened format
const messageUpdate: Partial<ExtendedMessage> = {
@@ -127,6 +145,7 @@ export function useWebSocket() {
websocketService.on('message-created', handleMessageCreated)
websocketService.on('message-updated', handleMessageUpdated)
websocketService.on('message-deleted', handleMessageDeleted)
websocketService.on('message-moved', handleMessageMoved)
websocketService.on('file-uploaded', handleFileUploaded)
websocketService.on('channel-created', handleChannelCreated)
websocketService.on('channel-deleted', handleChannelDeleted)
@@ -151,6 +170,7 @@ export function useWebSocket() {
websocketService.off('message-created', handleMessageCreated)
websocketService.off('message-updated', handleMessageUpdated)
websocketService.off('message-deleted', handleMessageDeleted)
websocketService.off('message-moved', handleMessageMoved)
websocketService.off('file-uploaded', handleFileUploaded)
websocketService.off('channel-created', handleChannelCreated)
websocketService.off('channel-deleted', handleChannelDeleted)

View File

@@ -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
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
const formData = new FormData()

View File

@@ -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> {
try {
@@ -19,55 +23,35 @@ export class SyncService {
// Get server messages
const serverResponse = await apiService.getMessages(channelId)
const serverMessages = serverResponse.messages
// Get local messages
const localMessages = appStore.messages[channelId] || []
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
// Merge messages using a simple strategy:
// 1. Create a map of all messages by ID
// 2. Server messages take precedence (they may have been updated)
// 3. Keep local messages that don't exist on server (may be unsent)
const messageMap = new Map<number, ExtendedMessage>()
// Add local messages first
localMessages.forEach(msg => {
if (typeof msg.id === 'number') {
messageMap.set(msg.id, msg)
}
})
// Add/update with server messages (server wins for conflicts)
serverMessages.forEach((msg: any) => {
// Transform server message format to match our types
const transformedMsg: ExtendedMessage = {
id: msg.id,
channel_id: msg.channelId || msg.channel_id,
content: msg.content,
created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id,
// Map the flattened file fields from backend
fileId: msg.fileId,
filePath: msg.filePath,
fileType: msg.fileType,
fileSize: msg.fileSize,
originalName: msg.originalName,
fileCreatedAt: msg.fileCreatedAt
}
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
messageMap.set(msg.id, transformedMsg)
})
// Convert back to array, sorted by creation time
const mergedMessages = Array.from(messageMap.values())
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log(`Merged result: ${mergedMessages.length} messages`)
// Update local storage
appStore.setMessages(channelId, mergedMessages)
console.log(`Server has ${serverMessages.length} messages, replacing local set for channel ${channelId}`)
// Transform and sort server messages only (pruning locals not on server)
const normalizedServerMessages: ExtendedMessage[] = serverMessages
.map((msg: any) => {
const transformedMsg: ExtendedMessage = {
id: msg.id,
channel_id: msg.channelId || msg.channel_id,
content: msg.content,
created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id,
// Map the flattened file fields from backend
fileId: msg.fileId,
filePath: msg.filePath,
fileType: msg.fileType,
fileSize: msg.fileSize,
originalName: msg.originalName,
fileCreatedAt: msg.fileCreatedAt
}
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
return transformedMsg
})
.sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
// Update local storage with server truth
appStore.setMessages(channelId, normalizedServerMessages)
await appStore.saveState()
} catch (error) {
@@ -302,4 +286,4 @@ export class SyncService {
}
}
export const syncService = new SyncService()
export const syncService = new SyncService()

View File

@@ -71,9 +71,22 @@ export const useAppStore = defineStore('app', () => {
if (!messages.value[message.channel_id]) {
messages.value[message.channel_id] = []
}
messages.value[message.channel_id].push(message)
console.log('Store: Messages for channel', message.channel_id, 'now has', messages.value[message.channel_id].length, 'messages')
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
}
@@ -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) => {
unsentMessages.value.push(message)
}
@@ -169,10 +211,11 @@ export const useAppStore = defineStore('app', () => {
addMessage,
updateMessage,
removeMessage,
moveMessage,
addUnsentMessage,
removeUnsentMessage,
updateSettings,
loadState,
saveState
}
})
})

View File

@@ -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 */
* {
box-sizing: border-box;
@@ -7,7 +30,7 @@ body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
height: var(--vh-dynamic, 100vh);
overflow: hidden;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
@@ -16,6 +39,9 @@ body {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* iOS-specific optimizations */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
#app {
@@ -26,6 +52,26 @@ body {
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 */
.sr-only {
position: absolute;

View File

@@ -9,6 +9,7 @@
<form @submit.prevent="handleAuth" class="auth-form">
<BaseInput
v-model="serverUrl"
ref="serverInput"
type="url"
label="Server URL (optional)"
:placeholder="defaultServerUrl"
@@ -59,7 +60,7 @@ const serverUrl = ref('')
const error = ref('')
const isLoading = ref(false)
const tokenInput = ref()
const serverInput = ref()
// Get default server URL for placeholder
const defaultServerUrl = authStore.getDefaultServerUrl()
@@ -80,7 +81,7 @@ const handleAuth = async () => {
router.push('/')
} else {
error.value = 'Invalid authentication token or server URL'
tokenInput.value?.focus()
serverInput.value?.focus()
}
} catch (err) {
error.value = 'Authentication failed. Please check your token and server URL.'
@@ -91,14 +92,14 @@ const handleAuth = async () => {
}
onMounted(() => {
tokenInput.value?.focus()
serverInput.value?.focus()
playSound('intro')
})
</script>
<style scoped>
.auth-view {
height: 100vh;
height: var(--vh-dynamic, 100vh);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -36,6 +36,7 @@
@select-channel="(id) => { selectChannel(id); sidebarOpen = false }"
@channel-info="handleChannelInfo"
@settings="showSettings = true"
@close="sidebarOpen = false"
/>
<!-- Main Content -->
@@ -53,6 +54,7 @@
:messages="appStore.currentMessages"
:unsent-messages="appStore.unsentMessagesForChannel"
ref="messagesContainer"
@open-message-dialog="handleOpenMessageDialog"
/>
<!-- Message Input -->
@@ -117,6 +119,18 @@
@close="showChannelInfoDialog = false"
/>
</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>
</template>
@@ -148,9 +162,10 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
// Types
import type { ExtendedMessage, Channel } from '@/types'
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
const router = useRouter()
const appStore = useAppStore()
@@ -178,7 +193,9 @@ const showSettings = ref(false)
const showSearchDialog = ref(false)
const showFileDialog = ref(false)
const showVoiceDialog = ref(false)
const showMessageDialog = ref(false)
const showCameraDialog = ref(false)
const selectedMessage = ref<ExtendedMessage | null>(null)
// Mobile sidebar state
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
for (let i = 1; i <= 9; i++) {
addShortcut({
@@ -410,6 +442,93 @@ const 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) => {
showChannelDialog.value = false
await selectChannel(channelId)
@@ -482,7 +601,7 @@ onMounted(async () => {
<style scoped>
.main-view {
display: flex;
height: 100vh;
height: var(--vh-dynamic, 100vh);
background: #ffffff;
}
@@ -524,20 +643,34 @@ onMounted(async () => {
align-items: center;
justify-content: space-between;
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;
border-bottom: 1px solid #e5e7eb;
position: sticky;
position: fixed;
top: 0;
z-index: 100;
left: 0;
right: 0;
z-index: 500; /* Higher than sidebar to prevent conflicts */
}
.mobile-menu-button,
.mobile-search-button {
background: none;
border: none;
padding: 0.5rem;
padding: 0.75rem;
cursor: pointer;
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,
@@ -567,7 +700,7 @@ onMounted(async () => {
@media (max-width: 768px) {
.main-view {
flex-direction: column;
height: 100vh;
height: var(--vh-dynamic, 100vh);
}
.mobile-header {
@@ -579,14 +712,16 @@ onMounted(async () => {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: var(--vh-dynamic, 100vh);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 300;
transition: transform 0.3s ease, visibility 0.3s ease;
z-index: 400; /* Lower than mobile header but higher than overlay */
visibility: hidden; /* Completely hide when closed */
}
.sidebar.sidebar-open {
transform: translateX(0);
visibility: visible;
}
.sidebar-overlay {
@@ -596,6 +731,7 @@ onMounted(async () => {
.main-content {
flex: 1;
overflow: hidden;
padding-top: var(--header-total-height); /* Account for fixed header height with safe area */
}
.chat-container {

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "notebrook",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}