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:
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user