feat: implement presigned upload functionality for S3 in PresignedUploadController; update media upload handling across various components to support S3 keys and improve user experience
This commit is contained in:
parent
deabe6b043
commit
d02af40836
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Media;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Aws\S3\S3Client;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PresignedUploadController extends Controller
|
||||
{
|
||||
public function presign(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi
|
||||
self::MAX_PHOTOS,
|
||||
required: true,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -180,6 +180,7 @@ private function syncPhotos(Expense $expense, array $validated): void
|
||||
self::MAX_PHOTOS,
|
||||
required: true,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -821,6 +821,7 @@ private function syncPhotos(Order $order, array $validated): void
|
||||
self::MAX_PHOTOS,
|
||||
required: $requiresPhoto,
|
||||
errorKey: 'photos',
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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<MediaUploadState>({
|
||||
default: () => createMediaUploadState(),
|
||||
});
|
||||
|
||||
// ─── Upload progress tracking ────────────────────────────────────────────────
|
||||
const uploadProgress = ref<Record<string, number>>({});
|
||||
const uploadErrors = ref<Record<string, string>>({});
|
||||
|
||||
// ─── Dialog ──────────────────────────────────────────────────────────────────
|
||||
const previewOpen = ref(false);
|
||||
const previewUrl = ref<string | null>(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<NormalizedEntry[]>(() => {
|
||||
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<NormalizedEntry[]>(() => {
|
||||
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(
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Upload progress overlay -->
|
||||
<div v-if="entry.uploadPercent !== null" class="upload-progress-overlay">
|
||||
<div class="upload-progress-bar">
|
||||
<div class="upload-progress-fill" :style="{ width: `${entry.uploadPercent}%` }" />
|
||||
</div>
|
||||
<span class="upload-progress-text">{{ entry.uploadPercent }}%</span>
|
||||
</div>
|
||||
<!-- Upload error overlay -->
|
||||
<div v-if="entry.uploadError" class="upload-error-overlay">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info + Remove -->
|
||||
@ -331,7 +399,9 @@ watch(
|
||||
<p class="preview-name" :title="entry.name">
|
||||
{{ entry.name }}
|
||||
</p>
|
||||
<p class="preview-size">{{ entry.sizeLabel }}</p>
|
||||
<p class="preview-size">
|
||||
{{ entry.uploadError ? 'Gagal' : entry.sizeLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
@ -571,6 +641,7 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-thumb img {
|
||||
@ -593,6 +664,51 @@ watch(
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Upload progress overlay */
|
||||
.upload-progress-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in oklch, var(--background) 80%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
width: 80%;
|
||||
height: 4px;
|
||||
background: var(--muted);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.upload-progress-text {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Upload error overlay */
|
||||
.upload-error-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in oklch, var(--destructive) 20%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Footer row: info + remove button */
|
||||
.preview-footer {
|
||||
display: flex;
|
||||
|
||||
59
resources/js/lib/s3-upload.ts
Normal file
59
resources/js/lib/s3-upload.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
export type PresignedUploadResponse = {
|
||||
key: string;
|
||||
url: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export async function getPresignedUploadUrl(
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
): Promise<PresignedUploadResponse> {
|
||||
return apiFetch<PresignedUploadResponse>('/admin/media/presign', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename, mime_type: mimeType }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFileToS3(
|
||||
presignedUrl: string,
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload gagal (HTTP ${xhr.status})`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload gagal. Periksa koneksi internet Anda.')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload dibatalkan.')));
|
||||
|
||||
xhr.open('PUT', presignedUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFileAndGetKey(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<string> {
|
||||
const { key, url } = await getPresignedUploadUrl(file.name, file.type);
|
||||
|
||||
await uploadFileToS3(url, file, onProgress);
|
||||
|
||||
return key;
|
||||
}
|
||||
@ -51,6 +51,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
||||
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
|
||||
);
|
||||
|
||||
const isUploading = computed(() => profilePhotoState.value.pendingUploads > 0);
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -62,8 +64,8 @@ function buildFormData(): FormData {
|
||||
formData.append('birth_date', form.birth_date);
|
||||
formData.append('address', form.address);
|
||||
|
||||
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) => {
|
||||
@ -175,9 +177,9 @@ function submit() {
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,6 +51,8 @@ const isWithdrawal = computed(() => currentMode.value === CashTransactionType.WI
|
||||
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
@ -166,9 +168,9 @@ const placeholder = computed(() =>
|
||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -41,6 +41,8 @@ const isEditing = computed(() => props.expense != null);
|
||||
|
||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
|
||||
const form = useForm({
|
||||
amount: '',
|
||||
description: '',
|
||||
@ -143,9 +145,9 @@ function submit() {
|
||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -70,6 +70,8 @@ const profilePhotoState = ref<MediaUploadState>(
|
||||
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() {
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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() {
|
||||
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" form="quick-create-product-form" :disabled="loading">
|
||||
<Button type="submit" form="quick-create-product-form" :disabled="loading || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -34,6 +34,10 @@ const emit = defineEmits<{
|
||||
const loading = ref(false);
|
||||
const selectPortalTarget = ref<HTMLElement>();
|
||||
|
||||
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() {
|
||||
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" form="quick-create-raw-material-form" :disabled="loading">
|
||||
<Button type="submit" form="quick-create-raw-material-form" :disabled="loading || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -40,6 +40,8 @@ defineProps<{
|
||||
const printAfterSave = defineModel<boolean>('printAfterSave', { required: true });
|
||||
const selectedPaperSize = defineModel<'58mm' | '80mm'>('selectedPaperSize', { required: true });
|
||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -126,8 +128,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
|
||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@ -38,6 +38,8 @@ const stock = ref('0');
|
||||
const media = ref<MediaUploadState>(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() {
|
||||
<Button type="button" variant="outline" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" :disabled="loading || !variant || !price">
|
||||
{{ loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
||||
<Button type="submit" :disabled="loading || !variant || !price || isUploading">
|
||||
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Tambah Varian' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -29,6 +29,8 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const photoState = defineModel<MediaUploadState>('photoState', { required: true });
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -75,8 +77,8 @@ const photoState = defineModel<MediaUploadState>('photoState', { required: true
|
||||
:errors="formErrors(form, 'photos')"
|
||||
/>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty">
|
||||
<Button type="submit" class="w-full" :disabled="form.processing || cartEmpty || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@ -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
|
||||
</Button>
|
||||
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
</Button>
|
||||
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -34,21 +34,27 @@ const galleryState = ref<MediaUploadState>(
|
||||
);
|
||||
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() {
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" :disabled="isSubmitting">
|
||||
<Button type="submit" :disabled="isSubmitting || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : isSubmitting ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -32,6 +32,12 @@ const logoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const faviconState = ref<MediaUploadState>(createMediaUploadState());
|
||||
const loginCoverState = ref<MediaUploadState>(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() {
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Button type="submit" :disabled="form.processing || isUploading">
|
||||
<Save class="size-4" />
|
||||
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
{{ isUploading ? 'Mengunggah...' : form.processing ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user