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

@@ -13,19 +13,20 @@ CREATE TABLE IF NOT EXISTS files (
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
SET
NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
checked INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
SET
NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content = 'messages',
content_rowid = 'id'
);
);

View File

@@ -53,7 +53,7 @@ export const getMessages = async (req: Request, res: Response) => {
res.json({ messages });
}
export const moveMessage = async (req: Request, res: Response) => {
export const moveMessage = async (req: Request, res: Response) => {
const { messageId } = req.params;
const { targetChannelId } = req.body;
@@ -77,4 +77,19 @@ export const moveMessage = async (req: Request, res: Response) => {
logger.critical(`Failed to move message ${messageId}:`, error);
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

@@ -4,9 +4,10 @@ import { authenticate } from '../middleware/auth';
export const router = Router({mergeParams: true});
router.post('/', authenticate, MessageController.createMessage);
router.put('/:messageId', authenticate, MessageController.updateMessage);
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
router.get('/', authenticate, MessageController.getMessages);
router.post('/', authenticate, MessageController.createMessage);
router.put('/:messageId', authenticate, MessageController.updateMessage);
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
router.put('/:messageId/checked', authenticate, MessageController.setChecked);
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
router.get('/', authenticate, MessageController.getMessages);

View File

@@ -1,9 +1,9 @@
import { db, FTS5Enabled } from "../db";
import { events } from "../globals";
export const createMessage = async (channelId: string, content: string) => {
const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
const result = query.run({ channelId: channelId, content: content });
export const createMessage = async (channelId: string, content: string) => {
const query = db.prepare(`INSERT INTO messages (channelId, content, checked) VALUES ($channelId, $content, NULL)`);
const result = query.run({ channelId: channelId, content: content });
const messageId = result.lastInsertRowid;
console.log(`Adding message for search with id ${messageId}`);
@@ -17,9 +17,9 @@ export const createMessage = async (channelId: string, content: string) => {
return messageId;
}
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`);
const result = query.run({ content: content, id: messageId });
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`);
const result = query.run({ content: content, id: messageId });
@@ -46,13 +46,13 @@ export const deleteMessage = async (messageId: string) => {
return result;
}
export const getMessages = async (channelId: string) => {
const query = db.prepare(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
export const getMessages = async (channelId: string) => {
const query = db.prepare(`
SELECT
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
FROM
messages
LEFT JOIN
files
ON
@@ -61,16 +61,16 @@ export const getMessages = async (channelId: string) => {
messages.channelId = $channelId
`);
const rows = query.all({ channelId: channelId });
return rows;
}
return rows;
}
export const getMessage = async (id: string) => {
const query = db.prepare(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
export const getMessage = async (id: string) => {
const query = db.prepare(`
SELECT
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
FROM
messages
LEFT JOIN
files
ON
@@ -79,8 +79,17 @@ export const getMessage = async (id: string) => {
messages.id = $id
`);
const row = query.get({ id: id });
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) => {
// Get current message to emit proper events
@@ -104,4 +113,4 @@ export const moveMessage = async (messageId: string, targetChannelId: string) =>
events.emit('message-moved', messageId, (currentMessage as any).channelId, targetChannelId);
return result;
}
}

View File

@@ -4,12 +4,13 @@ export interface Channel {
created_at: string;
}
export interface Message {
id: number;
channel_id: number;
content: string;
created_at: string;
}
export interface Message {
id: number;
channel_id: number;
content: string;
created_at: string;
checked?: boolean | null;
}
export interface File {
id: number;
@@ -18,4 +19,4 @@ export interface File {
file_path: string;
file_type: string;
created_at: string;
}
}

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>