feat: add media library configuration and file upload components

- Added `media-library.php` configuration file for managing media uploads and conversions.
- Updated `filesystems.php` to set default visibility for AWS S3 storage.
- Implemented `FileUpload` component for handling file uploads with preview and error handling.
- Created `ImagePreviewModal` component for displaying image previews in a modal.
- Introduced `Attachment` UI components for better file attachment handling.
- Added `useFileUpload` hook to manage file upload state and logic.
- Implemented utility functions for uploading files to S3 with presigned URLs.
- Created API route for generating presigned URLs for file uploads.
This commit is contained in:
Yoga Pangestu 2026-07-30 10:16:49 +07:00
parent dded6058dc
commit a138ffe63c
20 changed files with 1948 additions and 23 deletions

31
app/Enums/Modules.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum Modules: string
{
case PRODUCT = 'product';
case EMPLOYEE = 'employee';
case ORDER = 'order';
case PURCHASE = 'purchase';
case RAW_MATERIAL = 'raw_material';
case EXPENSE = 'expense';
case USER = 'user';
case HOMEPAGE = 'homepage';
case OTHER = 'other';
public function label(): string
{
return match ($this) {
self::PRODUCT => 'Produk',
self::EMPLOYEE => 'Karyawan',
self::ORDER => 'Pesanan',
self::PURCHASE => 'Pembelian',
self::RAW_MATERIAL => 'Bahan Baku',
self::EXPENSE => 'Pengeluaran',
self::USER => 'Pengguna',
self::HOMEPAGE => 'Homepage',
self::OTHER => 'Lainnya',
};
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\PresignedUrlRequest;
use App\Services\S3PresignedService;
use Illuminate\Http\JsonResponse;
class PresignedUrlController extends Controller
{
public function __construct(
private S3PresignedService $service
) {}
public function store(PresignedUrlRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->service->createUploadUrl(
$validated['file_name'],
$validated['mime_type'],
$validated['folder'] ?? null,
);
return response()->json($result);
}
}

View File

@ -27,6 +27,9 @@ public function rules(): array
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:100'],
'receipt_key' => ['nullable', 'string', 'max:500'],
'file_size' => ['nullable', 'integer', 'min:1'],
'file_mime_type' => ['nullable', 'string', 'in:image/jpeg,image/png,image/webp,image/gif'],
];
}
@ -35,6 +38,9 @@ public function attributes(): array
return [
'amount' => 'jumlah',
'description' => 'keterangan',
'receipt_key' => 'bukti',
'file_size' => 'ukuran file',
'file_mime_type' => 'tipe file',
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class PresignedUrlRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'file_name' => ['required', 'string', 'max:255'],
'mime_type' => ['required', 'string', 'in:image/jpeg,image/png,image/webp,image/gif'],
'folder' => ['nullable', 'string', 'max:100'],
];
}
public function attributes(): array
{
return [
'file_name' => 'nama file',
'mime_type' => 'tipe file',
'folder' => 'folder',
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Media\PathGenerators;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
class CustomPathGenerator implements PathGenerator
{
public function getPath(Media $media): string
{
$model = $media->model;
if (method_exists($model, 'getMediaPath')) {
return $model->getMediaPath($media).'/';
}
return $this->defaultPath($media);
}
public function getPathForConversions(Media $media): string
{
return $this->getPath($media).'conversions/';
}
public function getPathForResponsiveImages(Media $media): string
{
return $this->getPath($media).'responsive/';
}
private function defaultPath(Media $media): string
{
$module = strtolower(class_basename($media->model_type));
$date = $media->created_at->format('Y/m/d');
$id = $media->id;
return "{$module}/{$date}/{$id}/";
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class S3PresignedService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
public function createUploadUrl(string $fileName, string $mimeType, ?string $folder = null): array
{
if (! in_array($mimeType, self::ALLOWED_MIME_TYPES)) {
abort(422, 'Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.');
}
$disk = Storage::disk('s3');
$client = $disk->getClient();
$bucket = config('filesystems.disks.s3.bucket');
$key = $this->generateKey($fileName, $folder);
$uuid = pathinfo($key, PATHINFO_FILENAME);
$command = $client->getCommand('PutObject', [
'Bucket' => $bucket,
'Key' => $key,
'ContentType' => $mimeType,
'ACL' => 'public-read',
]);
$presignedUrl = (string) $client->createPresignedRequest($command, '+15 minutes')->getUri();
return [
'upload_url' => $presignedUrl,
'key' => $key,
'uuid' => $uuid,
];
}
public function getTemporaryUrl(string $key, int $minutes = 60): string
{
$disk = Storage::disk('s3');
$client = $disk->getClient();
$bucket = config('filesystems.disks.s3.bucket');
$command = $client->getCommand('GetObject', [
'Bucket' => $bucket,
'Key' => $key,
]);
return (string) $client->createPresignedRequest($command, "+{$minutes} minutes")->getUri();
}
public function deleteFile(string $key): bool
{
return Storage::disk('s3')->delete($key);
}
private function generateKey(string $fileName, ?string $folder): string
{
$date = now()->format('Y/m/d');
$uuid = Str::uuid();
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$safeName = Str::slug(pathinfo($fileName, PATHINFO_FILENAME));
$prefix = $folder ? "{$folder}/{$date}" : $date;
return "{$prefix}/{$uuid}/{$safeName}.{$extension}";
}
}

View File

@ -11,6 +11,7 @@
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

View File

@ -16,6 +16,8 @@
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"league/flysystem-aws-s3-v3": "^3.0",
"spatie/laravel-medialibrary": "^11.23",
"spatie/laravel-permission": "^8.3",
"spatie/laravel-sluggable": "^4.0"
},

801
composer.lock generated
View File

@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bf685f2e2b1d13f4dfba5042cdd5af8b",
"content-hash": "e3f219d587b4456806b7c536eab8b82f",
"packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.389.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "784e0fb95e752e55c4654b5800b900c78f6d3990"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/784e0fb95e752e55c4654b5800b900c78f6d3990",
"reference": "784e0fb95e752e55c4654b5800b900c78f6d3990",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.389.2"
},
"time": "2026-07-28T18:10:25+00:00"
},
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
@ -189,6 +340,83 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
@ -2217,6 +2445,61 @@
},
"time": "2026-07-06T14:42:07+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
},
"time": "2026-07-01T23:25:49+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.31.0",
@ -2504,6 +2787,84 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@ -2607,6 +2968,72 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
},
"time": "2026-07-06T18:56:19+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.13.1",
@ -4122,6 +4549,246 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "spatie/image",
"version": "3.9.5",
"source": {
"type": "git",
"url": "https://github.com/spatie/image.git",
"reference": "7ac0b9dab1100ddfc0c98afd12720f5b9fc0cd65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image/zipball/7ac0b9dab1100ddfc0c98afd12720f5b9fc0cd65",
"reference": "7ac0b9dab1100ddfc0c98afd12720f5b9fc0cd65",
"shasum": ""
},
"require": {
"ext-exif": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.2",
"spatie/image-optimizer": "^1.7.5",
"spatie/temporary-directory": "^2.2",
"symfony/process": "^6.4|^7.0|^8.0"
},
"require-dev": {
"ext-ffi": "*",
"ext-gd": "*",
"ext-imagick": "*",
"jcupitt/vips": "^2.4",
"laravel/sail": "^1.34",
"pestphp/pest": "^3.0|^4.0",
"phpstan/phpstan": "^1.10.50",
"spatie/pest-plugin-snapshots": "^2.1",
"spatie/pixelmatch-php": "^1.0",
"spatie/ray": "^1.40.1",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Manipulate images with an expressive API",
"homepage": "https://github.com/spatie/image",
"keywords": [
"image",
"spatie"
],
"support": {
"source": "https://github.com/spatie/image/tree/3.9.5"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-06-19T07:40:17+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.4|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
},
"time": "2026-06-29T08:28:30+00:00"
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.23.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-medialibrary.git",
"reference": "b578814b8c81a68c8ec9c9005246df5d417fe0f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/b578814b8c81a68c8ec9c9005246df5d417fe0f9",
"reference": "b578814b8c81a68c8ec9c9005246df5d417fe0f9",
"shasum": ""
},
"require": {
"composer/semver": "^3.4",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-json": "*",
"illuminate/bus": "^10.2|^11.0|^12.0|^13.0",
"illuminate/conditionable": "^10.2|^11.0|^12.0|^13.0",
"illuminate/console": "^10.2|^11.0|^12.0|^13.0",
"illuminate/database": "^10.2|^11.0|^12.0|^13.0",
"illuminate/pipeline": "^10.2|^11.0|^12.0|^13.0",
"illuminate/support": "^10.2|^11.0|^12.0|^13.0",
"maennchen/zipstream-php": "^3.1",
"php": "^8.2",
"spatie/image": "^3.3.2",
"spatie/laravel-package-tools": "^1.16.1",
"spatie/temporary-directory": "^2.2",
"symfony/console": "^6.4.1|^7.0|^8.0"
},
"conflict": {
"php-ffmpeg/php-ffmpeg": "<0.6.1"
},
"require-dev": {
"aws/aws-sdk-php": "^3.293.10",
"ext-imagick": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.8.1",
"larastan/larastan": "^2.7|^3.0",
"league/flysystem-aws-s3-v3": "^3.22",
"mockery/mockery": "^1.6.7",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"pestphp/pest": "^2.36|^3.0|^4.0",
"phpstan/extension-installer": "^1.3.1",
"spatie/laravel-ray": "^1.33",
"spatie/pdf-to-image": "^2.2|^3.0",
"spatie/pest-expectations": "^1.13",
"spatie/pest-plugin-snapshots": "^2.1"
},
"suggest": {
"league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage",
"php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails",
"spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\MediaLibrary\\MediaLibraryServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\MediaLibrary\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Associate files with Eloquent models",
"homepage": "https://github.com/spatie/laravel-medialibrary",
"keywords": [
"cms",
"conversion",
"downloads",
"images",
"laravel",
"laravel-medialibrary",
"media",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-medialibrary/issues",
"source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.3"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-07-22T07:06:25+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.1",
@ -4349,6 +5016,67 @@
],
"time": "2026-07-28T13:16:49+00:00"
},
{
"name": "spatie/temporary-directory",
"version": "2.4.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/temporary-directory.git",
"reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9",
"reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\TemporaryDirectory\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily create, use and destroy temporary directories",
"homepage": "https://github.com/spatie/temporary-directory",
"keywords": [
"php",
"spatie",
"temporary-directory"
],
"support": {
"issues": "https://github.com/spatie/temporary-directory/issues",
"source": "https://github.com/spatie/temporary-directory/tree/2.4.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-06-22T07:55:44+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.3.0",
@ -5093,6 +5821,77 @@
],
"time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/filesystem",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/finder",
"version": "v8.1.1",

View File

@ -58,6 +58,7 @@
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
'visibility' => 'public',
],
],

363
config/media-library.php Normal file
View File

@ -0,0 +1,363 @@
<?php
use App\Media\PathGenerators\CustomPathGenerator;
use Spatie\ImageOptimizer\Optimizers\Avifenc;
use Spatie\ImageOptimizer\Optimizers\Cwebp;
use Spatie\ImageOptimizer\Optimizers\Gifsicle;
use Spatie\ImageOptimizer\Optimizers\Jpegoptim;
use Spatie\ImageOptimizer\Optimizers\Optipng;
use Spatie\ImageOptimizer\Optimizers\Pngquant;
use Spatie\ImageOptimizer\Optimizers\Svgo;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Avif;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Image;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Svg;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Video;
use Spatie\MediaLibrary\Conversions\ImageGenerators\Webp;
use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
use Spatie\MediaLibrary\Downloaders\DefaultDownloader;
use Spatie\MediaLibrary\MediaCollections\FileAdder;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver;
use Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob;
use Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred;
use Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator;
use Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer;
use Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover;
use Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator;
use Spatie\MediaLibraryPro\Models\TemporaryUpload;
return [
/*
* The disk on which to store added files and derived images by default. Choose
* one or more of the disks you've configured in config/filesystems.php.
*/
'disk_name' => env('MEDIA_DISK', 's3'),
/*
* The disk on which to store conversions (thumbnails, etc.) and responsive images
* when no disk is specified explicitly on the media collection or via
* `storingConversionsOnDisk()`. When left null, conversions are stored on the
* same disk as the original media preserving previous behavior.
*
* This is useful when the originals live on a remote disk (e.g. S3) but the
* generated derivatives should stay local for faster access and lower egress.
*/
'conversions_disk_name' => env('MEDIA_CONVERSIONS_DISK', null),
/*
* The maximum file size of an item in bytes.
* Adding a larger file will result in an exception.
*/
'max_file_size' => 1024 * 1024 * 10, // 10MB
/*
* Uploads whose file name contains any of these extensions will be rejected.
* The check looks at every extension in the file name, so a file named
* `malicious.php.jpg` is blocked as well. Matching is case-insensitive
* and a leading dot is optional.
*
* The default list lives on the `FileAdder` class so the shipped config
* and the in-code fallback (used when the config is cached without the
* key) cannot drift. Override here to extend or shrink it.
*/
'disallowed_extensions' => FileAdder::$defaultDisallowedExtensions,
/*
* When this is set to an array of extensions, only uploads whose final
* extension is in the list will be accepted. Matching is case-insensitive
* and a leading dot is optional. The `disallowed_extensions` list above
* is still enforced, so an interior dangerous segment (such as the `php`
* in `shell.php.jpg`) is rejected even if the final extension is allowed.
* Leave `null` to disable allowlisting.
*/
'allowed_extensions' => null,
/*
* This queue connection will be used to generate derived and responsive images.
* Leave empty to use the default queue connection.
*/
'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'),
/*
* This queue will be used to generate derived and responsive images.
* Leave empty to use the default queue.
*/
'queue_name' => env('MEDIA_QUEUE', ''),
/*
* By default all conversions will be performed on a queue.
*/
'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true),
/*
* Should database transactions be run after database commits?
*/
'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true),
/*
* The fully qualified class name of the media model.
*/
'media_model' => Media::class,
/*
* The fully qualified class name of the media observer.
*/
'media_observer' => MediaObserver::class,
/*
* When enabled, media collections will be serialised using the default
* laravel model serialization behaviour.
*
* Keep this option disabled if using Media Library Pro components (https://medialibrary.pro)
*/
'use_default_collection_serialization' => false,
/*
* The fully qualified class name of the model used for temporary uploads.
*
* This model is only used in Media Library Pro (https://medialibrary.pro)
*/
'temporary_upload_model' => TemporaryUpload::class,
/*
* When enabled, Media Library Pro will only process temporary uploads that were uploaded
* in the same session. You can opt to disable this for stateless usage of
* the pro components.
*/
'enable_temporary_uploads_session_affinity' => true,
/*
* When enabled, Media Library pro will generate thumbnails for uploaded file.
*/
'generate_thumbnails_for_temporary_uploads' => true,
/*
* This is the class that is responsible for naming generated files.
*/
'file_namer' => DefaultFileNamer::class,
/*
* The class that contains the strategy for determining a media file's path.
*/
'path_generator' => CustomPathGenerator::class,
/*
* The class that contains the strategy for determining how to remove files.
*/
'file_remover_class' => DefaultFileRemover::class,
/*
* Here you can specify which path generator should be used for the given class.
*/
'custom_path_generators' => [
// Model::class => PathGenerator::class
// or
// 'model_morph_alias' => PathGenerator::class
],
/*
* When urls to files get generated, this class will be called. Use the default
* if your files are stored locally above the site root or on s3.
*/
'url_generator' => DefaultUrlGenerator::class,
/*
* Moves media on updating to keep path consistent. Enable it only with a custom
* PathGenerator that uses, for example, the media UUID.
*/
'moves_media_on_update' => false,
/*
* Whether to activate versioning when urls to files get generated.
* When activated, this attaches a ?v=xx query string to the URL.
*/
'version_urls' => false,
/*
* The media library will try to optimize all converted images by removing
* metadata and applying a little bit of compression. These are
* the optimizers that will be used by default.
*/
'image_optimizers' => [
Jpegoptim::class => [
'-m85', // set maximum quality to 85%
'--force', // ensure that progressive generation is always done also if a little bigger
'--strip-all', // this strips out all text information such as comments and EXIF data
'--all-progressive', // this will make sure the resulting image is a progressive one
],
Pngquant::class => [
'--force', // required parameter for this package
],
Optipng::class => [
'-i0', // this will result in a non-interlaced, progressive scanned image
'-o2', // this set the optimization level to two (multiple IDAT compression trials)
'-quiet', // required parameter for this package
],
Svgo::class => [
'--disable=cleanupIDs', // disabling because it is known to cause troubles
],
Gifsicle::class => [
'-b', // required parameter for this package
'-O3', // this produces the slowest but best results
],
Cwebp::class => [
'-m 6', // for the slowest compression method in order to get the best compression.
'-pass 10', // for maximizing the amount of analysis pass.
'-mt', // multithreading for some speed improvements.
'-q 90', // quality factor that brings the least noticeable changes.
],
Avifenc::class => [
'-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63).
'-j all', // number of jobs (worker threads, "all" uses all available cores).
'--min 0', // min quantizer for color (0-63).
'--max 63', // max quantizer for color (0-63).
'--minalpha 0', // min quantizer for alpha (0-63).
'--maxalpha 63', // max quantizer for alpha (0-63).
'-a end-usage=q', // rate control mode set to Constant Quality mode.
'-a tune=ssim', // SSIM as tune the encoder for distortion metric.
],
],
/*
* These generators will be used to create an image of media files.
*/
'image_generators' => [
Image::class,
Webp::class,
Avif::class,
Pdf::class,
Svg::class,
Video::class,
],
/*
* The path where to store temporary files while performing image conversions.
* If set to null, storage_path('media-library/temp') will be used.
*/
'temporary_directory_path' => storage_path('app/media-library/temp'),
/*
* The engine that should perform the image conversions.
* Should be either `gd`, `imagick` or `vips`.
*/
'image_driver' => env('IMAGE_DRIVER', 'gd'),
/*
* FFMPEG & FFProbe binaries paths, only used if you try to generate video
* thumbnails and have installed the php-ffmpeg/php-ffmpeg composer
* dependency.
*/
'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'),
'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'),
/*
* The timeout (in seconds) that will be used when generating video
* thumbnails via FFMPEG.
*/
'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900),
/*
* The number of threads that FFMPEG should use. 0 means that FFMPEG
* may decide itself.
*/
'ffmpeg_threads' => env('FFMPEG_THREADS', 0),
/*
* Here you can override the class names of the jobs used by this package. Make sure
* your custom jobs extend the ones provided by the package.
*/
'jobs' => [
'perform_conversions' => PerformConversionsJob::class,
'generate_responsive_images' => GenerateResponsiveImagesJob::class,
],
/*
* When using the addMediaFromUrl method you may want to replace the default downloader.
* This is particularly useful when the url of the image is behind a firewall and
* need to add additional flags, possibly using curl.
*/
'media_downloader' => DefaultDownloader::class,
/*
* When using the addMediaFromUrl method the SSL is verified by default.
* This is option disables SSL verification when downloading remote media.
* Please note that this is a security risk and should only be false in a local environment.
*/
'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true),
/*
* The default lifetime in minutes for temporary urls.
* This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item.
*/
'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5),
'remote' => [
/*
* Any extra headers that should be included when uploading media to
* a remote disk. Even though supported headers may vary between
* different drivers, a sensible default has been provided.
*
* Supported by S3: CacheControl, Expires, StorageClass,
* ServerSideEncryption, Metadata, ACL, ContentEncoding
*/
'extra_headers' => [
'CacheControl' => 'max-age=604800',
],
],
'responsive_images' => [
/*
* This class is responsible for calculating the target widths of the responsive
* images. By default we optimize for filesize and create variations that each are 30%
* smaller than the previous one. More info in the documentation.
*
* https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images
*/
'width_calculator' => FileSizeOptimizedWidthCalculator::class,
/*
* By default rendering media to a responsive image will add some javascript and a tiny placeholder.
* This ensures that the browser can already determine the correct layout.
* When disabled, no tiny placeholder is generated.
*/
'use_tiny_placeholders' => true,
/*
* This class will generate the tiny placeholder used for progressive image loading. By default
* the media library will use a tiny blurred jpg image.
*/
'tiny_placeholder_generator' => Blurred::class,
],
/*
* When enabling this option, a route will be registered that will enable
* the Media Library Pro Vue and React components to move uploaded files
* in a S3 bucket to their right place.
*/
'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false),
/*
* When converting Media instances to response the media library will add
* a `loading` attribute to the `img` tag. Here you can set the default
* value of that attribute.
*
* Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction.
*
* More info: https://css-tricks.com/native-lazy-loading/
*/
'default_loading_attribute_value' => null,
/*
* You can specify a prefix for that is used for storing all media.
* If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory.
*/
'prefix' => env('MEDIA_PREFIX', ''),
/*
* When forcing lazy loading, media will be loaded even if you don't eager load media and you have
* disabled lazy loading globally in the service provider.
*/
'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true),
];

View File

@ -1,7 +1,7 @@
import { createInertiaApp } from '@inertiajs/react';
import { FlashToast } from '@/components/flash-toast';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { FlashToast } from '@/components/flash-toast';
import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';

View File

@ -1,3 +1,24 @@
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { Link } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
@ -22,27 +43,6 @@ import {
Users,
Wallet,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
type NavMenuItem = { title: string; href: string; icon: LucideIcon };

View File

@ -0,0 +1,166 @@
import { FileImage, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from '@/components/ui/attachment';
import { uploadFile, UploadError } from '@/lib/upload';
type FileUploadProps = {
value: string | null;
onChange: (key: string | null) => void;
folder?: string;
accept?: string;
onUploadingChange?: (uploading: boolean) => void;
existingUrl?: string | null;
onFileMeta?: (meta: { size: number; type: string } | null) => void;
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
const [preview, setPreview] = useState<string | null>(null);
useEffect(() => {
onUploadingChange?.(uploading);
}, [uploading, onUploadingChange]);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) {
return;
}
setError(null);
setUploading(true);
setFileName(file.name);
setFileSize(file.size);
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
try {
const key = await uploadFile(file, folder);
onChange(key);
onFileMeta?.({ size: file.size, type: file.type });
} catch (err) {
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.';
setError(message);
setPreview(null);
setFileName(null);
setFileSize(null);
onFileMeta?.(null);
} finally {
setUploading(false);
if (inputRef.current) {
inputRef.current.value = '';
}
}
}
function handleRemove(e: React.MouseEvent) {
e.stopPropagation();
onChange(null);
setFileName(null);
setFileSize(null);
setPreview(null);
setError(null);
onFileMeta?.(null);
if (inputRef.current) {
inputRef.current.value = '';
}
}
const state = uploading ? 'uploading' : error ? 'error' : value ? 'done' : 'idle';
return (
<>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
id="file-upload"
/>
<Attachment state={state} orientation="horizontal" className='w-full'>
<AttachmentTrigger
onClick={() => inputRef.current?.click()}
aria-label={value ? 'Ganti file' : 'Pilih file untuk diunggah'}
/>
<AttachmentMedia variant={preview || existingUrl ? 'image' : 'icon'}>
{preview ? (
<img src={preview} alt={fileName ?? 'Preview'} />
) : existingUrl && value ? (
<img src={existingUrl} alt="Bukti" />
) : uploading ? (
<Upload className="animate-pulse" />
) : (
<FileImage />
)}
</AttachmentMedia>
<AttachmentContent>
{value ? (
<>
<AttachmentTitle>{fileName}</AttachmentTitle>
<AttachmentDescription>
{fileSize ? formatFileSize(fileSize) : 'Terupload'}
</AttachmentDescription>
</>
) : uploading ? (
<>
<AttachmentTitle>Mengunggah...</AttachmentTitle>
<AttachmentDescription>Memproses file</AttachmentDescription>
</>
) : error ? (
<>
<AttachmentTitle>Gagal</AttachmentTitle>
<AttachmentDescription>{error}</AttachmentDescription>
</>
) : (
<>
<AttachmentTitle>Unggah Bukti</AttachmentTitle>
<AttachmentDescription>Opsional · JPG, PNG, WebP, GIF · Maks 10MB</AttachmentDescription>
</>
)}
</AttachmentContent>
{value && (
<AttachmentActions>
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
<X />
</AttachmentAction>
</AttachmentActions>
)}
</Attachment>
</>
);
}

View File

@ -0,0 +1,30 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
type ImagePreviewModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
src: string | null;
title?: string;
alt?: string;
};
export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Preview' }: ImagePreviewModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton>
{title && (
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
)}
{src && (
<img
src={src}
alt={alt}
className="w-full rounded-lg object-contain max-h-[80vh]"
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -22,6 +22,7 @@ function formatRupiah(value: number): string {
function parseRupiah(value: string): number {
const cleaned = value.replace(/[^0-9]/g, '');
return cleaned === '' ? 0 : parseInt(cleaned, 10);
}
@ -45,6 +46,7 @@ export function RupiahInput({
if (min !== undefined && raw < min) {
clamped = min;
}
if (max !== undefined && raw > max) {
clamped = max;
}

View File

@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}

View File

@ -0,0 +1,59 @@
import { useCallback, useState } from 'react';
import { uploadFile, UploadError } from '@/lib/upload';
type UploadState = {
uploading: boolean;
progress: number;
error: string | null;
key: string | null;
preview: string | null;
};
export function useFileUpload() {
const [state, setState] = useState<UploadState>({
uploading: false,
progress: 0,
error: null,
key: null,
preview: null,
});
const upload = useCallback(async (file: File, folder?: string): Promise<string | null> => {
setState({ uploading: true, progress: 0, error: null, key: null, preview: null });
try {
const preview = URL.createObjectURL(file);
setState((prev) => ({ ...prev, preview, progress: 30 }));
const key = await uploadFile(file, folder);
setState((prev) => ({ ...prev, key, uploading: false, progress: 100 }));
return key;
} catch (err) {
const message = err instanceof UploadError ? err.message : 'Terjadi kesalahan saat mengunggah file.';
setState((prev) => ({ ...prev, error: message, uploading: false }));
return null;
}
}, []);
const reset = useCallback(() => {
setState({ uploading: false, progress: 0, error: null, key: null, preview: null });
}, []);
const setKey = useCallback((key: string | null) => {
setState((prev) => ({ ...prev, key }));
}, []);
const setPreview = useCallback((preview: string | null) => {
setState((prev) => ({ ...prev, preview }));
}, []);
return {
...state,
upload,
reset,
setKey,
setPreview,
};
}

View File

@ -0,0 +1,79 @@
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
export type PresignedUrlResponse = {
upload_url: string;
key: string;
uuid: string;
};
export class UploadError extends Error {
constructor(message: string) {
super(message);
this.name = 'UploadError';
}
}
function getSessionCookie(): string {
return document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? '';
}
export async function requestPresignedUrl(
fileName: string,
mimeType: string,
folder?: string,
): Promise<PresignedUrlResponse> {
const response = await fetch('/api/presigned-url', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()),
},
body: JSON.stringify({ file_name: fileName, mime_type: mimeType, folder }),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new UploadError(data?.message ?? 'Gagal membuat URL upload.');
}
return response.json();
}
export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
const response = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
if (!response.ok) {
throw new UploadError('Gagal mengunggah file ke storage.');
}
}
export async function uploadFile(file: File, folder?: string): Promise<string> {
if (!ALLOWED_TYPES.includes(file.type)) {
throw new UploadError('Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.');
}
if (file.size > MAX_FILE_SIZE) {
throw new UploadError('Ukuran file melebihi batas 10MB.');
}
const { upload_url, key } = await requestPresignedUrl(file.name, file.type, folder);
await uploadToS3(upload_url, file);
return key;
}
export function getTemporaryUrl(key: string, minutes = 60): string {
// This is a client-side helper - the actual presigned GET URL
// should be generated server-side via the Expense model accessor
return `/api/presigned-url/${encodeURIComponent(key)}?minutes=${minutes}`;
}

8
routes/api.php Normal file
View File

@ -0,0 +1,8 @@
<?php
use App\Http\Controllers\Api\PresignedUrlController;
use Illuminate\Support\Facades\Route;
Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
->middleware(['web', 'auth', 'verified'])
->name('presigned-url.store');