feat: wire ImageUpload into NoteEditor and show thumbnails on NoteCard

- Import ImageUpload into NoteEditor with attachment state management
- Add $effect to fetch attachments for existing notes loaded from IDB
- Add upload/remove handlers and image toggle button in toolbar
- Show ImageUpload panel between content and formatting toolbar
- Add full-bleed thumbnail strip at top of NoteCard (up to 3 images)
- Use &thumb=1 URL for card previews with lazy loading
- Show +N badge when more than 3 attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-08 22:37:50 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent f7e7aaa97d
commit cc6181aba1
2 changed files with 92 additions and 1 deletions
+22
View File
@@ -78,6 +78,28 @@
data-testid="note-card"
data-note-id={note.id}
>
<!-- Thumbnail strip -->
{#if note.attachments && note.attachments.length > 0}
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each note.attachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
loading="lazy"
data-testid="card-thumbnail"
/>
</div>
{/each}
{#if note.attachments.length > 3}
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
+{note.attachments.length - 3}
</div>
{/if}
</div>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
+70 -1
View File
@@ -3,12 +3,14 @@
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
@@ -30,11 +32,43 @@
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
let rawMarkdownMode = $state(note?.checklistMode ?? false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (note && !isNew && (!note.attachments || note.attachments.length === 0)) {
fetch(`/api/notes/${note.id}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
}
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
}
async function handleAttachmentRemove(attachmentId: string) {
if (!note) return;
try {
await fetch(`/api/notes/${note.id}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
}
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
@@ -124,6 +158,30 @@
{/if}
</div>
<!-- Image attachments -->
{#if !isNew && note && showImageUpload}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={note.id}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
/>
</div>
{:else if !isNew && attachmentsList.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<div class="flex flex-wrap gap-2">
{#each attachmentsList as attachment}
<img
src="/api/notes/{note?.id}/attachments?attachmentId={attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
/>
{/each}
</div>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
@@ -163,6 +221,17 @@
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={() => (showImageUpload = !showImageUpload)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
title="Image attachments"
disabled={isNew}
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}