This commit is contained in:
Dave Onkels 2026-01-30 08:24:06 +01:00 committed by GitHub
commit f05bf70768
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 130 additions and 5 deletions

View File

@ -263,6 +263,43 @@
cursor: not-allowed;
}
/* Drag-over visual feedback */
.chat-compose--drag-over {
outline: 2px dashed var(--accent);
outline-offset: -2px;
background: rgba(var(--accent-rgb), 0.05);
border-radius: 12px;
}
/* File attach button */
.chat-compose__attach {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
cursor: pointer;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
transition: all 150ms ease-out;
}
.chat-compose__attach:hover {
background: var(--panel);
color: var(--text);
border-color: var(--border-strong);
}
.chat-compose__attach svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
}
.chat-compose__actions {
flex-shrink: 0;
display: flex;

View File

@ -93,4 +93,24 @@ describe("chat view", () => {
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Stop");
});
it("renders file input for image attachments", () => {
const container = document.createElement("div");
render(
renderChat(
createProps({
onAttachmentsChange: vi.fn(),
}),
),
container,
);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(fileInput).not.toBeNull();
expect(fileInput?.accept).toBe("image/*");
expect(fileInput?.multiple).toBe(true);
const attachLabel = container.querySelector('label[for="chat-file-input"]');
expect(attachLabel).not.toBeNull();
});
});

View File

@ -108,10 +108,29 @@ function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(
e: ClipboardEvent,
props: ChatProps,
) {
function processFiles(files: FileList, props: ChatProps) {
if (!props.onAttachmentsChange) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith("image/")) continue;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
};
reader.readAsDataURL(file);
}
}
function handlePaste(e: ClipboardEvent, props: ChatProps) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) return;
@ -146,6 +165,34 @@ function handlePaste(
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).classList.add("chat-compose--drag-over");
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).classList.remove("chat-compose--drag-over");
}
function handleDrop(e: DragEvent, props: ChatProps) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).classList.remove("chat-compose--drag-over");
const files = e.dataTransfer?.files;
if (!files || !props.onAttachmentsChange) return;
processFiles(files, props);
}
function handleFileSelect(e: Event, props: ChatProps) {
const input = e.target as HTMLInputElement;
if (input.files) processFiles(input.files, props);
input.value = ""; // Reset so same file can be selected again
}
function renderAttachmentPreview(props: ChatProps) {
const attachments = props.attachments ?? [];
if (attachments.length === 0) return nothing;
@ -327,7 +374,12 @@ export function renderChat(props: ChatProps) {
`
: nothing}
<div class="chat-compose">
<div
class="chat-compose"
@dragover=${handleDragOver}
@dragleave=${handleDragLeave}
@drop=${(e: DragEvent) => handleDrop(e, props)}
>
${renderAttachmentPreview(props)}
<div class="chat-compose__row">
<label class="field chat-compose__field">
@ -353,6 +405,22 @@ export function renderChat(props: ChatProps) {
placeholder=${composePlaceholder}
></textarea>
</label>
<input
type="file"
id="chat-file-input"
accept="image/*"
multiple
hidden
@change=${(e: Event) => handleFileSelect(e, props)}
/>
<label
for="chat-file-input"
class="btn btn--icon chat-compose__attach"
title="Attach images"
aria-label="Attach images"
>
${icons.paperclip}
</label>
<div class="chat-compose__actions">
<button
class="btn"