feat: implement presigned upload functionality for S3 in PresignedUploadController; update media upload handling across various components to support S3 keys and improve user experience
Some checks failed
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-07-02 14:28:51 +07:00
parent deabe6b043
commit d02af40836
35 changed files with 447 additions and 87 deletions

View File

@ -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(),
]);
}
}

View File

@ -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',
];
}
}

View File

@ -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'],
];

View File

@ -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',
];
}
}

View File

@ -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',
];
}
}

View File

@ -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',
];

View File

@ -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;

View File

@ -418,6 +418,7 @@ private function syncPhotos(CashTransaction $transaction, array $validated): voi
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}

View File

@ -180,6 +180,7 @@ private function syncPhotos(Expense $expense, array $validated): void
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}

View File

@ -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

View File

@ -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();

View File

@ -821,6 +821,7 @@ private function syncPhotos(Order $order, array $validated): void
self::MAX_PHOTOS,
required: $requiresPhoto,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}

View File

@ -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,
);
}

View File

@ -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,
);
}

View File

@ -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,
);
}

View File

@ -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 = [

View File

@ -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,
);
}
}

View File

@ -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');
}

View File

@ -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;

View 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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) => {

View File

@ -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');