Compare commits
18 Commits
864f0a5a45
...
feat/new-f
Author | SHA1 | Date | |
---|---|---|---|
60c2a18dbe | |||
6286c1e0c9 | |||
28f6fad818 | |||
5c76c35d81 | |||
2b1bf5040f | |||
22b8392fd5 | |||
9948d1c25b | |||
cf15a0f9c2 | |||
452192d0a9 | |||
0d50359dae | |||
420ff46f05 | |||
b312065d3d | |||
b07916309e | |||
6585ec2abb | |||
4dcacd0d73 | |||
f2ac7d7209 | |||
fa1cbdf97e | |||
8c0f8c6b44 |
@@ -18,7 +18,16 @@ export const uploadFile = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
|
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
|
||||||
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
|
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
|
||||||
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType });
|
res.json({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
channel_id: parseInt(channelId),
|
||||||
|
message_id: parseInt(messageId),
|
||||||
|
file_path: filePath,
|
||||||
|
file_type: fileType,
|
||||||
|
file_size: fileSize,
|
||||||
|
original_name: originalName,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -52,3 +52,29 @@ export const getMessages = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
@@ -11,11 +11,16 @@ export const uploadFile = async (channelId: string, messageId: string, filePath:
|
|||||||
const result2 = updateQuery.run({ fileId: fileId, messageId: messageId });
|
const result2 = updateQuery.run({ fileId: fileId, messageId: messageId });
|
||||||
|
|
||||||
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
|
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
|
||||||
return result2; ''
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFiles = async (messageId: string) => {
|
export const getFiles = async (messageId: string) => {
|
||||||
const query = db.prepare(`SELECT * FROM files WHERE messageId = $messageId`);
|
// Get the file linked to this message via the fileId in the messages table
|
||||||
|
const query = db.prepare(`
|
||||||
|
SELECT files.* FROM files
|
||||||
|
JOIN messages ON messages.fileId = files.id
|
||||||
|
WHERE messages.id = $messageId
|
||||||
|
`);
|
||||||
const rows = query.all({ messageId: messageId });
|
const rows = query.all({ messageId: messageId });
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
@@ -81,3 +81,27 @@ 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) {
|
||||||
|
@@ -8,7 +8,6 @@
|
|||||||
@keydown.esc="handleClose"
|
@keydown.esc="handleClose"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-labelledby="titleId"
|
:aria-labelledby="titleId"
|
||||||
:aria-describedby="contentId"
|
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@@ -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 {
|
||||||
|
@@ -34,6 +34,7 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const fileExtension = computed(() => {
|
const fileExtension = computed(() => {
|
||||||
|
if (!props.file.original_name) return ''
|
||||||
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -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 }}
|
||||||
@@ -20,8 +22,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message__meta">
|
<div class="message__meta">
|
||||||
<time v-if="!isUnsent && 'created_at' in message" class="message__time">
|
<time
|
||||||
{{ formatTime(message.created_at) }}
|
v-if="!isUnsent && 'created_at' in message"
|
||||||
|
class="message__time"
|
||||||
|
:datetime="message.created_at"
|
||||||
|
>
|
||||||
|
{{ formatSmartTimestamp(message.created_at) }}
|
||||||
</time>
|
</time>
|
||||||
<span v-else class="message__status">Sending...</span>
|
<span v-else class="message__status">Sending...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,10 +35,13 @@
|
|||||||
</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 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'
|
||||||
|
|
||||||
@@ -42,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
|
||||||
})
|
})
|
||||||
@@ -52,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
|
||||||
@@ -61,21 +85,30 @@ const hasFileAttachment = computed(() => {
|
|||||||
const fileAttachment = computed((): FileAttachmentType | null => {
|
const fileAttachment = computed((): FileAttachmentType | null => {
|
||||||
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
|
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
|
||||||
|
|
||||||
|
// Check if we have the minimum required file metadata
|
||||||
|
if (!props.message.filePath || !props.message.originalName) {
|
||||||
|
console.warn('File attachment missing metadata:', {
|
||||||
|
fileId: props.message.fileId,
|
||||||
|
filePath: props.message.filePath,
|
||||||
|
originalName: props.message.originalName,
|
||||||
|
fileType: props.message.fileType
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: props.message.fileId!,
|
id: props.message.fileId!,
|
||||||
channel_id: props.message.channel_id,
|
channel_id: props.message.channel_id,
|
||||||
message_id: props.message.id,
|
message_id: props.message.id,
|
||||||
file_path: props.message.filePath!,
|
file_path: props.message.filePath!,
|
||||||
file_type: props.message.fileType!,
|
file_type: props.message.fileType || 'application/octet-stream',
|
||||||
file_size: props.message.fileSize!,
|
file_size: props.message.fileSize || 0,
|
||||||
original_name: props.message.originalName!,
|
original_name: props.message.originalName!,
|
||||||
created_at: props.message.fileCreatedAt || props.message.created_at
|
created_at: props.message.fileCreatedAt || props.message.created_at
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTime = (timestamp: string): string => {
|
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||||
return new Date(timestamp).toLocaleTimeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create comprehensive aria-label for screen readers
|
// Create comprehensive aria-label for screen readers
|
||||||
const messageAriaLabel = computed(() => {
|
const messageAriaLabel = computed(() => {
|
||||||
@@ -95,8 +128,8 @@ const messageAriaLabel = computed(() => {
|
|||||||
|
|
||||||
// Add timestamp
|
// Add timestamp
|
||||||
if ('created_at' in props.message && props.message.created_at) {
|
if ('created_at' in props.message && props.message.created_at) {
|
||||||
const time = formatTime(props.message.created_at)
|
const time = formatTimestampForScreenReader(props.message.created_at)
|
||||||
label += `. Sent at ${time}`
|
label += `. Sent ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add status for unsent messages
|
// Add status for unsent messages
|
||||||
@@ -127,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) {
|
||||||
@@ -146,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>
|
||||||
@@ -157,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 {
|
||||||
@@ -215,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 {
|
||||||
|
@@ -1,34 +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"
|
|
||||||
:aria-description="navigationHint"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
:tabindex="index === focusedMessageIndex ? 0 : -1"
|
|
||||||
:data-message-index="index"
|
|
||||||
@focus="focusedMessageIndex = index"
|
@focus="focusedMessageIndex = index"
|
||||||
/>
|
@open-dialog="emit('open-message-dialog', $event)" />
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -45,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>()
|
||||||
@@ -66,7 +52,7 @@ const messagesAriaLabel = computed(() => {
|
|||||||
} 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`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,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>
|
||||||
|
|
||||||
@@ -194,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 {
|
||||||
|
@@ -280,9 +280,13 @@ const sendPhoto = async () => {
|
|||||||
// Upload photo
|
// Upload photo
|
||||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||||
|
|
||||||
// Immediately update the local message with file metadata
|
// Create complete message with file metadata
|
||||||
const updatedMessage = {
|
const completeMessage = {
|
||||||
...message,
|
id: message.id,
|
||||||
|
channel_id: appStore.currentChannelId!,
|
||||||
|
content: message.content,
|
||||||
|
created_at: message.created_at,
|
||||||
|
file_id: uploadedFile.id,
|
||||||
fileId: uploadedFile.id,
|
fileId: uploadedFile.id,
|
||||||
filePath: uploadedFile.file_path,
|
filePath: uploadedFile.file_path,
|
||||||
fileType: uploadedFile.file_type,
|
fileType: uploadedFile.file_type,
|
||||||
@@ -291,8 +295,8 @@ const sendPhoto = async () => {
|
|||||||
fileCreatedAt: uploadedFile.created_at
|
fileCreatedAt: uploadedFile.created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the message in the store
|
// Add the complete message to the store (this will trigger immediate UI update)
|
||||||
appStore.updateMessage(message.id, updatedMessage)
|
appStore.addMessage(completeMessage)
|
||||||
|
|
||||||
toastStore.success('Photo sent!')
|
toastStore.success('Photo sent!')
|
||||||
emit('sent')
|
emit('sent')
|
||||||
|
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>
|
@@ -58,7 +58,7 @@
|
|||||||
{{ result.content }}
|
{{ result.content }}
|
||||||
</div>
|
</div>
|
||||||
<div class="result-time">
|
<div class="result-time">
|
||||||
{{ formatTime(result.created_at) }}
|
{{ formatSmartTimestamp(result.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +79,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { apiService } from '@/services/api'
|
import { apiService } from '@/services/api'
|
||||||
|
import { formatSmartTimestamp } from '@/utils/time'
|
||||||
import BaseInput from '@/components/base/BaseInput.vue'
|
import BaseInput from '@/components/base/BaseInput.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import type { Message, ExtendedMessage } from '@/types'
|
import type { Message, ExtendedMessage } from '@/types'
|
||||||
@@ -140,16 +141,7 @@ const getChannelName = (channelId: number): string => {
|
|||||||
return channel?.name || `Channel ${channelId}`
|
return channel?.name || `Channel ${channelId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (timestamp: string): string => {
|
// formatTime function removed - now using formatSmartTimestamp from utils
|
||||||
if (!timestamp) return 'Unknown time'
|
|
||||||
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return 'Invalid date'
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searchInput.value?.focus()
|
searchInput.value?.focus()
|
||||||
|
@@ -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"
|
||||||
@@ -145,6 +146,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Account</h3>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>Current Server</label>
|
||||||
|
<div class="server-info">
|
||||||
|
{{ currentServerUrl || 'Default' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleLogout"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="showResetConfirm = true"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
Reset All Data
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,14 +193,42 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Reset Data Confirmation Dialog -->
|
||||||
|
<div v-if="showResetConfirm" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Reset All Data</h3>
|
||||||
|
<p>This will permanently delete all local data including messages, settings, and authentication. This cannot be undone.</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="showResetConfirm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="handleResetData"
|
||||||
|
:loading="isResetting"
|
||||||
|
>
|
||||||
|
Reset All Data
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { clear } from 'idb-keyval'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import type { AppSettings } from '@/types'
|
import type { AppSettings } from '@/types'
|
||||||
|
|
||||||
@@ -176,12 +236,20 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const { availableVoices, speak, setVoice } = useAudio()
|
const { availableVoices, speak, setVoice } = useAudio()
|
||||||
|
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const isResetting = ref(false)
|
||||||
|
const showResetConfirm = ref(false)
|
||||||
const selectedVoiceURI = ref('')
|
const selectedVoiceURI = ref('')
|
||||||
|
const soundInput = ref()
|
||||||
|
|
||||||
|
// Computed property for current server URL
|
||||||
|
const currentServerUrl = computed(() => authStore.serverUrl)
|
||||||
const localSettings = reactive<AppSettings>({
|
const localSettings = reactive<AppSettings>({
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
speechEnabled: true,
|
speechEnabled: true,
|
||||||
@@ -229,12 +297,50 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authStore.clearAuth()
|
||||||
|
toastStore.success('Logged out successfully')
|
||||||
|
emit('close')
|
||||||
|
router.push('/auth')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error)
|
||||||
|
toastStore.error('Logout failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetData = async () => {
|
||||||
|
isResetting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear all IndexedDB data
|
||||||
|
await clear()
|
||||||
|
|
||||||
|
// Clear stores
|
||||||
|
await authStore.clearAuth()
|
||||||
|
appStore.$reset()
|
||||||
|
|
||||||
|
toastStore.success('All data has been reset')
|
||||||
|
showResetConfirm.value = false
|
||||||
|
emit('close')
|
||||||
|
|
||||||
|
// Redirect to auth page
|
||||||
|
router.push('/auth')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reset failed:', error)
|
||||||
|
toastStore.error('Failed to reset data')
|
||||||
|
} finally {
|
||||||
|
isResetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Copy current settings to local state
|
// Copy current settings to local state
|
||||||
Object.assign(localSettings, appStore.settings)
|
Object.assign(localSettings, appStore.settings)
|
||||||
|
|
||||||
// Set up voice selection
|
// Set up voice selection
|
||||||
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
|
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
|
||||||
|
soundInput.value.focus();
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -340,6 +446,63 @@ onMounted(() => {
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.setting-group h3 {
|
.setting-group h3 {
|
||||||
@@ -360,5 +523,25 @@ onMounted(() => {
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
border-top-color: #374151;
|
border-top-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog p {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@@ -188,9 +188,13 @@ const sendVoiceMessage = async () => {
|
|||||||
// Upload voice file
|
// Upload voice file
|
||||||
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
|
||||||
|
|
||||||
// Immediately update the local message with file metadata
|
// Create complete message with file metadata
|
||||||
const updatedMessage = {
|
const completeMessage = {
|
||||||
...message,
|
id: message.id,
|
||||||
|
channel_id: appStore.currentChannelId!,
|
||||||
|
content: message.content,
|
||||||
|
created_at: message.created_at,
|
||||||
|
file_id: uploadedFile.id,
|
||||||
fileId: uploadedFile.id,
|
fileId: uploadedFile.id,
|
||||||
filePath: uploadedFile.file_path,
|
filePath: uploadedFile.file_path,
|
||||||
fileType: uploadedFile.file_type,
|
fileType: uploadedFile.file_type,
|
||||||
@@ -199,8 +203,8 @@ const sendVoiceMessage = async () => {
|
|||||||
fileCreatedAt: uploadedFile.created_at
|
fileCreatedAt: uploadedFile.created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the message in the store
|
// Add the complete message to the store (this will trigger immediate UI update)
|
||||||
appStore.updateMessage(message.id, updatedMessage)
|
appStore.addMessage(completeMessage)
|
||||||
|
|
||||||
toastStore.success('Voice message sent!')
|
toastStore.success('Voice message sent!')
|
||||||
clearRecording()
|
clearRecording()
|
||||||
|
@@ -1,20 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="channel-list-container">
|
<div class="channel-list-container" ref="containerRef">
|
||||||
<ul class="channel-list" role="list">
|
<ul class="channel-list" role="listbox" aria-label="Channels">
|
||||||
<ChannelListItem
|
<ChannelListItem
|
||||||
v-for="channel in channels"
|
v-for="(channel, index) in channels"
|
||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
:channel="channel"
|
:channel="channel"
|
||||||
:is-active="channel.id === currentChannelId"
|
:is-active="channel.id === currentChannelId"
|
||||||
:unread-count="unreadCounts[channel.id]"
|
:unread-count="unreadCounts[channel.id]"
|
||||||
@select="$emit('select-channel', $event)"
|
:tabindex="index === focusedChannelIndex ? 0 : -1"
|
||||||
|
:channel-index="index"
|
||||||
|
:data-channel-index="index"
|
||||||
|
@select="handleChannelSelect"
|
||||||
@info="$emit('channel-info', $event)"
|
@info="$emit('channel-info', $event)"
|
||||||
|
@keydown="handleChannelKeydown"
|
||||||
|
@focus="handleChannelFocus"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||||
import ChannelListItem from './ChannelListItem.vue'
|
import ChannelListItem from './ChannelListItem.vue'
|
||||||
import type { Channel } from '@/types'
|
import type { Channel } from '@/types'
|
||||||
|
|
||||||
@@ -24,12 +30,129 @@ interface Props {
|
|||||||
unreadCounts: Record<number, number>
|
unreadCounts: Record<number, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const emit = defineEmits<{
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'select-channel': [channelId: number]
|
'select-channel': [channelId: number]
|
||||||
'channel-info': [channel: Channel]
|
'channel-info': [channel: Channel]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement>()
|
||||||
|
const focusedChannelIndex = ref(0)
|
||||||
|
|
||||||
|
// Handle individual channel events
|
||||||
|
const handleChannelSelect = (channelId: number) => {
|
||||||
|
emit('select-channel', channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChannelFocus = (index: number) => {
|
||||||
|
focusedChannelIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChannelKeydown = (event: KeyboardEvent, channelIndex: number) => {
|
||||||
|
if (props.channels.length === 0) return
|
||||||
|
|
||||||
|
// Don't handle keys with modifiers - let them bubble up for global shortcuts
|
||||||
|
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndex = channelIndex
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault()
|
||||||
|
newIndex = Math.max(0, channelIndex - 1)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault()
|
||||||
|
newIndex = Math.min(props.channels.length - 1, channelIndex + 1)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault()
|
||||||
|
newIndex = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault()
|
||||||
|
newIndex = props.channels.length - 1
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault()
|
||||||
|
const selectedChannel = props.channels[channelIndex]
|
||||||
|
if (selectedChannel) {
|
||||||
|
emit('select-channel', selectedChannel.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
case 'i':
|
||||||
|
case 'I':
|
||||||
|
// Only handle 'i' without modifiers
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
const infoChannel = props.channels[channelIndex]
|
||||||
|
if (infoChannel) {
|
||||||
|
emit('channel-info', infoChannel)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIndex !== channelIndex) {
|
||||||
|
focusChannel(newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusChannel = (index: number) => {
|
||||||
|
focusedChannelIndex.value = index
|
||||||
|
nextTick(() => {
|
||||||
|
const buttonElement = containerRef.value?.querySelector(`[data-channel-index="${index}"] .channel-button`) as HTMLElement
|
||||||
|
if (buttonElement) {
|
||||||
|
buttonElement.focus()
|
||||||
|
buttonElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Watch for channels changes and adjust focus
|
||||||
|
watch(() => props.channels.length, (newLength) => {
|
||||||
|
if (focusedChannelIndex.value >= newLength) {
|
||||||
|
focusedChannelIndex.value = Math.max(0, newLength - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set initial focus to current channel or first channel
|
||||||
|
watch(() => props.currentChannelId, (newChannelId) => {
|
||||||
|
if (newChannelId) {
|
||||||
|
const index = props.channels.findIndex(channel => channel.id === newChannelId)
|
||||||
|
if (index !== -1) {
|
||||||
|
focusedChannelIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Focus the current channel if available
|
||||||
|
if (props.currentChannelId) {
|
||||||
|
const index = props.channels.findIndex(channel => channel.id === props.currentChannelId)
|
||||||
|
if (index !== -1) {
|
||||||
|
focusedChannelIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focusChannel
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -37,8 +160,13 @@ defineEmits<{
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.channel-list {
|
.channel-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@@ -4,13 +4,19 @@
|
|||||||
'channel-item',
|
'channel-item',
|
||||||
{ 'channel-item--active': isActive }
|
{ 'channel-item--active': isActive }
|
||||||
]"
|
]"
|
||||||
|
:data-channel-index="channelIndex"
|
||||||
|
role="listitem"
|
||||||
>
|
>
|
||||||
<div class="channel-wrapper">
|
<div class="channel-wrapper">
|
||||||
<button
|
<button
|
||||||
class="channel-button"
|
class="channel-button"
|
||||||
@click="$emit('select', channel.id)"
|
@click="$emit('select', channel.id)"
|
||||||
:aria-pressed="isActive"
|
@focus="handleFocus"
|
||||||
:aria-label="`Select channel ${channel.name}`"
|
role="option"
|
||||||
|
:aria-current="isActive"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
:aria-label="channelAriaLabel"
|
||||||
>
|
>
|
||||||
<span class="channel-name">{{ channel.name }}</span>
|
<span class="channel-name">{{ channel.name }}</span>
|
||||||
<span v-if="unreadCount" class="channel-unread">
|
<span v-if="unreadCount" class="channel-unread">
|
||||||
@@ -18,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}`"
|
||||||
@@ -31,20 +37,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { Channel } from '@/types'
|
import type { Channel } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: Channel
|
channel: Channel
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
unreadCount?: number
|
unreadCount?: number
|
||||||
|
tabindex?: number
|
||||||
|
channelIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const emit = defineEmits<{
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
select: [channelId: number]
|
select: [channelId: number]
|
||||||
info: [channel: Channel]
|
info: [channel: Channel]
|
||||||
|
focus: [index: number]
|
||||||
|
keydown: [event: KeyboardEvent, index: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Better ARIA label that announces the channel name and unread count
|
||||||
|
const channelAriaLabel = computed(() => {
|
||||||
|
let label = `${props.channel.name}`
|
||||||
|
if (props.unreadCount) {
|
||||||
|
label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (props.channelIndex !== undefined) {
|
||||||
|
emit('focus', props.channelIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (props.channelIndex !== undefined) {
|
||||||
|
emit('keydown', event, props.channelIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar__header">
|
<div class="sidebar__header">
|
||||||
|
<div class="sidebar__header-left">
|
||||||
<h1 class="sidebar__title">Notebrook</h1>
|
<h1 class="sidebar__title">Notebrook</h1>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar__header-right">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -10,6 +13,16 @@
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</BaseButton>
|
</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>
|
@@ -25,11 +25,8 @@ export function useKeyboardShortcuts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
// Skip shortcuts when focused on input/textarea elements
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
|
const isInInputField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA'
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: ShortcutConfig = {
|
const config: ShortcutConfig = {
|
||||||
key: event.key.toLowerCase(),
|
key: event.key.toLowerCase(),
|
||||||
@@ -44,6 +41,16 @@ export function useKeyboardShortcuts() {
|
|||||||
const shortcut = shortcuts.value.get(shortcutKey)
|
const shortcut = shortcuts.value.get(shortcutKey)
|
||||||
|
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
|
// Allow certain shortcuts to work globally, even in input fields
|
||||||
|
const isGlobalShortcut = (shortcut.ctrlKey && shortcut.shiftKey) ||
|
||||||
|
shortcut.altKey ||
|
||||||
|
shortcut.key === 'escape'
|
||||||
|
|
||||||
|
// Skip shortcuts that shouldn't work in input fields
|
||||||
|
if (isInInputField && !isGlobalShortcut) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shortcut.preventDefault !== false) {
|
if (shortcut.preventDefault !== false) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,14 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { websocketService } from '@/services/websocket'
|
import { websocketService } from '@/services/websocket'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
import type { Channel, ExtendedMessage, FileAttachment } from '@/types'
|
||||||
|
|
||||||
export function useWebSocket() {
|
export function useWebSocket() {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const { announceMessage } = useAudio()
|
const { announceMessage } = useAudio()
|
||||||
|
|
||||||
@@ -62,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> = {
|
||||||
@@ -125,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)
|
||||||
@@ -149,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)
|
||||||
@@ -157,6 +179,11 @@ export function useWebSocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Set custom server URL if available
|
||||||
|
if (authStore.serverUrl) {
|
||||||
|
websocketService.setServerUrl(authStore.serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
setupEventHandlers()
|
setupEventHandlers()
|
||||||
websocketService.connect()
|
websocketService.connect()
|
||||||
})
|
})
|
||||||
|
@@ -9,6 +9,11 @@ class ApiService {
|
|||||||
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
|
console.log('API service token set:', token ? `${token.substring(0, 10)}...` : 'null')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBaseUrl(url: string) {
|
||||||
|
this.baseUrl = url
|
||||||
|
console.log('API service base URL set:', url)
|
||||||
|
}
|
||||||
|
|
||||||
private getHeaders(): HeadersInit {
|
private getHeaders(): HeadersInit {
|
||||||
return {
|
return {
|
||||||
'Authorization': this.token,
|
'Authorization': this.token,
|
||||||
@@ -113,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 {
|
||||||
@@ -20,28 +24,11 @@ export class SyncService {
|
|||||||
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] || []
|
|
||||||
|
|
||||||
console.log(`Server has ${serverMessages.length} messages, local has ${localMessages.length} messages`)
|
// Transform and sort server messages only (pruning locals not on server)
|
||||||
|
const normalizedServerMessages: ExtendedMessage[] = serverMessages
|
||||||
// Merge messages using a simple strategy:
|
.map((msg: any) => {
|
||||||
// 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 = {
|
const transformedMsg: ExtendedMessage = {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
channel_id: msg.channelId || msg.channel_id,
|
channel_id: msg.channelId || msg.channel_id,
|
||||||
@@ -57,17 +44,14 @@ export class SyncService {
|
|||||||
fileCreatedAt: msg.fileCreatedAt
|
fileCreatedAt: msg.fileCreatedAt
|
||||||
}
|
}
|
||||||
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
|
console.log(`Sync: Processing message ${msg.id}, has file:`, !!msg.fileId, `(${msg.originalName})`)
|
||||||
messageMap.set(msg.id, transformedMsg)
|
return transformedMsg
|
||||||
})
|
})
|
||||||
|
.sort((a: ExtendedMessage, b: ExtendedMessage) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||||
|
|
||||||
// Convert back to array, sorted by creation time
|
console.log(`Pruned + normalized result: ${normalizedServerMessages.length} messages`)
|
||||||
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 with server truth
|
||||||
|
appStore.setMessages(channelId, normalizedServerMessages)
|
||||||
// Update local storage
|
|
||||||
appStore.setMessages(channelId, mergedMessages)
|
|
||||||
await appStore.saveState()
|
await appStore.saveState()
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -77,7 +61,7 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to send all unsent messages
|
* Attempt to send all unsent messages (text and file messages)
|
||||||
*/
|
*/
|
||||||
async retryUnsentMessages(): Promise<void> {
|
async retryUnsentMessages(): Promise<void> {
|
||||||
const appStore = this.getAppStore()
|
const appStore = this.getAppStore()
|
||||||
@@ -86,11 +70,49 @@ export class SyncService {
|
|||||||
|
|
||||||
for (const unsentMsg of [...unsentMessages]) {
|
for (const unsentMsg of [...unsentMessages]) {
|
||||||
try {
|
try {
|
||||||
console.log(`Sending unsent message: ${unsentMsg.content}`)
|
console.log(`Sending unsent ${unsentMsg.messageType || 'text'} message: ${unsentMsg.content}`)
|
||||||
|
|
||||||
// Try to send the message
|
if (unsentMsg.messageType === 'voice' || unsentMsg.messageType === 'image') {
|
||||||
|
// Handle file message retry
|
||||||
|
if (!unsentMsg.fileData) {
|
||||||
|
console.error(`File message ${unsentMsg.id} missing file data, removing`)
|
||||||
|
appStore.removeUnsentMessage(unsentMsg.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message and upload file
|
||||||
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||||
console.log(`Successfully sent unsent message, got ID: ${response.id}`)
|
|
||||||
|
// Create file from stored blob data
|
||||||
|
const file = new File([unsentMsg.fileData.blob], unsentMsg.fileData.fileName, {
|
||||||
|
type: unsentMsg.fileData.fileType
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadedFile = await apiService.uploadFile(unsentMsg.channelId, response.id, file)
|
||||||
|
|
||||||
|
// Create complete message with file metadata
|
||||||
|
const sentMessage: ExtendedMessage = {
|
||||||
|
id: response.id,
|
||||||
|
channel_id: unsentMsg.channelId,
|
||||||
|
content: unsentMsg.content,
|
||||||
|
created_at: response.created_at,
|
||||||
|
file_id: uploadedFile.id,
|
||||||
|
fileId: uploadedFile.id,
|
||||||
|
filePath: uploadedFile.file_path,
|
||||||
|
fileType: uploadedFile.file_type,
|
||||||
|
fileSize: uploadedFile.file_size,
|
||||||
|
originalName: uploadedFile.original_name,
|
||||||
|
fileCreatedAt: uploadedFile.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
appStore.addMessage(sentMessage)
|
||||||
|
console.log(`Successfully sent unsent ${unsentMsg.messageType} message, got ID: ${response.id}`)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Handle text message retry (existing logic)
|
||||||
|
const response = await apiService.createMessage(unsentMsg.channelId, unsentMsg.content)
|
||||||
|
console.log(`Successfully sent unsent text message, got ID: ${response.id}`)
|
||||||
|
|
||||||
// Create the sent message
|
// Create the sent message
|
||||||
const sentMessage: ExtendedMessage = {
|
const sentMessage: ExtendedMessage = {
|
||||||
@@ -100,14 +122,16 @@ export class SyncService {
|
|||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to messages and remove from unsent
|
|
||||||
appStore.addMessage(sentMessage)
|
appStore.addMessage(sentMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from unsent messages
|
||||||
appStore.removeUnsentMessage(unsentMsg.id)
|
appStore.removeUnsentMessage(unsentMsg.id)
|
||||||
|
|
||||||
// Save state immediately after successful send to ensure UI updates
|
// Save state immediately after successful send to ensure UI updates
|
||||||
await appStore.saveState()
|
await appStore.saveState()
|
||||||
|
|
||||||
console.log(`Moved unsent message ${unsentMsg.id} to sent messages with ID ${response.id}`)
|
console.log(`Moved unsent message ${unsentMsg.id} to sent messages`)
|
||||||
console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`)
|
console.log(`Unsent messages remaining: ${appStore.unsentMessages.length}`)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -201,6 +225,65 @@ export class SyncService {
|
|||||||
throw error // Re-throw so caller knows it failed
|
throw error // Re-throw so caller knows it failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a file message with optimistic updates and offline support
|
||||||
|
*/
|
||||||
|
async sendFileMessage(channelId: number, content: string, file: File, messageType: 'voice' | 'image' = 'image'): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`Optimistically sending ${messageType} message: ${content}`)
|
||||||
|
|
||||||
|
// Try to send immediately
|
||||||
|
const message = await apiService.createMessage(channelId, content)
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadedFile = await apiService.uploadFile(channelId, message.id, file)
|
||||||
|
|
||||||
|
// Success - create complete message with file metadata
|
||||||
|
const completeMessage: ExtendedMessage = {
|
||||||
|
id: message.id,
|
||||||
|
channel_id: channelId,
|
||||||
|
content: content,
|
||||||
|
created_at: message.created_at,
|
||||||
|
file_id: uploadedFile.id,
|
||||||
|
fileId: uploadedFile.id,
|
||||||
|
filePath: uploadedFile.file_path,
|
||||||
|
fileType: uploadedFile.file_type,
|
||||||
|
fileSize: uploadedFile.file_size,
|
||||||
|
originalName: uploadedFile.original_name,
|
||||||
|
fileCreatedAt: uploadedFile.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
const appStore = this.getAppStore()
|
||||||
|
appStore.addMessage(completeMessage)
|
||||||
|
console.log(`${messageType} message sent successfully with ID: ${message.id}`)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to send ${messageType} message immediately, queuing for later:`, error)
|
||||||
|
|
||||||
|
// Queue file message for retry when back online
|
||||||
|
const unsentMessage: UnsentMessage = {
|
||||||
|
id: `unsent_${messageType}_${Date.now()}_${Math.random()}`,
|
||||||
|
channelId: channelId,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retries: 0,
|
||||||
|
messageType: messageType,
|
||||||
|
fileData: {
|
||||||
|
blob: file,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
fileSize: file.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appStore = this.getAppStore()
|
||||||
|
appStore.addUnsentMessage(unsentMessage)
|
||||||
|
await appStore.saveState()
|
||||||
|
|
||||||
|
throw error // Re-throw so caller knows it failed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncService = new SyncService()
|
export const syncService = new SyncService()
|
@@ -6,20 +6,35 @@ class WebSocketService {
|
|||||||
private maxReconnectAttempts = 5
|
private maxReconnectAttempts = 5
|
||||||
private reconnectInterval = 1000
|
private reconnectInterval = 1000
|
||||||
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
private eventHandlers: Map<string, ((data: any) => void)[]> = new Map()
|
||||||
|
private customServerUrl: string | null = null
|
||||||
|
|
||||||
|
setServerUrl(url: string) {
|
||||||
|
this.customServerUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In development, connect to backend server (port 3000)
|
// Determine WebSocket URL
|
||||||
// In production, use same host as frontend
|
let wsUrl: string
|
||||||
|
|
||||||
|
if (this.customServerUrl) {
|
||||||
|
// Use custom server URL
|
||||||
|
const url = new URL(this.customServerUrl)
|
||||||
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
wsUrl = `${protocol}//${url.host}`
|
||||||
|
} else {
|
||||||
|
// Use default behavior
|
||||||
const isDev = import.meta.env.DEV
|
const isDev = import.meta.env.DEV
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const host = isDev ? 'localhost:3000' : window.location.host
|
const host = isDev ? 'localhost:3000' : window.location.host
|
||||||
const wsUrl = `${protocol}//${host}`
|
wsUrl = `${protocol}//${host}`
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl)
|
||||||
this.ws = new WebSocket(wsUrl)
|
this.ws = new WebSocket(wsUrl)
|
||||||
this.setupEventListeners()
|
this.setupEventListeners()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@@ -71,8 +71,21 @@ 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,6 +211,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
addMessage,
|
addMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
|
moveMessage,
|
||||||
addUnsentMessage,
|
addUnsentMessage,
|
||||||
removeUnsentMessage,
|
removeUnsentMessage,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
@@ -4,27 +4,58 @@ import { get, set } from 'idb-keyval'
|
|||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(null)
|
const token = ref<string | null>(null)
|
||||||
|
const serverUrl = ref<string | null>(null)
|
||||||
const isAuthenticated = ref(false)
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
const setToken = async (newToken: string) => {
|
// Get default server URL based on environment
|
||||||
|
const getDefaultServerUrl = () => {
|
||||||
|
return import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const setToken = async (newToken: string, customServerUrl?: string) => {
|
||||||
token.value = newToken
|
token.value = newToken
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
await set('auth_token', newToken)
|
|
||||||
|
// Set server URL or use default
|
||||||
|
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||||
|
serverUrl.value = urlToUse
|
||||||
|
|
||||||
|
// Save both token and server URL
|
||||||
|
await Promise.all([
|
||||||
|
set('auth_token', newToken),
|
||||||
|
set('server_url', urlToUse)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const setServerUrl = async (url: string) => {
|
||||||
|
serverUrl.value = url
|
||||||
|
await set('server_url', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAuth = async () => {
|
const clearAuth = async () => {
|
||||||
token.value = null
|
token.value = null
|
||||||
|
serverUrl.value = null
|
||||||
isAuthenticated.value = false
|
isAuthenticated.value = false
|
||||||
await set('auth_token', null)
|
await Promise.all([
|
||||||
|
set('auth_token', null),
|
||||||
|
set('server_url', null)
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const storedToken = await get('auth_token')
|
const [storedToken, storedServerUrl] = await Promise.all([
|
||||||
|
get('auth_token'),
|
||||||
|
get('server_url')
|
||||||
|
])
|
||||||
|
|
||||||
if (storedToken) {
|
if (storedToken) {
|
||||||
|
// Set server URL or use default
|
||||||
|
const urlToUse = storedServerUrl || getDefaultServerUrl()
|
||||||
|
serverUrl.value = urlToUse
|
||||||
|
|
||||||
// Verify token with backend
|
// Verify token with backend
|
||||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
const response = await fetch(`${urlToUse}/check-token`, {
|
||||||
const response = await fetch(`${baseUrl}/check-token`, {
|
|
||||||
headers: { Authorization: storedToken }
|
headers: { Authorization: storedToken }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,15 +73,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticate = async (authToken: string): Promise<boolean> => {
|
const authenticate = async (authToken: string, customServerUrl?: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
const urlToUse = customServerUrl || getDefaultServerUrl()
|
||||||
const response = await fetch(`${baseUrl}/check-token`, {
|
const response = await fetch(`${urlToUse}/check-token`, {
|
||||||
headers: { Authorization: authToken }
|
headers: { Authorization: authToken }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await setToken(authToken)
|
await setToken(authToken, urlToUse)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
await clearAuth()
|
await clearAuth()
|
||||||
@@ -65,10 +96,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
|
serverUrl,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
setToken,
|
setToken,
|
||||||
|
setServerUrl,
|
||||||
clearAuth,
|
clearAuth,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
authenticate
|
authenticate,
|
||||||
|
getDefaultServerUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
@@ -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;
|
||||||
|
@@ -72,6 +72,14 @@ export interface UnsentMessage {
|
|||||||
content: string
|
content: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
retries: number
|
retries: number
|
||||||
|
// File message support (for future offline retry capability)
|
||||||
|
messageType?: 'text' | 'voice' | 'image'
|
||||||
|
fileData?: {
|
||||||
|
blob: Blob
|
||||||
|
fileName: string
|
||||||
|
fileType: string
|
||||||
|
fileSize: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
@@ -84,6 +92,7 @@ export interface AppSettings {
|
|||||||
selectedVoiceURI: string | null
|
selectedVoiceURI: string | null
|
||||||
defaultChannelId: number | null
|
defaultChannelId: number | null
|
||||||
theme: 'light' | 'dark' | 'auto'
|
theme: 'light' | 'dark' | 'auto'
|
||||||
|
serverUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio Types
|
// Audio Types
|
||||||
|
94
frontend-vue/src/utils/time.ts
Normal file
94
frontend-vue/src/utils/time.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Smart timestamp formatting that shows appropriate level of detail based on message age
|
||||||
|
*/
|
||||||
|
export function formatSmartTimestamp(timestamp: string): string {
|
||||||
|
const now = new Date()
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
// Handle invalid dates
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Invalid date'
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
// Same day (today)
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
if (diffDays === 1) {
|
||||||
|
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `Yesterday ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// This week (2-6 days ago)
|
||||||
|
if (diffDays <= 6) {
|
||||||
|
const dayStr = date.toLocaleDateString([], { weekday: 'short' })
|
||||||
|
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${dayStr} ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// This year (more than a week ago)
|
||||||
|
if (now.getFullYear() === date.getFullYear()) {
|
||||||
|
return date.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different year
|
||||||
|
return date.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp for accessibility/screen readers with full context
|
||||||
|
*/
|
||||||
|
export function formatTimestampForScreenReader(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Invalid date'
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
// Same day
|
||||||
|
if (diffDays === 0) {
|
||||||
|
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `today at ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
if (diffDays === 1) {
|
||||||
|
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `yesterday at ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// This week
|
||||||
|
if (diffDays <= 6) {
|
||||||
|
const dayStr = date.toLocaleDateString([], { weekday: 'long' })
|
||||||
|
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${dayStr} at ${timeStr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older messages - use full date and time
|
||||||
|
return date.toLocaleDateString([], {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
@@ -7,6 +7,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleAuth" class="auth-form">
|
<form @submit.prevent="handleAuth" class="auth-form">
|
||||||
|
<BaseInput
|
||||||
|
v-model="serverUrl"
|
||||||
|
ref="serverInput"
|
||||||
|
type="url"
|
||||||
|
label="Server URL (optional)"
|
||||||
|
:placeholder="defaultServerUrl"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
v-model="token"
|
v-model="token"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -47,9 +56,13 @@ const toastStore = useToastStore()
|
|||||||
const { playSound } = useAudio()
|
const { playSound } = useAudio()
|
||||||
|
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
|
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
|
||||||
|
const defaultServerUrl = authStore.getDefaultServerUrl()
|
||||||
|
|
||||||
const handleAuth = async () => {
|
const handleAuth = async () => {
|
||||||
if (!token.value.trim()) return
|
if (!token.value.trim()) return
|
||||||
@@ -58,18 +71,20 @@ const handleAuth = async () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await authStore.authenticate(token.value.trim())
|
// Use custom server URL if provided, otherwise use default
|
||||||
|
const customUrl = serverUrl.value.trim() || undefined
|
||||||
|
const success = await authStore.authenticate(token.value.trim(), customUrl)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await playSound('login')
|
await playSound('login')
|
||||||
toastStore.success('Authentication successful!')
|
toastStore.success('Authentication successful!')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} else {
|
} else {
|
||||||
error.value = 'Invalid authentication token'
|
error.value = 'Invalid authentication token or server URL'
|
||||||
tokenInput.value?.focus()
|
serverInput.value?.focus()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = 'Authentication failed. Please try again.'
|
error.value = 'Authentication failed. Please check your token and server URL.'
|
||||||
console.error('Auth error:', err)
|
console.error('Auth error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -77,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>
|
||||||
|
|
||||||
@@ -130,6 +144,7 @@ import { useOfflineSync } from '@/composables/useOfflineSync'
|
|||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { formatTimestampForScreenReader } from '@/utils/time'
|
||||||
import { apiService } from '@/services/api'
|
import { apiService } from '@/services/api'
|
||||||
import { syncService } from '@/services/sync'
|
import { syncService } from '@/services/sync'
|
||||||
|
|
||||||
@@ -147,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()
|
||||||
@@ -158,10 +174,13 @@ const toastStore = useToastStore()
|
|||||||
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
const { sendMessage: sendMessageOffline } = useOfflineSync()
|
||||||
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
|
const { playWater, playSent, playSound, speak, stopSpeaking, isSpeaking } = useAudio()
|
||||||
|
|
||||||
// Set up services - ensure token is properly set
|
// Set up services - ensure token and URL are properly set
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
apiService.setToken(authStore.token)
|
apiService.setToken(authStore.token)
|
||||||
}
|
}
|
||||||
|
if (authStore.serverUrl) {
|
||||||
|
apiService.setBaseUrl(authStore.serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const messagesContainer = ref()
|
const messagesContainer = ref()
|
||||||
@@ -174,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)
|
||||||
@@ -276,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({
|
||||||
@@ -306,6 +342,11 @@ const selectChannel = async (channelId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
|
// Auto-focus message input when switching channels
|
||||||
|
nextTick(() => {
|
||||||
|
messageInput.value?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendMessage = async (content: string) => {
|
const handleSendMessage = async (content: string) => {
|
||||||
@@ -356,9 +397,7 @@ const handleSelectMessage = async (message: ExtendedMessage) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (timestamp: string): string => {
|
// formatTime function removed - now using formatTimestampForScreenReader from utils
|
||||||
return new Date(timestamp).toLocaleTimeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVoiceSent = () => {
|
const handleVoiceSent = () => {
|
||||||
// Voice message was sent successfully
|
// Voice message was sent successfully
|
||||||
@@ -388,8 +427,8 @@ const announceLastMessage = (position: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messages[messageIndex]
|
const message = messages[messageIndex]
|
||||||
const timeStr = formatTime(message.created_at)
|
const timeStr = formatTimestampForScreenReader(message.created_at)
|
||||||
const announcement = `${message.content}; ${timeStr}`
|
const announcement = `${message.content}; sent ${timeStr}`
|
||||||
|
|
||||||
toastStore.info(announcement)
|
toastStore.info(announcement)
|
||||||
|
|
||||||
@@ -403,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)
|
||||||
@@ -449,7 +575,12 @@ onMounted(async () => {
|
|||||||
await selectChannel(appStore.channels[0].id)
|
await selectChannel(appStore.channels[0].id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Set up periodic sync for unsent messages
|
// 6. Auto-focus message input on page load
|
||||||
|
nextTick(() => {
|
||||||
|
messageInput.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. Set up periodic sync for unsent messages
|
||||||
const syncInterval = setInterval(async () => {
|
const syncInterval = setInterval(async () => {
|
||||||
if (appStore.unsentMessages.length > 0) {
|
if (appStore.unsentMessages.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -470,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,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,
|
||||||
@@ -555,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 {
|
||||||
@@ -567,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 {
|
||||||
@@ -584,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