From 1100d540278326de87e337d8636151bee60c58f5 Mon Sep 17 00:00:00 2001 From: tmeunier Date: Tue, 17 Mar 2026 22:13:24 +0100 Subject: [PATCH 1/3] feat: add button for uplaod image to file --- .env.example | 27 +- adonisrc.ts | 1 + .../images/upload_images_controller.ts | 39 + app/models/image.ts | 9 + config/drive.ts | 30 + .../1773423772443_create_images_table.ts | 16 + inertia/components/form/file-upload.tsx | 104 + inertia/components/form/mdeditor.tsx | 26 +- inertia/hooks/use_fetch_api.ts | 135 + package-lock.json | 7490 ++++++++++------- package.json | 8 +- start/env.ts | 12 + start/routes.ts | 5 + 13 files changed, 5077 insertions(+), 2825 deletions(-) create mode 100644 app/controllers/images/upload_images_controller.ts create mode 100644 app/models/image.ts create mode 100644 config/drive.ts create mode 100644 database/migrations/1773423772443_create_images_table.ts create mode 100644 inertia/components/form/file-upload.tsx create mode 100644 inertia/hooks/use_fetch_api.ts diff --git a/.env.example b/.env.example index e1b9115..4bdf792 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,29 @@ +## APP +APP_URL= +APP_KEY= + +## CONF TZ=UTC PORT=3333 HOST=localhost -LOG_LEVEL=info -APP_KEY= NODE_ENV=development + +## Session SESSION_DRIVER=cookie + +## LOG +LOG_LEVEL=info + +## Database DB_HOST=127.0.0.1 DB_PORT=5432 -DB_USER=root -DB_PASSWORD=root -DB_DATABASE=app \ No newline at end of file +DB_USER=knowledge +DB_PASSWORD=knowledge +DB_DATABASE=knowledge + +### S3 +DRIVE_DISK=s3 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +S3_BUCKET= diff --git a/adonisrc.ts b/adonisrc.ts index 2736a71..c79cb2f 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -53,6 +53,7 @@ export default defineConfig({ () => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/inertia/inertia_provider'), + () => import('@adonisjs/drive/drive_provider') ], /* diff --git a/app/controllers/images/upload_images_controller.ts b/app/controllers/images/upload_images_controller.ts new file mode 100644 index 0000000..912acdb --- /dev/null +++ b/app/controllers/images/upload_images_controller.ts @@ -0,0 +1,39 @@ +// import type { HttpContext } from '@adonisjs/core/http' + +import { HttpContext } from '@adonisjs/core/http' +import string from '@adonisjs/core/helpers/string' +import Image from '#models/image' +import { v4 as uuidv4 } from 'uuid' +import vine from '@vinejs/vine' + +export default class UploadImagesController { + static validator = vine.compile( + vine.object({ + file: vine.file({ + extnames: ['jpg', 'png', 'jpeg'], + size: '2mb', + }), + }) + ) + + async upload({ request, response, session }: HttpContext) { + const { file } = await request.validateUsing(UploadImagesController.validator) + + if (!file) return response.badRequest('Invalid file') + + const fileName = `${string.uuid()}.${file.extname}` + + await Promise.all([ + file.moveToDisk(`/uploads/${fileName}`), + Image.create({ + id: uuidv4(), + url: fileName, + }), + ]) + + session.flash('success', 'File uploaded successfully') + session.flash('share', `/uploads/${fileName}`) + + return response.redirect().back() + } +} diff --git a/app/models/image.ts b/app/models/image.ts new file mode 100644 index 0000000..3b0f428 --- /dev/null +++ b/app/models/image.ts @@ -0,0 +1,9 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class Image extends BaseModel { + @column({ isPrimary: true }) + declare id: string + + @column() + declare url: string +} diff --git a/config/drive.ts b/config/drive.ts new file mode 100644 index 0000000..0ca54c5 --- /dev/null +++ b/config/drive.ts @@ -0,0 +1,30 @@ +import env from '#start/env' +import { defineConfig, services } from '@adonisjs/drive' + +const driveConfig = defineConfig({ + default: env.get('DRIVE_DISK'), + + /** + * The services object can be used to configure multiple file system + * services each using the same or a different driver. + */ + services: { + s3: services.s3({ + credentials: { + accessKeyId: env.get('AWS_ACCESS_KEY_ID'), + secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'), + }, + region: env.get('AWS_REGION'), + bucket: env.get('S3_BUCKET'), + endpoint: env.get('S3_ENDPOINT'), + visibility: 'private', + forcePathStyle: env.get('S3_FORCE_PATH_STYLE'), + }), + }, +}) + +export default driveConfig + +declare module '@adonisjs/drive/types' { + export interface DriveDisks extends InferDriveDisks {} +} diff --git a/database/migrations/1773423772443_create_images_table.ts b/database/migrations/1773423772443_create_images_table.ts new file mode 100644 index 0000000..31358c3 --- /dev/null +++ b/database/migrations/1773423772443_create_images_table.ts @@ -0,0 +1,16 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'images' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary() + table.string('url').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/inertia/components/form/file-upload.tsx b/inertia/components/form/file-upload.tsx new file mode 100644 index 0000000..5e05136 --- /dev/null +++ b/inertia/components/form/file-upload.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Loader2 } from 'lucide-react' +import { router } from '@inertiajs/react' + +type Props = { + onUpload: (url: string) => void +} + +export function FileUploader({ onUpload }: Props) { + const [processing, setProcessing] = useState(false) + const [error, setError] = useState(null) + const isMounted = useRef(true) + + useEffect(() => { + isMounted.current = true + return () => { + isMounted.current = false + } + }, []) + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (!file) return + + const formData = new FormData() + formData.append('file', file) + + setProcessing(true) + setError(null) + + router.post('/upload', formData, { + forceFormData: true, + preserveState: true, + preserveScroll: true, + onSuccess: (page) => { + if (!isMounted.current) return + const share = (page.props as { flash?: { share?: string } }).flash?.share + if (share) { + onUpload(share) + } else { + setError('Upload succeeded but no URL returned') + } + }, + onError: (errors) => { + if (!isMounted.current) return + setError(errors.file ?? 'Upload failed') + }, + onFinish: () => { + if (isMounted.current) setProcessing(false) + }, + }) + }, + [onUpload] + ) + + const onDropRejected = useCallback((rejectedFiles: import('react-dropzone').FileRejection[]) => { + const code = rejectedFiles[0]?.errors[0]?.code + if (code === 'file-too-large') setError('File is too large (max 5mb)') + else if (code === 'file-invalid-type') setError('Invalid file type') + else setError('File rejected') + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + onDropRejected, + accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] }, + maxSize: 5 * 1024 * 1024, + multiple: false, + disabled: processing, + }) + + return ( +
+ + + {processing ? ( +
+ +

Uploading…

+
+ ) : isDragActive ? ( +

Drop the image here

+ ) : ( +
+

Drag & drop an image here

+

or click to browse

+

JPG, PNG, WEBP — max 5mb

+
+ )} + + {error &&

{error}

} +
+ ) +} diff --git a/inertia/components/form/mdeditor.tsx b/inertia/components/form/mdeditor.tsx index b6fec56..b2e3923 100644 --- a/inertia/components/form/mdeditor.tsx +++ b/inertia/components/form/mdeditor.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import 'easymde/dist/easymde.min.css' import EasyMDE from 'easymde' +import { Dialog, DialogContent } from '../ui/dialog' +import { FileUploader } from '@/components/form/file-upload' type Props = { defaultValue: string @@ -12,6 +14,7 @@ type Props = { export default function MdEditor(props: Props) { const textareaRef = useRef(null) const editorRef = useRef(null) + const [fileExplorerOpen, setFileExplorerOpen] = useState(false) useEffect(() => { if (!textareaRef.current || editorRef.current) { @@ -36,6 +39,12 @@ export default function MdEditor(props: Props) { 'ordered-list', '|', 'link', + { + name: 'image', + action: () => setFileExplorerOpen(true), + className: 'fa fa-image', + title: 'Insert image', + }, 'code', '|', 'side-by-side', @@ -60,6 +69,21 @@ export default function MdEditor(props: Props) { return (