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

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Repository Guidelines
## Project Structure & Module Organization
- `frontend-vue/` Vue 3 + Vite app. Key folders: `src/components`, `src/views`, `src/stores`, `src/services`, `src/composables`, `src/router`, and static assets in `public/`.
- `backend/` TypeScript API + WebSocket server. Key folders: `src/controllers`, `src/routes`, `src/services`, `src/utils`, `src/logging`, `src/jobs`. Database files in `schema.sql` and `migrations/`.
- `etc/systemd/` example unit files for deployment. `dockerfile` container build (optional).
## Build, Test, and Development Commands
- Frontend
- `cd frontend-vue && npm install`
- `npm run dev` start Vite dev server.
- `npm run build` type-check + production build.
- `npm run preview` preview production build.
- `npm run lint` / `npm run format` ESLint / Prettier.
- Backend
- `cd backend && npm install`
- `npm run dev` start server with `tsx --watch`.
- `npm run start` start server once (prod-like).
- Note: backend was initialized with Bun; Node/npm + tsx is the primary flow.
## Coding Style & Naming Conventions
- General: TypeScript, 2-space indent, small focused modules. File names: lowercase-kebab for modules (e.g., `message-service.ts`), `PascalCase.vue` for Vue SFCs.
- Frontend: Prettier + ESLint enforced; prefer single quotes; composition API; component names in `PascalCase`.
- Backend: Keep semicolons and consistent import quoting (matches current files). Use `PascalCase` for types/classes, `camelCase` for variables/functions.
## Testing Guidelines
- No formal test runner is configured yet. If adding tests:
- Place unit tests alongside code as `*.spec.ts` or in a `__tests__/` directory.
- Keep tests fast and deterministic; document manual verification steps in PRs until a runner is introduced.
## Commit & Pull Request Guidelines
- Commits: imperative mood, concise subject (<=72 chars), include scope prefix when helpful, e.g. `frontend: fix message input blur` or `backend: add channel search`.
- PRs must include: clear description, linked issue (if any), screenshots/GIFs for UI changes, manual test steps, and notes on migrations if touching `backend/migrations/`.
- Ensure `npm run lint` (frontend) passes and the app runs locally for both services before requesting review.
## Security & Configuration Tips
- Backend reads environment from `.env` (see `backend/src/config.ts`): important keys include `DB_PATH`, `API_TOKEN`, `UPLOAD_DIR`, `PORT`, `USE_SSL`, `OPENAI_API_KEY`, `OLLAMA_URL`, and related model settings.
- Do not commit secrets. Provide `.env` examples in PRs when adding new variables.
- For local SSL, set `USE_SSL=1` and supply `SSL_KEY/SSL_CERT`, or let the server generate a self-signed pair for development.

View File

@@ -0,0 +1,3 @@
-- Add tri-state checked column to messages (NULL | 0 | 1)
ALTER TABLE messages ADD COLUMN checked INTEGER NULL;

View File

@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS messages (
channelId INTEGER, channelId INTEGER,
content TEXT, content TEXT,
fileId INTEGER NULL, fileId INTEGER NULL,
checked INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE, FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE

View File

@@ -78,3 +78,18 @@ export const moveMessage = async (req: Request, res: Response) => {
res.status(500).json({ error: 'Failed to move message' }); res.status(500).json({ error: 'Failed to move message' });
} }
} }
export const setChecked = async (req: Request, res: Response) => {
const { messageId } = req.params;
const { checked } = req.body as { checked: boolean | null | undefined };
if (!messageId) {
return res.status(400).json({ error: 'Message ID is required' });
}
const value = (checked === undefined) ? null : checked;
const result = await MessageService.setMessageChecked(messageId, value);
if (result.changes === 0) {
return res.status(404).json({ error: 'Message not found' });
}
logger.info(`Message ${messageId} checked set to ${value}`);
res.json({ id: parseInt(messageId), checked: value });
}

View File

@@ -7,6 +7,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.put('/:messageId/move', authenticate, MessageController.moveMessage);
router.put('/:messageId/checked', authenticate, MessageController.setChecked);
router.delete('/:messageId', authenticate, MessageController.deleteMessage); router.delete('/:messageId', authenticate, MessageController.deleteMessage);
router.get('/', authenticate, MessageController.getMessages); router.get('/', authenticate, MessageController.getMessages);

View File

@@ -2,7 +2,7 @@ import { db, FTS5Enabled } from "../db";
import { events } from "../globals"; import { events } from "../globals";
export const createMessage = async (channelId: string, content: string) => { export const createMessage = async (channelId: string, content: string) => {
const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`); const query = db.prepare(`INSERT INTO messages (channelId, content, checked) VALUES ($channelId, $content, NULL)`);
const result = query.run({ channelId: channelId, content: content }); const result = query.run({ channelId: channelId, content: content });
const messageId = result.lastInsertRowid; const messageId = result.lastInsertRowid;
@@ -49,7 +49,7 @@ export const deleteMessage = async (messageId: string) => {
export const getMessages = async (channelId: string) => { export const getMessages = async (channelId: string) => {
const query = db.prepare(` const query = db.prepare(`
SELECT SELECT
messages.id, messages.channelId, messages.content, messages.createdAt, messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM FROM
messages messages
@@ -67,7 +67,7 @@ export const getMessages = async (channelId: string) => {
export const getMessage = async (id: string) => { export const getMessage = async (id: string) => {
const query = db.prepare(` const query = db.prepare(`
SELECT SELECT
messages.id, messages.channelId, messages.content, messages.createdAt, messages.id, messages.channelId, messages.content, messages.createdAt, messages.checked,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM FROM
messages messages
@@ -82,6 +82,15 @@ export const getMessage = async (id: string) => {
return row; return row;
} }
export const setMessageChecked = async (messageId: string, checked: boolean | null) => {
const query = db.prepare(`UPDATE messages SET checked = $checked WHERE id = $id`);
// SQLite stores booleans as integers; NULL for unknown
const value = checked === null ? null : (checked ? 1 : 0);
const result = query.run({ id: messageId, checked: value });
events.emit('message-updated', messageId, { checked: value });
return result;
}
export const moveMessage = async (messageId: string, targetChannelId: string) => { export const moveMessage = async (messageId: string, targetChannelId: string) => {
// Get current message to emit proper events // Get current message to emit proper events
const currentMessage = await getMessage(messageId); const currentMessage = await getMessage(messageId);

View File

@@ -9,6 +9,7 @@ export interface Message {
channel_id: number; channel_id: number;
content: string; content: string;
created_at: string; created_at: string;
checked?: boolean | null;
} }
export interface File { export interface File {

View File

@@ -30,6 +30,16 @@
🎤 🎤
</BaseButton> </BaseButton>
<BaseButton
variant="ghost"
size="xs"
@click="$emit('toggle-check')"
aria-label="Toggle check on focused message"
:disabled="disabled"
>
</BaseButton>
<BaseButton <BaseButton
variant="primary" variant="primary"
size="sm" size="sm"
@@ -59,6 +69,7 @@ defineEmits<{
'file-upload': [] 'file-upload': []
'camera': [] 'camera': []
'voice': [] 'voice': []
'toggle-check': []
'send': [] 'send': []
}>() }>()
</script> </script>
@@ -70,4 +81,10 @@ defineEmits<{
gap: 0.25rem; /* Reduced gap to save space */ gap: 0.25rem; /* Reduced gap to save space */
flex-shrink: 0; flex-shrink: 0;
} }
/* 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> </style>

View File

@@ -17,6 +17,7 @@
@file-upload="$emit('file-upload')" @file-upload="$emit('file-upload')"
@camera="$emit('camera')" @camera="$emit('camera')"
@voice="$emit('voice')" @voice="$emit('voice')"
@toggle-check="$emit('toggle-check')"
@send="handleSubmit" @send="handleSubmit"
/> />
</div> </div>
@@ -35,6 +36,7 @@ const emit = defineEmits<{
'file-upload': [] 'file-upload': []
'camera': [] 'camera': []
'voice': [] 'voice': []
'toggle-check': []
}>() }>()
const appStore = useAppStore() const appStore = useAppStore()

View File

@@ -14,6 +14,8 @@
@focus="handleFocus" @focus="handleFocus"
> >
<div class="message__content"> <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 }} {{ message.content }}
</div> </div>
@@ -23,6 +25,16 @@
</div> </div>
<div class="message__meta"> <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 <time
v-if="!isUnsent && 'created_at' in message" v-if="!isUnsent && 'created_at' in message"
class="message__time" class="message__time"
@@ -83,6 +95,11 @@ const hasFileAttachment = computed(() => {
return 'fileId' in props.message && !!props.message.fileId 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 // Create FileAttachment object from flattened message data
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
@@ -114,8 +131,16 @@ const fileAttachment = computed((): FileAttachmentType | null => {
// Create comprehensive aria-label for screen readers // Create comprehensive aria-label for screen readers
const messageAriaLabel = computed(() => { const messageAriaLabel = computed(() => {
let prefix = ''
let label = '' 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 // Add message content
if (props.message.content) { if (props.message.content) {
label += props.message.content label += props.message.content
@@ -139,7 +164,7 @@ const messageAriaLabel = computed(() => {
label += '. Message is sending' label += '. Message is sending'
} }
return label return `${prefix}${label}`.trim()
}) })
// Helper to determine file type for better description // Helper to determine file type for better description
@@ -174,6 +199,12 @@ const handleKeydown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) { if (event.ctrlKey || event.metaKey || event.altKey) {
return return
} }
if (event.key === ' ' || event.code === 'Space') {
event.preventDefault()
event.stopPropagation()
toggleChecked()
return
}
if (event.key === 'c') { if (event.key === 'c') {
// Copy message content (only when no modifiers are pressed) // Copy message content (only when no modifiers are pressed)
@@ -268,6 +299,33 @@ const handleDelete = async () => {
toastStore.error('Failed to delete message') 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> </script>
<style scoped> <style scoped>
@@ -330,6 +388,31 @@ const handleDelete = async () => {
font-weight: 500; 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) { @media (prefers-color-scheme: dark) {
.message { .message {
background: #2d3748; background: #2d3748;
@@ -352,7 +435,4 @@ const handleDelete = async () => {
} }
} }
</style> </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 }> { async moveMessage(channelId: number, messageId: number, targetChannelId: number): Promise<{ message: string, messageId: number, targetChannelId: number }> {
return this.request(`/channels/${channelId}/messages/${messageId}/move`, { return this.request(`/channels/${channelId}/messages/${messageId}/move`, {
method: 'PUT', method: 'PUT',

View File

@@ -35,6 +35,7 @@ export class SyncService {
content: msg.content, content: msg.content,
created_at: msg.createdAt || msg.created_at, created_at: msg.createdAt || msg.created_at,
file_id: msg.fileId || msg.file_id, 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 // Map the flattened file fields from backend
fileId: msg.fileId, fileId: msg.fileId,
filePath: msg.filePath, 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) => { const removeMessage = (messageId: number) => {
for (const channelId in messages.value) { for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)] const channelMessages = messages.value[parseInt(channelId)]
@@ -210,6 +214,7 @@ export const useAppStore = defineStore('app', () => {
setMessages, setMessages,
addMessage, addMessage,
updateMessage, updateMessage,
setMessageChecked,
removeMessage, removeMessage,
moveMessage, moveMessage,
addUnsentMessage, addUnsentMessage,

View File

@@ -11,6 +11,7 @@ export interface Message {
content: string content: string
created_at: string created_at: string
file_id?: number file_id?: number
checked?: boolean | null
} }
export interface MessageWithFile extends Message { export interface MessageWithFile extends Message {

View File

@@ -63,6 +63,7 @@
@file-upload="showFileDialog = true" @file-upload="showFileDialog = true"
@camera="showCameraDialog = true" @camera="showCameraDialog = true"
@voice="showVoiceDialog = true" @voice="showVoiceDialog = true"
@toggle-check="handleToggleCheckFocused"
ref="messageInput" ref="messageInput"
/> />
</div> </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) => { const selectChannel = async (channelId: number) => {
console.log('Selecting channel:', channelId) console.log('Selecting channel:', channelId)
await appStore.setCurrentChannel(channelId) await appStore.setCurrentChannel(channelId)