feat: image upload & preview untuk bug reports via MinIO

- Upload hingga 3 gambar per bug report (FileInput multi-select)
- Backend: POST /api/upload/image → MinIO, GET /api/bugs/images → presigned URL redirect
- Auto-create bucket jika belum ada saat server start
- Preview gambar fullscreen saat thumbnail diklik
- Diterapkan di /bug-reports dan /apps/$appId/errors
- Migrasi storage dari Seafile ke MinIO (minio SDK v8)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:36:32 +08:00
parent a0ca6be8e1
commit 63c0a6acff
9 changed files with 535 additions and 24 deletions

View File

@@ -37,6 +37,7 @@ export const API_URLS = {
getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`,
uploadImage: () => `/api/upload/image`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
createLog: () => `/api/logs`,

View File

@@ -6,6 +6,7 @@ import {
Button,
Code,
Collapse,
FileInput,
Group,
Image,
Loader,
@@ -79,9 +80,13 @@ function AppErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({
description: '',
app: appId,
@@ -90,7 +95,6 @@ function AppErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
// Update Status Modal Logic
@@ -193,10 +197,20 @@ function AppErrorsPage() {
setIsSubmitting(true)
try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
})
if (res.ok) {
@@ -214,6 +228,7 @@ function AppErrorsPage() {
})
refetch()
close()
setImageFiles([])
setCreateForm({
description: '',
app: appId,
@@ -222,7 +237,6 @@ function AppErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
@@ -259,6 +273,28 @@ function AppErrorsPage() {
</Button>
</Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
@@ -334,7 +370,7 @@ function AppErrorsPage() {
<Modal
opened={opened}
onClose={close}
onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
@@ -396,11 +432,22 @@ function AppErrorsPage() {
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
<FileInput
label="Screenshot (Optional)"
placeholder="Klik untuk upload gambar..."
accept="image/*"
leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/>
<Textarea
@@ -606,7 +653,13 @@ function AppErrorsPage() {
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}

View File

@@ -20,6 +20,7 @@ import {
Stack,
Text,
ThemeIcon,
FileInput,
TextInput,
Textarea,
Title,
@@ -76,9 +77,13 @@ function ListErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({
description: '',
app: 'desa-plus',
@@ -87,7 +92,6 @@ function ListErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
// Update Status Modal Logic
@@ -190,10 +194,20 @@ function ListErrorsPage() {
setIsSubmitting(true)
try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
})
if (res.ok) {
@@ -211,6 +225,7 @@ function ListErrorsPage() {
})
refetch()
close()
setImageFiles([])
setCreateForm({
description: '',
app: 'desa-plus',
@@ -219,7 +234,6 @@ function ListErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
@@ -267,6 +281,28 @@ function ListErrorsPage() {
</Group>
</Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
@@ -342,7 +378,7 @@ function ListErrorsPage() {
<Modal
opened={opened}
onClose={close}
onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
@@ -404,11 +440,22 @@ function ListErrorsPage() {
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
<FileInput
label="Screenshot (Optional)"
placeholder="Klik untuk upload gambar..."
accept="image/*"
leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/>
<Textarea
@@ -625,7 +672,13 @@ function ListErrorsPage() {
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}