From d02af40836681a302bd1eff9ab0614b646fe6b05 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 2 Jul 2026 14:28:51 +0700 Subject: [PATCH] feat: implement presigned upload functionality for S3 in PresignedUploadController; update media upload handling across various components to support S3 keys and improve user experience --- .../Admin/Media/PresignedUploadController.php | 47 +++++++ .../Admin/Account/UpdateProfileRequest.php | 4 +- .../Requests/Admin/Hr/EmployeeRequest.php | 1 + .../System/Setting/HomepageSettingRequest.php | 12 +- .../Admin/System/Setting/SystemRequest.php | 6 + .../Concerns/ValidatesMediaUploads.php | 16 +-- app/Services/Account/ProfileService.php | 13 +- app/Services/Finance/CashService.php | 1 + app/Services/Finance/ExpenseService.php | 1 + app/Services/Hr/EmployeeService.php | 13 +- app/Services/Manage/CuttingService.php | 2 + app/Services/Manage/OrderService.php | 1 + app/Services/Manage/PurchaseService.php | 7 +- app/Services/Master/ProductService.php | 2 + app/Services/Master/RawMaterialService.php | 2 + app/Services/Media/MediaService.php | 54 +++++++- .../System/Setting/HomepageSettingService.php | 15 ++- app/Services/System/Setting/SystemService.php | 12 +- .../js/components/media/MediaDropzone.vue | 120 +++++++++++++++++- resources/js/lib/s3-upload.ts | 59 +++++++++ resources/js/pages/admin/account/Profile.vue | 10 +- .../cash/form/CashTransactionFormModal.vue | 6 +- .../expenses/form/ExpenseFormModal.vue | 6 +- .../admin/hr/employees/form/EmployeeForm.vue | 10 +- .../cuttings/form/QuickCreateProductModal.vue | 8 +- .../form/QuickCreateRawMaterialModal.vue | 8 +- .../orders/form/OrderPosCheckoutSection.vue | 6 +- .../form/PurchasePosAddVariantDialog.vue | 10 +- .../form/PurchasePosCheckoutSection.vue | 6 +- .../master/products/form/ProductForm.vue | 8 +- .../raw-materials/form/RawMaterialForm.vue | 8 +- .../system/setting/form/HomepageSection.vue | 22 ++-- .../system/setting/form/SystemSection.vue | 22 ++-- resources/js/types/media.ts | 12 +- routes/web.php | 4 + 35 files changed, 447 insertions(+), 87 deletions(-) create mode 100644 app/Http/Controllers/Admin/Media/PresignedUploadController.php create mode 100644 resources/js/lib/s3-upload.ts diff --git a/app/Http/Controllers/Admin/Media/PresignedUploadController.php b/app/Http/Controllers/Admin/Media/PresignedUploadController.php new file mode 100644 index 0000000..30c509e --- /dev/null +++ b/app/Http/Controllers/Admin/Media/PresignedUploadController.php @@ -0,0 +1,47 @@ +validate([ + 'filename' => ['required', 'string', 'max:255'], + 'mime_type' => ['required', 'string', 'max:100'], + ]); + + $extension = pathinfo($validated['filename'], PATHINFO_EXTENSION) ?: 'bin'; + $key = 'temp/'.Str::uuid().'.'.$extension; + + $s3 = new S3Client([ + 'region' => config('filesystems.disks.s3.region'), + 'endpoint' => config('filesystems.disks.s3.endpoint'), + 'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'), + 'credentials' => [ + 'key' => config('filesystems.disks.s3.key'), + 'secret' => config('filesystems.disks.s3.secret'), + ], + ]); + + $command = $s3->getCommand('PutObject', [ + 'Bucket' => config('filesystems.disks.s3.bucket'), + 'Key' => $key, + 'ContentType' => $validated['mime_type'], + ]); + + $presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri(); + + return response()->json([ + 'key' => $key, + 'url' => $presignedUrl, + 'expires_at' => now()->addMinutes(15)->toISOString(), + ]); + } +} diff --git a/app/Http/Requests/Admin/Account/UpdateProfileRequest.php b/app/Http/Requests/Admin/Account/UpdateProfileRequest.php index 0f0b7e9..e612d73 100644 --- a/app/Http/Requests/Admin/Account/UpdateProfileRequest.php +++ b/app/Http/Requests/Admin/Account/UpdateProfileRequest.php @@ -27,7 +27,7 @@ public function rules(): array 'gender' => ['nullable', Rule::enum(Gender::class)], 'birth_date' => ['nullable', 'date', 'before:today'], 'address' => ['nullable', 'string'], - 'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + 'profile_s3_key' => ['nullable', 'string'], 'remove_profile_photo_ids' => ['nullable', 'array'], 'remove_profile_photo_ids.*' => ['integer'], ]; @@ -46,7 +46,7 @@ public function attributes(): array 'gender' => 'Jenis Kelamin', 'birth_date' => 'Tanggal Lahir', 'address' => 'Alamat', - 'profile_photo' => 'Foto Profil', + 'profile_s3_key' => 'Foto Profil', ]; } } diff --git a/app/Http/Requests/Admin/Hr/EmployeeRequest.php b/app/Http/Requests/Admin/Hr/EmployeeRequest.php index e8d7e0d..18cd004 100644 --- a/app/Http/Requests/Admin/Hr/EmployeeRequest.php +++ b/app/Http/Requests/Admin/Hr/EmployeeRequest.php @@ -38,6 +38,7 @@ public function rules(): array 'address' => ['nullable', 'string'], 'role' => ['required', Rule::in(Role::assignableValues())], 'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + 'profile_s3_key' => ['nullable', 'string'], 'remove_profile_photo_ids' => ['nullable', 'array'], 'remove_profile_photo_ids.*' => ['integer'], ]; diff --git a/app/Http/Requests/Admin/System/Setting/HomepageSettingRequest.php b/app/Http/Requests/Admin/System/Setting/HomepageSettingRequest.php index ca363fb..cb71f22 100644 --- a/app/Http/Requests/Admin/System/Setting/HomepageSettingRequest.php +++ b/app/Http/Requests/Admin/System/Setting/HomepageSettingRequest.php @@ -19,9 +19,11 @@ public function rules(): array { return [ 'hero_image' => ['nullable', 'image', 'max:5120'], + 'hero_image_s3_key' => ['nullable', 'string'], 'about_image' => ['nullable', 'image', 'max:5120'], - 'gallery_images' => ['nullable', 'array', 'max:10'], - 'gallery_images.*' => ['image', 'max:5120'], + 'about_image_s3_key' => ['nullable', 'string'], + 'gallery_s3_keys' => ['nullable', 'array', 'max:10'], + 'gallery_s3_keys.*' => ['required', 'string'], 'gallery_images_remove' => ['nullable', 'array'], 'gallery_images_remove.*' => ['integer'], ]; @@ -34,9 +36,11 @@ public function attributes(): array { return [ 'hero_image' => 'Foto Hero', + 'hero_image_s3_key' => 'Foto Hero', 'about_image' => 'Foto Tentang Kami', - 'gallery_images' => 'Foto Lookbook', - 'gallery_images.*' => 'Foto Lookbook', + 'about_image_s3_key' => 'Foto Tentang Kami', + 'gallery_s3_keys' => 'Foto Lookbook', + 'gallery_s3_keys.*' => 'Foto Lookbook', ]; } } diff --git a/app/Http/Requests/Admin/System/Setting/SystemRequest.php b/app/Http/Requests/Admin/System/Setting/SystemRequest.php index 568bb9d..f1f796c 100644 --- a/app/Http/Requests/Admin/System/Setting/SystemRequest.php +++ b/app/Http/Requests/Admin/System/Setting/SystemRequest.php @@ -25,8 +25,11 @@ public function rules(): array 'phone' => ['required', 'string', 'max:20', new PhoneNumber], 'address' => ['required', 'string'], 'logo' => ['nullable', 'image', 'max:2048'], + 'logo_s3_key' => ['nullable', 'string'], 'favicon' => ['nullable', 'image', 'max:1024'], + 'favicon_s3_key' => ['nullable', 'string'], 'login_cover' => ['nullable', 'image', 'max:5120'], + 'login_cover_s3_key' => ['nullable', 'string'], ]; } @@ -42,8 +45,11 @@ public function attributes(): array 'phone' => 'nomor telepon', 'address' => 'alamat', 'logo' => 'logo', + 'logo_s3_key' => 'logo', 'favicon' => 'favicon', + 'favicon_s3_key' => 'favicon', 'login_cover' => 'cover login', + 'login_cover_s3_key' => 'cover login', ]; } } diff --git a/app/Http/Requests/Concerns/ValidatesMediaUploads.php b/app/Http/Requests/Concerns/ValidatesMediaUploads.php index 3ad2eb3..349cb78 100644 --- a/app/Http/Requests/Concerns/ValidatesMediaUploads.php +++ b/app/Http/Requests/Concerns/ValidatesMediaUploads.php @@ -10,8 +10,8 @@ trait ValidatesMediaUploads protected function photoRules(string $prefix = 'photos', int $max = 1): array { return [ - $prefix => ['nullable', 'array', "max:{$max}"], - "{$prefix}.*" => ['image', 'max:5120'], + 's3_keys' => ['nullable', 'array', "max:{$max}"], + 's3_keys.*' => ['required', 'string'], 'remove_media_ids' => ['nullable', 'array'], 'remove_media_ids.*' => ['integer'], ]; @@ -23,8 +23,8 @@ protected function photoRules(string $prefix = 'photos', int $max = 1): array protected function variantImageRules(string $variantsKey = 'variants', int $max = 5): array { return [ - "{$variantsKey}.*.images" => ['nullable', 'array', "max:{$max}"], - "{$variantsKey}.*.images.*" => ['image', 'max:5120'], + "{$variantsKey}.*.s3_keys" => ['nullable', 'array', "max:{$max}"], + "{$variantsKey}.*.s3_keys.*" => ['required', 'string'], "{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'], "{$variantsKey}.*.remove_media_ids.*" => ['integer'], ]; @@ -36,8 +36,8 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max protected function photoUploadAttributes(string $label): array { return [ - 'photos' => $label, - 'photos.*' => $label, + 's3_keys' => $label, + 's3_keys.*' => $label, 'remove_media_ids' => 'media yang dihapus', 'remove_media_ids.*' => 'media yang dihapus', ]; @@ -49,8 +49,8 @@ protected function photoUploadAttributes(string $label): array protected function variantImageAttributes(string $variantsKey, string $label): array { return [ - "{$variantsKey}.*.images" => $label, - "{$variantsKey}.*.images.*" => $label, + "{$variantsKey}.*.s3_keys" => $label, + "{$variantsKey}.*.s3_keys.*" => $label, "{$variantsKey}.*.remove_media_ids" => 'media yang dihapus', "{$variantsKey}.*.remove_media_ids.*" => 'media yang dihapus', ]; diff --git a/app/Services/Account/ProfileService.php b/app/Services/Account/ProfileService.php index 8b02b90..0fc395d 100644 --- a/app/Services/Account/ProfileService.php +++ b/app/Services/Account/ProfileService.php @@ -35,10 +35,17 @@ public function update(array $validated, User $user): void ], ); - $newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : []; - $removeIds = $validated['remove_profile_photo_ids'] ?? []; + $s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null; + $removeIds = $validated['remove_profile_photo_ids'] ?? null; - $this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1); + $this->mediaService->syncCollection( + $profile, + 'profile_photo', + null, + $removeIds, + 1, + s3Keys: $s3Keys, + ); }); } catch (ValidationException $e) { throw $e; diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 9df8b75..0842b19 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi self::MAX_PHOTOS, required: true, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } diff --git a/app/Services/Finance/ExpenseService.php b/app/Services/Finance/ExpenseService.php index f2562a2..03151f4 100644 --- a/app/Services/Finance/ExpenseService.php +++ b/app/Services/Finance/ExpenseService.php @@ -180,6 +180,7 @@ private function syncPhotos(Expense $expense, array $validated): void self::MAX_PHOTOS, required: true, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } diff --git a/app/Services/Hr/EmployeeService.php b/app/Services/Hr/EmployeeService.php index ec03d6c..ccae702 100644 --- a/app/Services/Hr/EmployeeService.php +++ b/app/Services/Hr/EmployeeService.php @@ -223,10 +223,17 @@ public function delete(User $user): void private function syncProfilePhoto(UserProfile $profile, array $validated): void { - $newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : []; - $removeIds = $validated['remove_profile_photo_ids'] ?? []; + $s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null; + $removeIds = $validated['remove_profile_photo_ids'] ?? null; - $this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1); + $this->mediaService->syncCollection( + $profile, + 'profile_photo', + null, + $removeIds, + 1, + s3Keys: $s3Keys, + ); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index f787005..eb2279b 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -1067,6 +1067,7 @@ public function quickCreateRawMaterial(array $validated): array $maxVariantImages, required: true, errorKey: "prices.{$index}.images", + s3Keys: $priceData['s3_keys'] ?? null, ); $price->refresh(); @@ -1136,6 +1137,7 @@ public function quickCreateProduct(array $validated): array $maxVariantImages, required: true, errorKey: "variants.{$index}.images", + s3Keys: $variantData['s3_keys'] ?? null, ); $variant->refresh(); diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index 7e1d175..f09319d 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -821,6 +821,7 @@ private function syncPhotos(Order $order, array $validated): void self::MAX_PHOTOS, required: $requiresPhoto, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 40db777..246bb42 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -211,15 +211,16 @@ public function createVariantAndDraft(array $validated, User $user): array 'stock' => (float) ($validated['stock'] ?? 0), ]); - if (! empty($validated['photos'])) { + if (! empty($validated['photos']) || ! empty($validated['s3_keys'])) { $this->mediaService->syncCollection( $price, 'images', - $validated['photos'], + $validated['photos'] ?? null, null, 5, required: false, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } @@ -543,6 +544,7 @@ private function syncPhotos(Purchase $purchase, array $validated): void self::MAX_PHOTOS, required: false, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } @@ -755,6 +757,7 @@ private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest self::MAX_PHOTOS, required: false, errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, ); } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index d496f47..7a10ee3 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -691,6 +691,7 @@ private function syncVariantImages( self::MAX_VARIANT_IMAGES, required: true, errorKey: "variants.{$index}.images", + s3Keys: $variantData['s3_keys'] ?? null, ); } @@ -710,6 +711,7 @@ private function syncRequestVariantImages( self::MAX_VARIANT_IMAGES, required: $required, errorKey: "variants.{$index}.images", + s3Keys: $variantData['s3_keys'] ?? null, ); } diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index c1c5937..f44dc50 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -644,6 +644,7 @@ private function syncPriceImages(RawMaterialPrice $price, array $priceData, int self::MAX_VARIANT_IMAGES, required: true, errorKey: "prices.{$index}.images", + s3Keys: $priceData['s3_keys'] ?? null, ); } @@ -663,6 +664,7 @@ private function syncRequestPriceImages( self::MAX_VARIANT_IMAGES, required: $required, errorKey: "prices.{$index}.images", + s3Keys: $priceData['s3_keys'] ?? null, ); } diff --git a/app/Services/Media/MediaService.php b/app/Services/Media/MediaService.php index 4dc28fa..66b2c45 100644 --- a/app/Services/Media/MediaService.php +++ b/app/Services/Media/MediaService.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; class MediaService @@ -21,10 +22,10 @@ public function syncCollection( ?string $type = null, bool $required = false, ?string $errorKey = null, + ?array $s3Keys = null, ): void { - $newFiles = array_values(array_filter($newFiles ?? [])); - - if ($maxFiles === 1 && $newFiles !== []) { + // Handle removals + if ($maxFiles === 1 && (! empty($newFiles) || ! empty($s3Keys))) { $model->clearMediaCollection($collection); } elseif ($removeIds !== null && $removeIds !== []) { $model->getMedia($collection) @@ -32,20 +33,30 @@ public function syncCollection( ->each->delete(); } + // Determine items to add (S3 keys take priority) + $useS3 = ! empty($s3Keys); + $items = $useS3 + ? array_values(array_filter($s3Keys)) + : array_values(array_filter($newFiles ?? [])); + if ($maxFiles === 1) { - $newFiles = array_slice($newFiles, 0, 1); + $items = array_slice($items, 0, 1); } $currentCount = $model->getMedia($collection)->count(); - if ($currentCount + count($newFiles) > $maxFiles) { + if ($currentCount + count($items) > $maxFiles) { throw ValidationException::withMessages([ $errorKey ?? $collection => "Maksimal {$maxFiles} gambar per item.", ]); } - foreach ($newFiles as $file) { - $this->addUploadedFile($model, $file, $collection, $type); + foreach ($items as $item) { + if ($useS3) { + $this->registerS3Key($model, $item, $collection, $type); + } else { + $this->addUploadedFile($model, $item, $collection, $type); + } } if ($model instanceof Model) { @@ -70,6 +81,24 @@ public function addUploadedFile( ->toMediaCollection($collection); } + /** + * @param HasMedia&InteractsWithMedia $model + */ + public function registerS3Key( + HasMedia $model, + string $s3Key, + string $collection, + ?string $type = null, + ): Media { + try { + return $model->addMediaFromDisk($s3Key, 's3') + ->withCustomProperties($this->customProperties($model, $type ?? $collection)) + ->toMediaCollection($collection); + } finally { + Storage::disk('s3')->delete($s3Key); + } + } + public function addBase64Image( HasMedia $model, string $base64Photo, @@ -114,6 +143,17 @@ public function replaceSingleFile( return $this->addUploadedFile($model, $file, $collection, $type); } + public function replaceSingleS3Key( + HasMedia $model, + string $s3Key, + string $collection, + ?string $type = null, + ): Media { + $model->clearMediaCollection($collection); + + return $this->registerS3Key($model, $s3Key, $collection, $type); + } + private function customProperties(HasMedia $model, ?string $type): array { $properties = [ diff --git a/app/Services/System/Setting/HomepageSettingService.php b/app/Services/System/Setting/HomepageSettingService.php index c27a984..bf2f117 100644 --- a/app/Services/System/Setting/HomepageSettingService.php +++ b/app/Services/System/Setting/HomepageSettingService.php @@ -33,22 +33,27 @@ public function updateHomepage(array $validated): void { $configuration = HomepageConfiguration::instance(); - if (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) { + if (isset($validated['hero_image_s3_key'])) { + $this->mediaService->replaceSingleS3Key($configuration, $validated['hero_image_s3_key'], 'hero_image', 'hero-image'); + } elseif (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) { $this->mediaService->replaceSingleFile($configuration, $validated['hero_image'], 'hero_image', 'hero-image'); } - if (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) { + if (isset($validated['about_image_s3_key'])) { + $this->mediaService->replaceSingleS3Key($configuration, $validated['about_image_s3_key'], 'about_image', 'about-image'); + } elseif (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) { $this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image'); } - if (isset($validated['gallery_images']) || isset($validated['gallery_images_remove'])) { + if (isset($validated['gallery_s3_keys']) || isset($validated['gallery_images_remove'])) { $this->mediaService->syncCollection( $configuration, 'gallery', - $validated['gallery_images'] ?? [], - $validated['gallery_images_remove'] ?? [], + null, + $validated['gallery_images_remove'] ?? null, 10, 'gallery', + s3Keys: $validated['gallery_s3_keys'] ?? null, ); } } diff --git a/app/Services/System/Setting/SystemService.php b/app/Services/System/Setting/SystemService.php index 3db6359..28d5733 100644 --- a/app/Services/System/Setting/SystemService.php +++ b/app/Services/System/Setting/SystemService.php @@ -49,15 +49,21 @@ public function updateSystem(array $validated): void $settings->address = $validated['address'] ?? null; $settings->save(); - if (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) { + if (isset($validated['logo_s3_key'])) { + $this->mediaService->replaceSingleS3Key($configuration, $validated['logo_s3_key'], 'logo', 'logo'); + } elseif (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) { $this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo'); } - if (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) { + if (isset($validated['favicon_s3_key'])) { + $this->mediaService->replaceSingleS3Key($configuration, $validated['favicon_s3_key'], 'favicon', 'favicon'); + } elseif (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) { $this->mediaService->replaceSingleFile($configuration, $validated['favicon'], 'favicon', 'favicon'); } - if (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) { + if (isset($validated['login_cover_s3_key'])) { + $this->mediaService->replaceSingleS3Key($configuration, $validated['login_cover_s3_key'], 'login_cover', 'login-cover'); + } elseif (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) { $this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover'); } diff --git a/resources/js/components/media/MediaDropzone.vue b/resources/js/components/media/MediaDropzone.vue index f7277e0..cf15dec 100644 --- a/resources/js/components/media/MediaDropzone.vue +++ b/resources/js/components/media/MediaDropzone.vue @@ -7,6 +7,7 @@ import { FieldError, FieldLabel, } from '@/components/ui/field'; +import { uploadFileAndGetKey } from '@/lib/s3-upload'; import type { MediaUploadState } from '@/types/media'; import { createMediaUploadState } from '@/types/media'; import 'dropzone-vue/dist/dropzone-vue.common.css'; @@ -101,6 +102,10 @@ const state = defineModel({ default: () => createMediaUploadState(), }); +// ─── Upload progress tracking ──────────────────────────────────────────────── +const uploadProgress = ref>({}); +const uploadErrors = ref>({}); + // ─── Dialog ────────────────────────────────────────────────────────────────── const previewOpen = ref(false); const previewUrl = ref(null); @@ -143,16 +148,52 @@ function onAddedFile(item: { id: string; file: File }) { filePreviews.value.forEach((p) => URL.revokeObjectURL(p.objectUrl)); filePreviews.value = []; state.value.newFiles = []; + state.value.newFileS3Keys = []; } - // Compress image asynchronously, then add to state + // Compress image asynchronously, then upload to S3 compressImage(item.file).then((compressed) => { + const fileIndex = state.value.newFiles.length; + state.value.newFiles.push(compressed); filePreviews.value.push({ id: item.id, file: compressed, objectUrl: URL.createObjectURL(compressed), }); + + // Upload to S3 in background + uploadProgress.value[item.id] = 0; + delete uploadErrors.value[item.id]; + state.value.pendingUploads++; + + uploadFileAndGetKey(compressed, (percent) => { + uploadProgress.value[item.id] = percent; + }).then((s3Key) => { + state.value.newFileS3Keys[fileIndex] = s3Key; + delete uploadProgress.value[item.id]; + state.value.pendingUploads--; + }).catch((error: Error) => { + uploadErrors.value[item.id] = error.message; + delete uploadProgress.value[item.id]; + state.value.pendingUploads--; + // Remove the file from state on upload failure + const idx = state.value.newFiles.indexOf(compressed); + + if (idx !== -1) { + state.value.newFiles.splice(idx, 1); + state.value.newFileS3Keys.splice(idx, 1); + } + + const pi = filePreviews.value.findIndex((p) => p.id === item.id); + + if (pi !== -1) { + URL.revokeObjectURL(filePreviews.value[pi].objectUrl); + filePreviews.value.splice(pi, 1); + } + + dropzoneRef.value?.removeFile(item.id); + }); }); } @@ -161,6 +202,7 @@ function onRemovedFile(item: { id: string; file: File }) { if (idx !== -1) { state.value.newFiles.splice(idx, 1); + state.value.newFileS3Keys.splice(idx, 1); } const pi = filePreviews.value.findIndex((p) => p.id === item.id); @@ -169,6 +211,9 @@ function onRemovedFile(item: { id: string; file: File }) { URL.revokeObjectURL(filePreviews.value[pi].objectUrl); filePreviews.value.splice(pi, 1); } + + delete uploadProgress.value[item.id]; + delete uploadErrors.value[item.id]; } // ─── Unified normalized entries ─────────────────────────────────────────────── @@ -179,6 +224,8 @@ type NormalizedEntry = { previewSrc: string; // src for fullscreen preview name: string; sizeLabel: string; + uploadPercent: number | null; + uploadError: string | null; onRemove: () => void; }; @@ -192,6 +239,8 @@ const allPreviews = computed(() => { previewSrc: item.url, name: item.url.split('/').pop() ?? `image-${item.id}`, sizeLabel: 'Tersimpan', + uploadPercent: null, + uploadError: null, onRemove: () => { const idx = state.value.existing.findIndex((e) => e.id === item.id); @@ -212,6 +261,8 @@ const allPreviews = computed(() => { previewSrc: p.objectUrl, name: p.file.name, sizeLabel: formatBytes(p.file.size), + uploadPercent: uploadProgress.value[p.id] ?? null, + uploadError: uploadErrors.value[p.id] ?? null, onRemove: () => dropzoneRef.value?.removeFile(p.id), })); @@ -323,6 +374,23 @@ watch( + +
+
+
+
+ {{ entry.uploadPercent }}% +
+ +
+ + + + + +
@@ -331,7 +399,9 @@ watch(

{{ entry.name }}

-

{{ entry.sizeLabel }}

+

+ {{ entry.uploadError ? 'Gagal' : entry.sizeLabel }} +

diff --git a/resources/js/pages/admin/finance/cash/form/CashTransactionFormModal.vue b/resources/js/pages/admin/finance/cash/form/CashTransactionFormModal.vue index 6a24fbc..0cfa8d7 100644 --- a/resources/js/pages/admin/finance/cash/form/CashTransactionFormModal.vue +++ b/resources/js/pages/admin/finance/cash/form/CashTransactionFormModal.vue @@ -51,6 +51,8 @@ const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WI const photoState = ref(createMediaUploadState()); +const isUploading = computed(() => photoState.value.pendingUploads > 0); + const form = useForm({ amount: '', description: '', @@ -166,9 +168,9 @@ const placeholder = computed(() => - diff --git a/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue b/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue index b6f1e65..564f459 100644 --- a/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue +++ b/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue @@ -41,6 +41,8 @@ const isEditing = computed(() => props.expense != null); const photoState = ref(createMediaUploadState()); +const isUploading = computed(() => photoState.value.pendingUploads > 0); + const form = useForm({ amount: '', description: '', @@ -143,9 +145,9 @@ function submit() { - diff --git a/resources/js/pages/admin/hr/employees/form/EmployeeForm.vue b/resources/js/pages/admin/hr/employees/form/EmployeeForm.vue index 5869234..457bfc2 100644 --- a/resources/js/pages/admin/hr/employees/form/EmployeeForm.vue +++ b/resources/js/pages/admin/hr/employees/form/EmployeeForm.vue @@ -70,6 +70,8 @@ const profilePhotoState = ref( createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []), ); +const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0); + function buildFormData(): FormData { const formData = new FormData(); @@ -85,8 +87,8 @@ function buildFormData(): FormData { formData.append('base_salary', form.base_salary === '' ? '' : form.base_salary); formData.append('role', form.role); - profilePhotoState.value.newFiles.forEach((file) => { - formData.append('profile_photo', file); + profilePhotoState.value.newFileS3Keys.forEach((key) => { + formData.append('profile_s3_key', key); }); profilePhotoState.value.removeIds.forEach((id) => { @@ -248,9 +250,9 @@ function submit() {
-
diff --git a/resources/js/pages/admin/manage/cuttings/form/QuickCreateProductModal.vue b/resources/js/pages/admin/manage/cuttings/form/QuickCreateProductModal.vue index f0c2f78..4a1da28 100644 --- a/resources/js/pages/admin/manage/cuttings/form/QuickCreateProductModal.vue +++ b/resources/js/pages/admin/manage/cuttings/form/QuickCreateProductModal.vue @@ -33,6 +33,10 @@ const emit = defineEmits<{ const loading = ref(false); +const isUploading = computed(() => + variants.value.some((v) => v.media.pendingUploads > 0) +); + function createClientId(): string { return `variant-${crypto.randomUUID()}`; } @@ -236,9 +240,9 @@ async function submit() { - diff --git a/resources/js/pages/admin/manage/cuttings/form/QuickCreateRawMaterialModal.vue b/resources/js/pages/admin/manage/cuttings/form/QuickCreateRawMaterialModal.vue index 54b1493..1a72e80 100644 --- a/resources/js/pages/admin/manage/cuttings/form/QuickCreateRawMaterialModal.vue +++ b/resources/js/pages/admin/manage/cuttings/form/QuickCreateRawMaterialModal.vue @@ -34,6 +34,10 @@ const emit = defineEmits<{ const loading = ref(false); const selectPortalTarget = ref(); +const isUploading = computed(() => + prices.value.some((p) => p.media.pendingUploads > 0) +); + function createClientId(): string { return `price-${crypto.randomUUID()}`; } @@ -226,9 +230,9 @@ async function submit() { - diff --git a/resources/js/pages/admin/manage/orders/form/OrderPosCheckoutSection.vue b/resources/js/pages/admin/manage/orders/form/OrderPosCheckoutSection.vue index f74dfae..a2875a8 100644 --- a/resources/js/pages/admin/manage/orders/form/OrderPosCheckoutSection.vue +++ b/resources/js/pages/admin/manage/orders/form/OrderPosCheckoutSection.vue @@ -40,6 +40,8 @@ defineProps<{ const printAfterSave = defineModel('printAfterSave', { required: true }); const selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true }); const photoState = defineModel('photoState', { required: true }); + +const isUploading = computed(() => photoState.value.pendingUploads > 0); diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue index 7d9e8ad..8bb4470 100644 --- a/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue @@ -38,6 +38,8 @@ const stock = ref('0'); const media = ref(createMediaUploadState()); const loading = ref(false); +const isUploading = computed(() => media.value.pendingUploads > 0); + watch(open, (isOpen) => { if (isOpen) { variant.value = ''; @@ -76,8 +78,8 @@ async function submit() { formData.append('price', String(Number.parseInt(parseRupiah(price.value), 10) || 0)); formData.append('stock', String(parseStockValue(stock.value))); - media.value.newFiles.forEach((file) => { - formData.append('photos[]', file); + media.value.newFileS3Keys.forEach((key) => { + formData.append('s3_keys[]', key); }); const response = await fetch(storeNewVariantRoute.url(), { @@ -163,8 +165,8 @@ async function submit() { - diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosCheckoutSection.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosCheckoutSection.vue index 6f82c53..b4e9bc5 100644 --- a/resources/js/pages/admin/manage/purchases/form/PurchasePosCheckoutSection.vue +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosCheckoutSection.vue @@ -29,6 +29,8 @@ defineProps<{ }>(); const photoState = defineModel('photoState', { required: true }); + +const isUploading = computed(() => photoState.value.pendingUploads > 0); diff --git a/resources/js/pages/admin/master/products/form/ProductForm.vue b/resources/js/pages/admin/master/products/form/ProductForm.vue index 50e3b91..0790662 100644 --- a/resources/js/pages/admin/master/products/form/ProductForm.vue +++ b/resources/js/pages/admin/master/products/form/ProductForm.vue @@ -33,6 +33,10 @@ function createClientId(): string { return `variant-${crypto.randomUUID()}`; } +const isUploading = computed(() => + variants.value.some((v) => v.media.pendingUploads > 0) +); + const { items: variants, addItem: addVariant, @@ -232,9 +236,9 @@ function submit() { Tambah Varian - diff --git a/resources/js/pages/admin/master/raw-materials/form/RawMaterialForm.vue b/resources/js/pages/admin/master/raw-materials/form/RawMaterialForm.vue index 5058bce..02d6f23 100644 --- a/resources/js/pages/admin/master/raw-materials/form/RawMaterialForm.vue +++ b/resources/js/pages/admin/master/raw-materials/form/RawMaterialForm.vue @@ -46,6 +46,10 @@ function createClientId(): string { return `price-${crypto.randomUUID()}`; } +const isUploading = computed(() => + prices.value.some((p) => p.media.pendingUploads > 0) +); + const { items: prices, removeItem: removePrice, @@ -218,9 +222,9 @@ function submit() { Tambah Varian - diff --git a/resources/js/pages/admin/system/setting/form/HomepageSection.vue b/resources/js/pages/admin/system/setting/form/HomepageSection.vue index edf1a73..5f1bcbf 100644 --- a/resources/js/pages/admin/system/setting/form/HomepageSection.vue +++ b/resources/js/pages/admin/system/setting/form/HomepageSection.vue @@ -34,21 +34,27 @@ const galleryState = ref( ); const isSubmitting = ref(false); +const isUploading = computed(() => + heroImage.value.pendingUploads > 0 || + aboutImage.value.pendingUploads > 0 || + galleryState.value.pendingUploads > 0 +); + function submit() { isSubmitting.value = true; const formData = new FormData(); - heroImage.value.newFiles.forEach((file) => { - formData.append('hero_image', file); + heroImage.value.newFileS3Keys.forEach((key) => { + formData.append('hero_image_s3_key', key); }); - aboutImage.value.newFiles.forEach((file) => { - formData.append('about_image', file); + aboutImage.value.newFileS3Keys.forEach((key) => { + formData.append('about_image_s3_key', key); }); - galleryState.value.newFiles.forEach((file) => { - formData.append('gallery_images[]', file); + galleryState.value.newFileS3Keys.forEach((key) => { + formData.append('gallery_s3_keys[]', key); }); galleryState.value.removeIds.forEach((id) => { @@ -102,9 +108,9 @@ function submit() {
-
diff --git a/resources/js/pages/admin/system/setting/form/SystemSection.vue b/resources/js/pages/admin/system/setting/form/SystemSection.vue index a3a6238..e4193a1 100644 --- a/resources/js/pages/admin/system/setting/form/SystemSection.vue +++ b/resources/js/pages/admin/system/setting/form/SystemSection.vue @@ -32,6 +32,12 @@ const logoState = ref(createMediaUploadState()); const faviconState = ref(createMediaUploadState()); const loginCoverState = ref(createMediaUploadState()); +const isUploading = computed(() => + logoState.value.pendingUploads > 0 || + faviconState.value.pendingUploads > 0 || + loginCoverState.value.pendingUploads > 0 +); + function buildFormData(): FormData { const formData = new FormData(); @@ -41,16 +47,16 @@ function buildFormData(): FormData { formData.append('phone', form.phone); formData.append('address', form.address); - logoState.value.newFiles.forEach((file) => { - formData.append('logo', file); + logoState.value.newFileS3Keys.forEach((key) => { + formData.append('logo_s3_key', key); }); - faviconState.value.newFiles.forEach((file) => { - formData.append('favicon', file); + faviconState.value.newFileS3Keys.forEach((key) => { + formData.append('favicon_s3_key', key); }); - loginCoverState.value.newFiles.forEach((file) => { - formData.append('login_cover', file); + loginCoverState.value.newFileS3Keys.forEach((key) => { + formData.append('login_cover_s3_key', key); }); return formData; @@ -138,9 +144,9 @@ function submit() {
-
diff --git a/resources/js/types/media.ts b/resources/js/types/media.ts index 1619613..09b2c59 100644 --- a/resources/js/types/media.ts +++ b/resources/js/types/media.ts @@ -7,14 +7,18 @@ export type MediaItem = { export type MediaUploadState = { existing: MediaItem[]; newFiles: File[]; + newFileS3Keys: string[]; removeIds: number[]; + pendingUploads: number; }; export function createMediaUploadState(existing: MediaItem[] = []): MediaUploadState { return { existing: [...existing], newFiles: [], + newFileS3Keys: [], removeIds: [], + pendingUploads: 0, }; } @@ -27,8 +31,8 @@ export function appendMediaToFormData( prefix: string, state: MediaUploadState, ): void { - state.newFiles.forEach((file) => { - formData.append(`${prefix}[images][]`, file); + state.newFileS3Keys.forEach((key) => { + formData.append(`${prefix}[s3_keys][]`, key); }); state.removeIds.forEach((id) => { @@ -51,8 +55,8 @@ export function appendPhotosToFormData( } export function appendRootPhotosToFormData(formData: FormData, state: MediaUploadState): void { - state.newFiles.forEach((file) => { - formData.append('photos[]', file); + state.newFileS3Keys.forEach((key) => { + formData.append('s3_keys[]', key); }); state.removeIds.forEach((id) => { diff --git a/routes/web.php b/routes/web.php index 11f0461..68244d8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,7 @@ use App\Http\Controllers\Admin\Master\ProductController; use App\Http\Controllers\Admin\Master\RawMaterialController; use App\Http\Controllers\Admin\Master\SupplierController; +use App\Http\Controllers\Admin\Media\PresignedUploadController; use App\Http\Controllers\Admin\NotificationController; use App\Http\Controllers\Admin\System\ActivityLogController; use App\Http\Controllers\Admin\System\RoleController; @@ -49,6 +50,9 @@ Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () { Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout'); + // Media presigned upload + Route::post('/admin/media/presign', [PresignedUploadController::class, 'presign'])->name('media.presign'); + // Push Notifications Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store'); Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');