From a138ffe63c05ed11a76bbabfa3fffc0a183ade11 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 30 Jul 2026 10:16:49 +0700 Subject: [PATCH] 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. --- app/Enums/Modules.php | 31 + .../Api/PresignedUrlController.php | 28 + .../Requests/Admin/Finance/ExpenseRequest.php | 6 + app/Http/Requests/Api/PresignedUrlRequest.php | 31 + .../PathGenerators/CustomPathGenerator.php | 39 + app/Services/S3PresignedService.php | 76 ++ bootstrap/app.php | 1 + composer.json | 2 + composer.lock | 801 +++++++++++++++++- config/filesystems.php | 1 + config/media-library.php | 363 ++++++++ resources/js/app.tsx | 2 +- resources/js/components/app-sidebar.tsx | 42 +- resources/js/components/file-upload.tsx | 166 ++++ .../js/components/image-preview-modal.tsx | 30 + resources/js/components/rupiah-input.tsx | 2 + resources/js/components/ui/attachment.tsx | 204 +++++ resources/js/hooks/use-file-upload.ts | 59 ++ resources/js/lib/upload.ts | 79 ++ routes/api.php | 8 + 20 files changed, 1948 insertions(+), 23 deletions(-) create mode 100644 app/Enums/Modules.php create mode 100644 app/Http/Controllers/Api/PresignedUrlController.php create mode 100644 app/Http/Requests/Api/PresignedUrlRequest.php create mode 100644 app/Media/PathGenerators/CustomPathGenerator.php create mode 100644 app/Services/S3PresignedService.php create mode 100644 config/media-library.php create mode 100644 resources/js/components/file-upload.tsx create mode 100644 resources/js/components/image-preview-modal.tsx create mode 100644 resources/js/components/ui/attachment.tsx create mode 100644 resources/js/hooks/use-file-upload.ts create mode 100644 resources/js/lib/upload.ts create mode 100644 routes/api.php diff --git a/app/Enums/Modules.php b/app/Enums/Modules.php new file mode 100644 index 0000000..b1971d7 --- /dev/null +++ b/app/Enums/Modules.php @@ -0,0 +1,31 @@ + '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', + }; + } +} diff --git a/app/Http/Controllers/Api/PresignedUrlController.php b/app/Http/Controllers/Api/PresignedUrlController.php new file mode 100644 index 0000000..d23af73 --- /dev/null +++ b/app/Http/Controllers/Api/PresignedUrlController.php @@ -0,0 +1,28 @@ +validated(); + + $result = $this->service->createUploadUrl( + $validated['file_name'], + $validated['mime_type'], + $validated['folder'] ?? null, + ); + + return response()->json($result); + } +} diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php index 8154955..cf1ed4e 100644 --- a/app/Http/Requests/Admin/Finance/ExpenseRequest.php +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.php @@ -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', ]; } } diff --git a/app/Http/Requests/Api/PresignedUrlRequest.php b/app/Http/Requests/Api/PresignedUrlRequest.php new file mode 100644 index 0000000..f4eff9f --- /dev/null +++ b/app/Http/Requests/Api/PresignedUrlRequest.php @@ -0,0 +1,31 @@ + ['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', + ]; + } +} diff --git a/app/Media/PathGenerators/CustomPathGenerator.php b/app/Media/PathGenerators/CustomPathGenerator.php new file mode 100644 index 0000000..107e8eb --- /dev/null +++ b/app/Media/PathGenerators/CustomPathGenerator.php @@ -0,0 +1,39 @@ +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}/"; + } +} diff --git a/app/Services/S3PresignedService.php b/app/Services/S3PresignedService.php new file mode 100644 index 0000000..b0f3891 --- /dev/null +++ b/app/Services/S3PresignedService.php @@ -0,0 +1,76 @@ +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}"; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ba5cf4c..e95b56b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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', ) diff --git a/composer.json b/composer.json index 6645e7c..2c94b3c 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/composer.lock b/composer.lock index 563d633..592ed49 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/filesystems.php b/config/filesystems.php index 6cfdfb3..84e07d1 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -58,6 +58,7 @@ 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, 'report' => false, + 'visibility' => 'public', ], ], diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 0000000..9b21744 --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,363 @@ + 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), +]; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 67cdae8..66a17ca 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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'; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 4af569c..fa17837 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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 }; diff --git a/resources/js/components/file-upload.tsx b/resources/js/components/file-upload.tsx new file mode 100644 index 0000000..f4dddbf --- /dev/null +++ b/resources/js/components/file-upload.tsx @@ -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(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + const [fileSize, setFileSize] = useState(null); + const [preview, setPreview] = useState(null); + + useEffect(() => { + onUploadingChange?.(uploading); + }, [uploading, onUploadingChange]); + + async function handleFileChange(e: React.ChangeEvent) { + 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 ( + <> + + + + inputRef.current?.click()} + aria-label={value ? 'Ganti file' : 'Pilih file untuk diunggah'} + /> + + + {preview ? ( + {fileName + ) : existingUrl && value ? ( + Bukti + ) : uploading ? ( + + ) : ( + + )} + + + + {value ? ( + <> + {fileName} + + {fileSize ? formatFileSize(fileSize) : 'Terupload'} + + + ) : uploading ? ( + <> + Mengunggah... + Memproses file + + ) : error ? ( + <> + Gagal + {error} + + ) : ( + <> + Unggah Bukti + Opsional · JPG, PNG, WebP, GIF · Maks 10MB + + )} + + + {value && ( + + + + + + )} + + + ); +} diff --git a/resources/js/components/image-preview-modal.tsx b/resources/js/components/image-preview-modal.tsx new file mode 100644 index 0000000..4880d5f --- /dev/null +++ b/resources/js/components/image-preview-modal.tsx @@ -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 ( + + + {title && ( + + {title} + + )} + {src && ( + {alt} + )} + + + ); +} diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx index 13aa953..ead5912 100644 --- a/resources/js/components/rupiah-input.tsx +++ b/resources/js/components/rupiah-input.tsx @@ -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; } diff --git a/resources/js/components/ui/attachment.tsx b/resources/js/components/ui/attachment.tsx new file mode 100644 index 0000000..5bdd1ce --- /dev/null +++ b/resources/js/components/ui/attachment.tsx @@ -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 & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( +