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:
parent
dded6058dc
commit
a138ffe63c
31
app/Enums/Modules.php
Normal file
31
app/Enums/Modules.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Controllers/Api/PresignedUrlController.php
Normal file
28
app/Http/Controllers/Api/PresignedUrlController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,9 @@ public function rules(): array
|
|||||||
return [
|
return [
|
||||||
'amount' => ['required', 'integer', 'min:1'],
|
'amount' => ['required', 'integer', 'min:1'],
|
||||||
'description' => ['required', 'string', 'max:100'],
|
'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 [
|
return [
|
||||||
'amount' => 'jumlah',
|
'amount' => 'jumlah',
|
||||||
'description' => 'keterangan',
|
'description' => 'keterangan',
|
||||||
|
'receipt_key' => 'bukti',
|
||||||
|
'file_size' => 'ukuran file',
|
||||||
|
'file_mime_type' => 'tipe file',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/Http/Requests/Api/PresignedUrlRequest.php
Normal file
31
app/Http/Requests/Api/PresignedUrlRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Media/PathGenerators/CustomPathGenerator.php
Normal file
39
app/Media/PathGenerators/CustomPathGenerator.php
Normal 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}/";
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Services/S3PresignedService.php
Normal file
76
app/Services/S3PresignedService.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
"laravel/framework": "^13.17",
|
"laravel/framework": "^13.17",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
|
"spatie/laravel-medialibrary": "^11.23",
|
||||||
"spatie/laravel-permission": "^8.3",
|
"spatie/laravel-permission": "^8.3",
|
||||||
"spatie/laravel-sluggable": "^4.0"
|
"spatie/laravel-sluggable": "^4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
801
composer.lock
generated
801
composer.lock
generated
@ -4,8 +4,159 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "bf685f2e2b1d13f4dfba5042cdd5af8b",
|
"content-hash": "e3f219d587b4456806b7c536eab8b82f",
|
||||||
"packages": [
|
"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",
|
"name": "bacon/bacon-qr-code",
|
||||||
"version": "v3.1.1",
|
"version": "v3.1.1",
|
||||||
@ -189,6 +340,83 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "dasprid/enum",
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
@ -2217,6 +2445,61 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-07-06T14:42:07+00:00"
|
"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",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.31.0",
|
"version": "3.31.0",
|
||||||
@ -2504,6 +2787,84 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-08T20:05:35+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@ -2607,6 +2968,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"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",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.13.1",
|
"version": "3.13.1",
|
||||||
@ -4122,6 +4549,246 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-18T03:57:49+00:00"
|
"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",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.93.1",
|
"version": "1.93.1",
|
||||||
@ -4349,6 +5016,67 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-28T13:16:49+00:00"
|
"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",
|
"name": "spomky-labs/cbor-php",
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
@ -5093,6 +5821,77 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-06-05T06:23:12+00:00"
|
"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",
|
"name": "symfony/finder",
|
||||||
"version": "v8.1.1",
|
"version": "v8.1.1",
|
||||||
|
|||||||
@ -58,6 +58,7 @@
|
|||||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
|
'visibility' => 'public',
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
363
config/media-library.php
Normal file
363
config/media-library.php
Normal 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),
|
||||||
|
];
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { createInertiaApp } from '@inertiajs/react';
|
import { createInertiaApp } from '@inertiajs/react';
|
||||||
|
import { FlashToast } from '@/components/flash-toast';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { FlashToast } from '@/components/flash-toast';
|
|
||||||
import { initializeTheme } from '@/hooks/use-appearance';
|
import { initializeTheme } from '@/hooks/use-appearance';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import AuthLayout from '@/layouts/auth-layout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
|
|||||||
@ -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 { Link } from '@inertiajs/react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@ -22,27 +43,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from 'lucide-react';
|
} 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 };
|
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||||
|
|
||||||
|
|||||||
166
resources/js/components/file-upload.tsx
Normal file
166
resources/js/components/file-upload.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
resources/js/components/image-preview-modal.tsx
Normal file
30
resources/js/components/image-preview-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ function formatRupiah(value: number): string {
|
|||||||
|
|
||||||
function parseRupiah(value: string): number {
|
function parseRupiah(value: string): number {
|
||||||
const cleaned = value.replace(/[^0-9]/g, '');
|
const cleaned = value.replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
return cleaned === '' ? 0 : parseInt(cleaned, 10);
|
return cleaned === '' ? 0 : parseInt(cleaned, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ export function RupiahInput({
|
|||||||
if (min !== undefined && raw < min) {
|
if (min !== undefined && raw < min) {
|
||||||
clamped = min;
|
clamped = min;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (max !== undefined && raw > max) {
|
if (max !== undefined && raw > max) {
|
||||||
clamped = max;
|
clamped = max;
|
||||||
}
|
}
|
||||||
|
|||||||
204
resources/js/components/ui/attachment.tsx
Normal file
204
resources/js/components/ui/attachment.tsx
Normal 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,
|
||||||
|
}
|
||||||
59
resources/js/hooks/use-file-upload.ts
Normal file
59
resources/js/hooks/use-file-upload.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
resources/js/lib/upload.ts
Normal file
79
resources/js/lib/upload.ts
Normal 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
8
routes/api.php
Normal 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');
|
||||||
Loading…
Reference in New Issue
Block a user