feat: implement check functionality

This commit is contained in:
2025-09-13 07:45:19 +02:00
parent ec1a2ba7f0
commit fab05f32ec
15 changed files with 258 additions and 61 deletions

View File

@@ -30,6 +30,16 @@
🎤
</BaseButton>
<BaseButton
variant="ghost"
size="xs"
@click="$emit('toggle-check')"
aria-label="Toggle check on focused message"
:disabled="disabled"
>
</BaseButton>
<BaseButton
variant="primary"
size="sm"
@@ -59,6 +69,7 @@ defineEmits<{
'file-upload': []
'camera': []
'voice': []
'toggle-check': []
'send': []
}>()
</script>
@@ -70,4 +81,10 @@ defineEmits<{
gap: 0.25rem; /* Reduced gap to save space */
flex-shrink: 0;
}
</style>
/* Mobile-only for the checked toggle button */
.input-actions [aria-label="Toggle check on focused message"] { display: none; }
@media (max-width: 480px) {
.input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; }
}
</style>

View File

@@ -17,6 +17,7 @@
@file-upload="$emit('file-upload')"
@camera="$emit('camera')"
@voice="$emit('voice')"
@toggle-check="$emit('toggle-check')"
@send="handleSubmit"
/>
</div>
@@ -35,6 +36,7 @@ const emit = defineEmits<{
'file-upload': []
'camera': []
'voice': []
'toggle-check': []
}>()
const appStore = useAppStore()
@@ -120,4 +122,4 @@ defineExpose({
border-top-color: #374151;
}
}
</style>
</style>

View File

@@ -14,6 +14,8 @@
@focus="handleFocus"
>
<div class="message__content">
<span v-if="isChecked === true" class="message__check" aria-hidden="true"></span>
<span v-else-if="isChecked === false" class="message__check message__check--unchecked" aria-hidden="true"></span>
{{ message.content }}
</div>
@@ -23,6 +25,16 @@
</div>
<div class="message__meta">
<button
class="message__toggle"
type="button"
:aria-label="toggleAriaLabel"
@click.stop="toggleChecked()"
>
<span v-if="isChecked === true">Uncheck</span>
<span v-else-if="isChecked === false">Check</span>
<span v-else>Check</span>
</button>
<time
v-if="!isUnsent && 'created_at' in message"
class="message__time"
@@ -83,6 +95,11 @@ const hasFileAttachment = computed(() => {
return 'fileId' in props.message && !!props.message.fileId
})
// Tri-state checked
const isChecked = computed<boolean | null>(() => {
return (props as any).message?.checked ?? null
})
// Create FileAttachment object from flattened message data
const fileAttachment = computed((): FileAttachmentType | null => {
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
@@ -114,7 +131,15 @@ const fileAttachment = computed((): FileAttachmentType | null => {
// Create comprehensive aria-label for screen readers
const messageAriaLabel = computed(() => {
let prefix = ''
let label = ''
// Checked state first
if ((props as any).message?.checked === true) {
prefix = 'checked, '
} else if ((props as any).message?.checked === false) {
prefix = 'unchecked, '
}
// Add message content
if (props.message.content) {
@@ -139,7 +164,7 @@ const messageAriaLabel = computed(() => {
label += '. Message is sending'
}
return label
return `${prefix}${label}`.trim()
})
// Helper to determine file type for better description
@@ -174,6 +199,12 @@ const handleKeydown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) {
return
}
if (event.key === ' ' || event.code === 'Space') {
event.preventDefault()
event.stopPropagation()
toggleChecked()
return
}
if (event.key === 'c') {
// Copy message content (only when no modifiers are pressed)
@@ -268,6 +299,33 @@ const handleDelete = async () => {
toastStore.error('Failed to delete message')
}
}
const handleFocus = () => {
// Keep parent selection index in sync
emit('focus')
}
const toggleAriaLabel = computed(() => {
if (isChecked.value === true) return 'Mark as unchecked'
if (isChecked.value === false) return 'Mark as checked'
return 'Mark as checked'
})
const toggleChecked = async () => {
if (props.isUnsent) return
const msg = props.message as ExtendedMessage
const next = isChecked.value !== true
const prev = isChecked.value
try {
// optimistic
appStore.setMessageChecked(msg.id, next)
await apiService.setMessageChecked(msg.channel_id, msg.id, next)
} catch (e) {
// rollback
appStore.setMessageChecked(msg.id, prev as any)
console.error('Failed to set checked state', e)
}
}
</script>
<style scoped>
@@ -330,6 +388,31 @@ const handleDelete = async () => {
font-weight: 500;
}
.message__check {
margin-right: 6px;
color: #059669;
font-weight: 600;
}
.message__check--unchecked {
color: #6b7280;
}
.message__toggle {
appearance: none;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
}
/* Hide the per-message toggle on desktop; show only on mobile */
.message__toggle { display: none; }
@media (max-width: 480px) {
.message__toggle { display: inline-flex; }
}
@media (prefers-color-scheme: dark) {
.message {
background: #2d3748;
@@ -352,7 +435,4 @@ const handleDelete = async () => {
}
}
</style>
const handleFocus = () => {
// Emit a focus event so the parent list can update its focused index
emit('focus')
}

View File

@@ -118,6 +118,13 @@ class ApiService {
})
}
async setMessageChecked(channelId: number, messageId: number, checked: boolean | null): Promise<{ id: number, checked: boolean | null }> {
return this.request(`/channels/${channelId}/messages/${messageId}/checked`, {
method: 'PUT',
body: JSON.stringify({ checked })
})
}
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',
@@ -162,4 +169,4 @@ class ApiService {
}
}
export const apiService = new ApiService()
export const apiService = new ApiService()

View File

@@ -35,6 +35,7 @@ export class SyncService {
content: msg.content,
created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id,
checked: typeof msg.checked === 'number' ? (msg.checked === 1) : (typeof msg.checked === 'boolean' ? msg.checked : null),
// Map the flattened file fields from backend
fileId: msg.fileId,
filePath: msg.filePath,

View File

@@ -101,6 +101,10 @@ export const useAppStore = defineStore('app', () => {
}
}
const setMessageChecked = (messageId: number, checked: boolean | null) => {
updateMessage(messageId, { checked })
}
const removeMessage = (messageId: number) => {
for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)]
@@ -210,6 +214,7 @@ export const useAppStore = defineStore('app', () => {
setMessages,
addMessage,
updateMessage,
setMessageChecked,
removeMessage,
moveMessage,
addUnsentMessage,

View File

@@ -11,6 +11,7 @@ export interface Message {
content: string
created_at: string
file_id?: number
checked?: boolean | null
}
export interface MessageWithFile extends Message {
@@ -130,4 +131,4 @@ export interface UploadProgress {
loaded: number
total: number
percentage: number
}
}

View File

@@ -63,6 +63,7 @@
@file-upload="showFileDialog = true"
@camera="showCameraDialog = true"
@voice="showVoiceDialog = true"
@toggle-check="handleToggleCheckFocused"
ref="messageInput"
/>
</div>
@@ -329,6 +330,19 @@ const setupKeyboardShortcuts = () => {
})
}
const handleToggleCheckFocused = async () => {
const focused = messagesContainer.value?.getFocusedMessage?.()
if (!focused || 'channelId' in focused) return
try {
const next = (focused as ExtendedMessage).checked !== true
appStore.setMessageChecked((focused as ExtendedMessage).id, next)
await apiService.setMessageChecked((focused as ExtendedMessage).channel_id, (focused as ExtendedMessage).id, next)
toastStore.info(next ? 'Marked as checked' : 'Marked as unchecked')
} catch (e) {
toastStore.error('Failed to toggle check')
}
}
const selectChannel = async (channelId: number) => {
console.log('Selecting channel:', channelId)
await appStore.setCurrentChannel(channelId)
@@ -763,4 +777,4 @@ onMounted(async () => {
color: rgba(255, 255, 255, 0.87);
}
}
</style>
</style>