Fix image uploads: accept more formats, convert to JPEG, show errors
Deploy to LXC / deploy (push) Successful in 18s
Deploy to LXC / deploy (push) Successful in 18s
- Added HEIF, GIF, AVIF, BMP, TIFF to allowed image types - All uploads converted to JPEG via sharp (fixes HEIC/HEIF from iPhones) - Upload action wrapped in try/catch, errors shown in UI - Error banner displayed above image section on failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,10 @@ import sharp from 'sharp';
|
|||||||
const UPLOAD_BASE = 'static/uploads';
|
const UPLOAD_BASE = 'static/uploads';
|
||||||
const THUMBNAIL_WIDTH = 300;
|
const THUMBNAIL_WIDTH = 300;
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
|
const ALLOWED_IMAGE_TYPES = [
|
||||||
|
'image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif',
|
||||||
|
'image/bmp', 'image/tiff', 'image/gif', 'image/avif'
|
||||||
|
];
|
||||||
const ALLOWED_DOC_TYPES = ['application/pdf', 'text/plain', 'application/zip'];
|
const ALLOWED_DOC_TYPES = ['application/pdf', 'text/plain', 'application/zip'];
|
||||||
|
|
||||||
export async function saveImage(
|
export async function saveImage(
|
||||||
@@ -14,10 +17,10 @@ export async function saveImage(
|
|||||||
subfolder: 'devices' | 'components'
|
subfolder: 'devices' | 'components'
|
||||||
): Promise<{ filePath: string; thumbnailPath: string }> {
|
): Promise<{ filePath: string; thumbnailPath: string }> {
|
||||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
throw new Error(`Invalid image type: ${file.type}`);
|
throw new Error(`Invalid image type: ${file.type}. Allowed: JPEG, PNG, WebP, HEIC, GIF, AVIF, BMP, TIFF`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = extname(file.name) || '.jpg';
|
const ext = '.jpg'; // always save as jpg for consistency
|
||||||
const filename = `${randomUUID()}${ext}`;
|
const filename = `${randomUUID()}${ext}`;
|
||||||
const thumbFilename = `thumb_${filename}`;
|
const thumbFilename = `thumb_${filename}`;
|
||||||
|
|
||||||
@@ -26,15 +29,15 @@ export async function saveImage(
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
// Save original
|
// Convert to JPEG and save original
|
||||||
const filePath = join(dir, filename);
|
const filePath = join(dir, filename);
|
||||||
await writeFile(filePath, buffer);
|
await sharp(buffer).jpeg({ quality: 90 }).toFile(filePath);
|
||||||
|
|
||||||
// Generate thumbnail
|
// Generate thumbnail
|
||||||
const thumbnailPath = join(dir, thumbFilename);
|
const thumbnailPath = join(dir, thumbFilename);
|
||||||
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: 80 }).toFile(thumbnailPath);
|
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: 80 }).toFile(thumbnailPath);
|
||||||
|
|
||||||
// Return paths relative to static/ for serving
|
// Return URL paths (always forward slashes)
|
||||||
return {
|
return {
|
||||||
filePath: `/uploads/${subfolder}/${filename}`,
|
filePath: `/uploads/${subfolder}/${filename}`,
|
||||||
thumbnailPath: `/uploads/${subfolder}/${thumbFilename}`
|
thumbnailPath: `/uploads/${subfolder}/${thumbFilename}`
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const actions: Actions = {
|
|||||||
const file = formData.get('image') as File;
|
const file = formData.get('image') as File;
|
||||||
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
|
if (!file || file.size === 0) return fail(400, { error: 'No file selected' });
|
||||||
|
|
||||||
|
try {
|
||||||
const { filePath, thumbnailPath } = await saveImage(file, 'devices');
|
const { filePath, thumbnailPath } = await saveImage(file, 'devices');
|
||||||
const caption = formData.get('caption') as string;
|
const caption = formData.get('caption') as string;
|
||||||
|
|
||||||
@@ -228,6 +229,9 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { imageUploaded: true };
|
return { imageUploaded: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteImage: async ({ request }) => {
|
deleteImage: async ({ request }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js';
|
import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js';
|
||||||
import { formatDate, timeAgo } from '$lib/utils/date.js';
|
import { formatDate, timeAgo } from '$lib/utils/date.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, form } = $props();
|
||||||
let showStatusForm = $state(false);
|
let showStatusForm = $state(false);
|
||||||
let showUploadForm = $state(false);
|
let showUploadForm = $state(false);
|
||||||
let showDocForm = $state(false);
|
let showDocForm = $state(false);
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-3 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showUploadForm}
|
{#if showUploadForm}
|
||||||
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
|
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
|
||||||
<input type="file" name="image" accept="image/*" required
|
<input type="file" name="image" accept="image/*" required
|
||||||
|
|||||||
Reference in New Issue
Block a user