Skip to content

feat: image upload in markdown editor#230

Merged
Flo0806 merged 3 commits intomainfrom
feat/image-upload
Mar 15, 2026
Merged

feat: image upload in markdown editor#230
Flo0806 merged 3 commits intomainfrom
feat/image-upload

Conversation

@Flo0806
Copy link
Copy Markdown
Contributor

@Flo0806 Flo0806 commented Mar 15, 2026

Summary

  • Now we can upload images in the comment markdown editor to our S3 Cloudflare storage

Related issue(s)

Closes #73

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • CI

Checklist

  • Tests added/updated
  • i18n keys added/updated (if needed)
  • No breaking changes

Summary by CodeRabbit

  • New Features

    • Image upload via drag‑and‑drop or paste directly into the editor with on‑upload insertion.
    • Reply editor upgraded to an enhanced markdown editor with repo-context propagation.
  • Bug Fixes

    • Image validation with size/format checks and clearer "Image upload failed" messages surfaced via toast.
  • Tests

    • Added unit tests covering image validation and MIME/size handling.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

Adds image upload support to the editor: client-side upload utility, a TipTap ImageUpload extension (paste/drop), server API to validate and store images in Cloudflare R2, editor wiring to upload and insert image URLs, and i18n/key updates plus tests and an R2 .env example entry.

Changes

Cohort / File(s) Summary
Configuration & Dependencies
\.env.example, package.json
Added commented Cloudflare R2 env placeholders to .env.example and added @aws-sdk/client-s3 dependency.
Editor Components
app/components/timeline/ReviewCommentThread.vue, app/components/work-item/WorkItemConversation.vue
Swapped reply textarea to EditorMarkdownEditor (prop updates, fixed min-height) and introduced local submitComment wrapper that appends created comments to the local timeline.
Editor Composition & Handlers
app/composables/useMarkdownEditor.ts, app/composables/useMarkdownEditorHandlers.ts
Integrated ImageUpload extension and toast error handling; replaced FileReader-based insertion with async upload via uploadImage and updated handler names/messages.
Editor Extension
app/extensions/image-upload.ts
New TipTap extension ImageUpload that handles drop/paste, extracts image file, uploads via uploadImage, inserts image, and exposes onError option.
Client Upload Utility
app/utils/uploadImage.ts
New uploadImage(file: File): Promise<string> that POSTs multipart FormData to /api/upload/image and returns the public URL.
Server Upload Handler
server/api/upload/image.post.ts
New POST API that validates multipart input, enforces config presence, validates image, uploads to S3-compatible R2, and returns public URL.
Server Utilities
server/utils/image-upload.ts
New exports: ALLOWED_IMAGE_TYPES, MAX_IMAGE_SIZE, validateImageFile(...), and imageExtFromType(...) for validation and extension derivation.
Internationalization
i18n/locales/en.json, i18n/locales/de.json, i18n/schema.json
Renamed translation key editor.imageReadErroreditor.imageUploadError and updated messages/schema accordingly.
Tests
test/unit/imageUpload.test.ts
New unit tests covering validateImageFile and imageExtFromType (allowed types, size limits, edge cases).

Sequence Diagram

sequenceDiagram
    participant User
    participant Editor as "TipTap Editor"
    participant Ext as "ImageUpload Extension"
    participant Client as "uploadImage()"
    participant Server as "/api/upload/image"
    participant R2 as "Cloudflare R2"

    User->>Editor: Drag/Paste image
    Editor->>Ext: handleDrop / handlePaste
    Ext->>Client: uploadImage(file)
    Client->>Server: POST FormData (file)
    Server->>Server: validateImageFile()
    Server->>R2: PutObject (via S3 client)
    R2-->>Server: success
    Server-->>Client: { url }
    Client-->>Ext: return URL
    Ext->>Editor: insert image (setImage src:url)
    Editor-->>User: image inserted in editor
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I nibble code and stitch the thread,
I paste a picture, see it spread,
Up to R2 on a cloud-lit breeze,
A URL drops in with ease,
Hooray — comments bloom like clovered beds! 📸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding image upload capability to the markdown editor.
Description check ✅ Passed The description covers the key sections: summary of changes, related issue, type of change, and checklist completion. All required elements are present and adequately filled.
Linked Issues check ✅ Passed The PR successfully implements all acceptance criteria from issue #73: drag & drop/paste image upload, URL insertion into markdown, support for users without write access, and Cloudflare R2 storage integration.
Out of Scope Changes check ✅ Passed All changes directly support image upload functionality. The refactoring of comment submission logic and editor component updates are necessary to enable the new feature without introducing unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/image-upload
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
app/components/work-item/WorkItemConversation.vue (1)

31-44: Prefer payload-derived optimistic fields over hardcoded defaults.

For consistency with server state, use returned comment metadata when available (e.g., reactions/permissions) before falling back to local defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/work-item/WorkItemConversation.vue` around lines 31 - 44, The
optimistic timeline entry currently hardcodes reactionGroups and permission
flags; update the object creation in the timeline spread (where timeline:
[...workItemRef.value.timeline, { ... }]) to prefer payload-derived fields from
the returned comment (e.g., use comment.reactionGroups, comment.viewerCanUpdate,
comment.viewerCanDelete or comment.permissions) and only fall back to the local
defaults ([], true/false) when those properties are undefined; keep the existing
fields (id, subjectId, source, sourceNumber, kind, author, authorAvatarUrl,
createdAt, body) but replace the hardcoded
reactionGroups/viewerCanUpdate/viewerCanDelete with conditional uses of the
comment metadata.
app/extensions/image-upload.ts (1)

5-10: Consider client-side filtering against allowed types.

getImageFile accepts any file with type.startsWith('image/'), but the server only allows specific types (image/png, image/jpeg, image/gif, image/webp, image/svg+xml). Users dropping unsupported image formats (e.g., image/bmp, image/tiff) will see a generic upload error.

💡 Optional: Add client-side type filtering
+const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'])
+
 function getImageFile(dt: DataTransfer): File | null {
   for (const file of dt.files) {
-    if (file.type.startsWith('image/')) return file
+    if (ALLOWED_IMAGE_TYPES.has(file.type)) return file
   }
   return null
 }

This would silently ignore unsupported formats on drop/paste. Alternatively, you could invoke onError for unsupported types to give feedback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/extensions/image-upload.ts` around lines 5 - 10, getImageFile currently
accepts any MIME starting with "image/", causing unsupported formats like
image/bmp to be passed through; update getImageFile to check against the same
allowed set the server enforces (image/png, image/jpeg, image/gif, image/webp,
image/svg+xml) — create an allowedTypes array inside getImageFile and only
return the File if file.type is in that array, otherwise return null (or
optionally call the existing onError handler to surface a user-friendly message
about unsupported image types).
test/unit/imageUpload.test.ts (1)

4-33: Tests look solid with good edge case coverage.

The tests cover valid types, unsupported types, and size boundaries effectively. Consider adding a test for the No file provided case when data or type is missing, as this is a distinct validation path in validateImageFile.

💡 Optional: Add missing file test
it('rejects when data or type is missing', () => {
  const noData = validateImageFile({ data: undefined as any, type: 'image/png' })
  expect(noData.valid).toBe(false)
  if (!noData.valid) {
    expect(noData.message).toContain('No file provided')
  }

  const noType = validateImageFile({ data: Buffer.from('x'), type: undefined })
  expect(noType.valid).toBe(false)
  if (!noType.valid) {
    expect(noType.message).toContain('No file provided')
  }
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/imageUpload.test.ts` around lines 4 - 33, Add a test case named
something like "rejects when data or type is missing" in imageUpload.test.ts
that calls validateImageFile with data undefined (e.g., data: undefined as any,
type: 'image/png') and with type undefined (e.g., data: Buffer.from('x'), type:
undefined), and assert in both cases that the returned object has valid ===
false and that result.message contains "No file provided"; place it alongside
the other validateImageFile it blocks to cover this distinct validation path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/utils/uploadImage.ts`:
- Around line 4-8: The response from the $fetch call to '/api/upload/image' is
used directly; validate the returned url before returning it from uploadImage:
check that the destructured url is a non-empty string and a well-formed URL
(e.g., attempt new URL(url) or a regex) and if validation fails throw or return
a clear error/nullable value so callers don't get a broken image src; update the
block around the $fetch call and the return to perform this validation and
handle the invalid case.

In `@server/api/upload/image.post.ts`:
- Around line 56-61: The upload currently stores SVGs with their original
Content-Type which can allow inline JS execution; update the PutObjectCommand
call (the call to getS3Client().send(new PutObjectCommand({...}))) to set
ContentDisposition: 'attachment' when file.type === 'image/svg+xml' (or
otherwise disallow SVG by rejecting that MIME type earlier), e.g., include a
conditional that adds ContentDisposition: 'attachment' to the PutObjectCommand
input for SVGs so browsers will download instead of render inline; keep
ContentType as file.type and ensure the branch is applied where the upload is
performed.

---

Nitpick comments:
In `@app/components/work-item/WorkItemConversation.vue`:
- Around line 31-44: The optimistic timeline entry currently hardcodes
reactionGroups and permission flags; update the object creation in the timeline
spread (where timeline: [...workItemRef.value.timeline, { ... }]) to prefer
payload-derived fields from the returned comment (e.g., use
comment.reactionGroups, comment.viewerCanUpdate, comment.viewerCanDelete or
comment.permissions) and only fall back to the local defaults ([], true/false)
when those properties are undefined; keep the existing fields (id, subjectId,
source, sourceNumber, kind, author, authorAvatarUrl, createdAt, body) but
replace the hardcoded reactionGroups/viewerCanUpdate/viewerCanDelete with
conditional uses of the comment metadata.

In `@app/extensions/image-upload.ts`:
- Around line 5-10: getImageFile currently accepts any MIME starting with
"image/", causing unsupported formats like image/bmp to be passed through;
update getImageFile to check against the same allowed set the server enforces
(image/png, image/jpeg, image/gif, image/webp, image/svg+xml) — create an
allowedTypes array inside getImageFile and only return the File if file.type is
in that array, otherwise return null (or optionally call the existing onError
handler to surface a user-friendly message about unsupported image types).

In `@test/unit/imageUpload.test.ts`:
- Around line 4-33: Add a test case named something like "rejects when data or
type is missing" in imageUpload.test.ts that calls validateImageFile with data
undefined (e.g., data: undefined as any, type: 'image/png') and with type
undefined (e.g., data: Buffer.from('x'), type: undefined), and assert in both
cases that the returned object has valid === false and that result.message
contains "No file provided"; place it alongside the other validateImageFile it
blocks to cover this distinct validation path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34003bda-e526-4958-9975-373b28c654e3

📥 Commits

Reviewing files that changed from the base of the PR and between 49d5a5c and facc9e1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • .env.example
  • app/components/timeline/ReviewCommentThread.vue
  • app/components/work-item/WorkItemConversation.vue
  • app/composables/useMarkdownEditor.ts
  • app/composables/useMarkdownEditorHandlers.ts
  • app/extensions/image-upload.ts
  • app/utils/uploadImage.ts
  • i18n/locales/de.json
  • i18n/locales/en.json
  • i18n/schema.json
  • package.json
  • server/api/upload/image.post.ts
  • server/utils/image-upload.ts
  • test/unit/imageUpload.test.ts

Comment on lines +4 to +8
const { url } = await $fetch<{ url: string }>('/api/upload/image', {
method: 'POST',
body: form,
})
return url
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate response URL before returning.

If the API responds without a valid url, this returns invalid data and can insert a broken image source downstream.

💡 Proposed fix
 export async function uploadImage(file: File): Promise<string> {
   const form = new FormData()
   form.append('file', file)
-  const { url } = await $fetch<{ url: string }>('/api/upload/image', {
+  const response = await $fetch<{ url?: string }>('/api/upload/image', {
     method: 'POST',
     body: form,
   })
-  return url
+  if (!response.url || !response.url.trim()) {
+    throw new Error('Image upload did not return a valid URL')
+  }
+  return response.url
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/uploadImage.ts` around lines 4 - 8, The response from the $fetch
call to '/api/upload/image' is used directly; validate the returned url before
returning it from uploadImage: check that the destructured url is a non-empty
string and a well-formed URL (e.g., attempt new URL(url) or a regex) and if
validation fails throw or return a clear error/nullable value so callers don't
get a broken image src; update the block around the $fetch call and the return
to perform this validation and handle the invalid case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - this comes from us. If this url would be broken/wrong we would have a bigger problem. No fix needed in my eyes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Flo0806, that's a fair point — since the /api/upload/image endpoint is fully under your control and the server contract is well-defined, trusting the response is a reasonable call. I'll defer to your judgment here.

😔 🐇


🧠 Learnings used
Learnt from: Flo0806
Repo: flumen-dev/flumen.dev PR: 158
File: app/stores/dashboard.ts:1-23
Timestamp: 2026-02-28T08:12:02.323Z
Learning: In the flumen.dev Nuxt project, all pure TypeScript types exported from ROOT/shared/types are globally auto-imported across components, composables, and stores. Do not add explicit 'import type' statements for these types. Only runtime values (consts, functions, objects) require explicit imports. For this file (app/stores/dashboard.ts), rely on the global type imports and avoid importing these types explicitly.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/extensions/image-upload.ts (1)

5-10: Consider adding client-side file size validation for faster user feedback.

The server performs file size validation, but users will wait for the upload to complete (or timeout) before seeing a size error. Pre-validating file size client-side provides immediate feedback for oversized files.

♻️ Proposed change to add size check
+const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB - should match server limit
+
-function getImageFile(dt: DataTransfer): File | null {
+function getImageFile(dt: DataTransfer, maxSize?: number): File | null {
   for (const file of dt.files) {
-    if (file.type.startsWith('image/')) return file
+    if (file.type.startsWith('image/')) {
+      if (maxSize && file.size > maxSize) return null
+      return file
+    }
   }
   return null
 }

Then update callers to pass MAX_IMAGE_SIZE and potentially surface a specific error for oversized files via onError.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/extensions/image-upload.ts` around lines 5 - 10, Add client-side file
size validation by extending getImageFile to accept a maxSize parameter (e.g.,
getImageFile(dt: DataTransfer, maxSize: number): File | null) and check each
candidate file's size (file.size) in addition to type; if file.type
startsWith('image/') but file.size > maxSize return null (or a distinct signal)
so callers like the upload handler can surface an "oversized file" error via
their onError handler; update all callers to pass MAX_IMAGE_SIZE and handle the
null/oversize case to provide immediate user feedback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/extensions/image-upload.ts`:
- Around line 28-34: Extend the ImageUploadOptions type to include onLoading?:
(isLoading: boolean) => void, then update handleFile to call
options.onLoading?.(true) immediately before starting uploadImage(file) and
ensure options.onLoading?.(false) is called in both the .then branch after
editor.chain().focus().setImage(...) and in the .catch branch (after invoking
options.onError) so the loading state is always cleared on success or error;
ensure uploadImage, handleFile and any exported types (ImageUploadOptions) are
updated so consumers can pass the loading callback.

---

Nitpick comments:
In `@app/extensions/image-upload.ts`:
- Around line 5-10: Add client-side file size validation by extending
getImageFile to accept a maxSize parameter (e.g., getImageFile(dt: DataTransfer,
maxSize: number): File | null) and check each candidate file's size (file.size)
in addition to type; if file.type startsWith('image/') but file.size > maxSize
return null (or a distinct signal) so callers like the upload handler can
surface an "oversized file" error via their onError handler; update all callers
to pass MAX_IMAGE_SIZE and handle the null/oversize case to provide immediate
user feedback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b08d0bd8-ec52-4f0c-ba4b-21458ed42ec6

📥 Commits

Reviewing files that changed from the base of the PR and between facc9e1 and 7554d59.

📒 Files selected for processing (3)
  • app/extensions/image-upload.ts
  • server/utils/image-upload.ts
  • test/unit/imageUpload.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/utils/image-upload.ts
  • test/unit/imageUpload.test.ts

Comment on lines +28 to +34
function handleFile(file: File) {
uploadImage(file)
.then(url => editor.chain().focus().setImage({ src: url }).run())
.catch((e) => {
options.onError?.(e instanceof Error ? e : new Error('Upload failed'))
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if loading/progress state is handled when ImageUpload extension is used
rg -n -A5 -B5 'ImageUpload' --glob '*.ts' --glob '*.vue' | head -80

Repository: flumen-dev/flumen.dev

Length of output: 2741


🏁 Script executed:

#!/bin/bash
# Get the full ImageUpload extension to see all available options
cat app/extensions/image-upload.ts

Repository: flumen-dev/flumen.dev

Length of output: 1564


🏁 Script executed:

#!/bin/bash
# Check if there are any loading/progress state callbacks or handlers in useMarkdownEditor
cat app/composables/useMarkdownEditor.ts | head -100

Repository: flumen-dev/flumen.dev

Length of output: 5836


🏁 Script executed:

#!/bin/bash
# Search for any loading indicators related to image uploads in the codebase
rg -n 'loading|progress|isUploading' --glob '*.ts' --glob '*.vue' -A2 -B2 | grep -i 'image\|upload' -A2 -B2

Repository: flumen-dev/flumen.dev

Length of output: 47


Add a loading state callback to provide upload progress feedback.

The error handling correctly wraps non-Error values, ensuring onError always receives an Error instance, and the chained focus().setImage() pattern is idiomatic for TipTap.

However, there's no visual loading state while the upload is in progress. The ImageUpload extension currently only provides an onError callback with no mechanism for tracking upload progress. Users have no feedback between initiating the upload and the image appearing in the editor. Extend ImageUploadOptions to include a loading state callback (e.g., onLoading?: (isLoading: boolean) => void) so consuming code can display a loading indicator.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/extensions/image-upload.ts` around lines 28 - 34, Extend the
ImageUploadOptions type to include onLoading?: (isLoading: boolean) => void,
then update handleFile to call options.onLoading?.(true) immediately before
starting uploadImage(file) and ensure options.onLoading?.(false) is called in
both the .then branch after editor.chain().focus().setImage(...) and in the
.catch branch (after invoking options.onError) so the loading state is always
cleared on success or error; ensure uploadImage, handleFile and any exported
types (ImageUploadOptions) are updated so consumers can pass the loading
callback.

@Flo0806 Flo0806 merged commit 363d1b7 into main Mar 15, 2026
9 of 10 checks passed
@Flo0806 Flo0806 deleted the feat/image-upload branch March 15, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image uploads for issue comments

1 participant