feat: update homepage settings to use image keys and add presigned URL retrieval

This commit is contained in:
Yoga Pangestu 2026-07-31 10:30:25 +07:00
parent a1a73cb222
commit d07eb3498a
8 changed files with 215 additions and 62 deletions

View File

@ -6,6 +6,7 @@
use App\Http\Requests\Api\PresignedUrlRequest;
use App\Services\S3PresignedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
class PresignedUrlController extends Controller
{
@ -25,4 +26,12 @@ public function store(PresignedUrlRequest $request): JsonResponse
return response()->json($result);
}
public function show(string $key): RedirectResponse
{
$minutes = (int) request('minutes', 60);
$url = $this->service->getTemporaryUrl(urldecode($key), $minutes);
return redirect($url);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
use Override;
class UpdateHRRequest extends FormRequest
{
@ -11,6 +12,22 @@ public function authorize(): bool
return true;
}
#[Override]
public function prepareForValidation()
{
if ($this->has('late_penalty_amount')) {
$this->merge([
'late_penalty_amount' => str_replace('.', '', $this->late_penalty_amount),
]);
}
if ($this->has('absent_penalty_amount')) {
$this->merge([
'absent_penalty_amount' => str_replace('.', '', $this->absent_penalty_amount),
]);
}
}
public function rules(): array
{
return [

View File

@ -14,19 +14,19 @@ public function authorize(): bool
public function rules(): array
{
return [
'hero_image_url' => ['nullable', 'string', 'max:2000'],
'about_image_url' => ['nullable', 'string', 'max:2000'],
'gallery_images' => ['nullable', 'array'],
'gallery_images.*' => ['string', 'max:2000'],
'hero_image_key' => ['nullable', 'string', 'max:2000'],
'about_image_key' => ['nullable', 'string', 'max:2000'],
'gallery_image_keys' => ['nullable', 'array'],
'gallery_image_keys.*' => ['string', 'max:2000'],
];
}
public function attributes(): array
{
return [
'hero_image_url' => 'url foto hero',
'about_image_url' => 'url foto tentang kami',
'gallery_images' => 'gambar galeri',
'hero_image_key' => 'foto hero',
'about_image_key' => 'foto tentang kami',
'gallery_image_keys' => 'gambar galeri',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin;
use App\Services\S3PresignedService;
use App\Settings\HRSettings;
use App\Settings\HomepageSettings;
use App\Settings\MarketplaceSettings;
@ -11,6 +12,10 @@
class AdminSettingsService
{
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function getSystemData(): array
{
$settings = app(SystemSettings::class);
@ -29,12 +34,27 @@ public function getHomepageData(): array
$settings = app(HomepageSettings::class);
return [
'hero_image_url' => $settings->hero_image_url,
'about_image_url' => $settings->about_image_url,
'gallery_images' => $settings->gallery_images,
'hero_image_url' => $settings->hero_image_url ? $this->getTemporaryUrl($settings->hero_image_url) : null,
'hero_image_key' => $settings->hero_image_url,
'about_image_url' => $settings->about_image_url ? $this->getTemporaryUrl($settings->about_image_url) : null,
'about_image_key' => $settings->about_image_url,
'gallery_images' => array_map(
fn ($key) => $this->getTemporaryUrl($key),
$settings->gallery_images ?? [],
),
'gallery_image_keys' => $settings->gallery_images ?? [],
];
}
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
public function getSocialMediaData(): array
{
$settings = app(SocialMediaSettings::class);
@ -95,7 +115,11 @@ public function updateSystem(array $data): void
public function updateHomepage(array $data): void
{
$settings = app(HomepageSettings::class);
$settings->fill($data);
$settings->hero_image_url = $data['hero_image_key'] ?? null;
$settings->about_image_url = $data['about_image_key'] ?? null;
$settings->gallery_images = $data['gallery_image_keys'] ?? [];
$settings->save();
}

View File

@ -1,5 +1,5 @@
import { FileImage, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useId } from 'react';
import {
Attachment,
AttachmentAction,
@ -36,6 +36,7 @@ function formatFileSize(bytes: number): string {
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 uploadId = useId();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
@ -106,7 +107,7 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
accept={accept}
onChange={handleFileChange}
className="hidden"
id="file-upload"
id={uploadId}
/>
<Attachment state={state} orientation="horizontal" className='w-full'>

View File

@ -1,3 +1,4 @@
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
import { RupiahInput } from '@/components/rupiah-input';
@ -10,8 +11,9 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { updateHomepage, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
import { updateHomepage, updateHr, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
type MarketplaceFeeRule = {
@ -30,8 +32,11 @@ type Props = {
};
homepage: {
hero_image_url: string;
hero_image_key: string;
about_image_url: string;
about_image_key: string;
gallery_images: string[];
gallery_image_keys: string[];
};
socialMedia: {
instagram_url: string;
@ -50,6 +55,124 @@ type Props = {
};
};
type HomepageSettingsTabProps = {
homepage: Props['homepage'];
};
function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
const [heroKey, setHeroKey] = useState<string | null>(homepage.hero_image_key ?? null);
const [heroUploading, setHeroUploading] = useState(false);
const [aboutKey, setAboutKey] = useState<string | null>(homepage.about_image_key ?? null);
const [aboutUploading, setAboutUploading] = useState(false);
const [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []);
const [galleryUploading, setGalleryUploading] = useState(false);
function addGalleryImage() {
setGalleryKeys((prev) => [...prev, '']);
}
function removeGalleryImage(index: number) {
setGalleryKeys((prev) => prev.filter((_, i) => i !== index));
}
function updateGalleryKey(index: number, key: string | null) {
setGalleryKeys((prev) => prev.map((k, i) => (i === index ? (key ?? '') : k)));
}
return (
<Form action={updateHomepage()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
{galleryKeys.map((key, index) => (
<input key={index} type="hidden" name={`gallery_image_keys[${index}]`} value={key} />
))}
<div className="grid gap-2">
<Label>Foto Hero</Label>
<FileUpload
value={heroKey}
onChange={setHeroKey}
folder="homepage/hero"
existingUrl={homepage.hero_image_url}
onUploadingChange={setHeroUploading}
/>
<InputError message={errors.hero_image_key} />
</div>
<div className="grid gap-2">
<Label>Foto Tentang Kami</Label>
<FileUpload
value={aboutKey}
onChange={setAboutKey}
folder="homepage/about"
existingUrl={homepage.about_image_url}
onUploadingChange={setAboutUploading}
/>
<InputError message={errors.about_image_key} />
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Galeri Foto</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addGalleryImage}
>
<Plus className="h-4 w-4" />
Tambah
</Button>
</div>
<div className="grid gap-3">
{galleryKeys.map((key, index) => (
<div key={index} className="flex items-start gap-2">
<div className="flex-1">
<FileUpload
value={key || null}
onChange={(k) => updateGalleryKey(index, k)}
folder="homepage/gallery"
existingUrl={homepage.gallery_images[index] ?? null}
onUploadingChange={setGalleryUploading}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 text-destructive hover:text-destructive"
onClick={() => removeGalleryImage(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{galleryKeys.length === 0 && (
<p className="text-sm text-muted-foreground">Belum ada gambar. Klik "Tambah" untuk menambahkan gambar galeri.</p>
)}
</div>
<InputError message={errors.gallery_image_keys} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing || heroUploading || aboutUploading || galleryUploading}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</div>
)}
</Form>
);
}
const sidebarTabs = [
{ key: 'sistem', label: 'Sistem' },
{ key: 'homepage', label: 'Homepage' },
@ -249,32 +372,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)}
{activeTab === 'homepage' && (
<Form action={updateHomepage()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Homepage</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid gap-2">
<Label htmlFor="hero_image_url">URL Foto Hero</Label>
<Input id="hero_image_url" name="hero_image_url" placeholder="https://..." defaultValue={homepage.hero_image_url} />
<InputError message={errors.hero_image_url} />
</div>
<div className="grid gap-2">
<Label htmlFor="about_image_url">URL Foto Tentang Kami</Label>
<Input id="about_image_url" name="about_image_url" placeholder="https://..." defaultValue={homepage.about_image_url} />
<InputError message={errors.about_image_url} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button>
</div>
</div>
)}
</Form>
<HomepageSettingsTab homepage={homepage} />
)}
{activeTab === 'media-sosial' && (
@ -336,7 +434,7 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)}
{activeTab === 'hr' && (
<Form action="/admin/settings/hr" options={{ preserveScroll: true }}>
<Form action={updateHr()} options={{ preserveScroll: true }}>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>

View File

@ -6,3 +6,7 @@
Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
->middleware(['web', 'auth', 'verified'])
->name('presigned-url.store');
Route::get('/presigned-url/{key}', [PresignedUrlController::class, 'show'])
->middleware(['web', 'auth', 'verified'])
->name('presigned-url.show');

View File

@ -32,7 +32,7 @@
test('guest cannot update homepage settings', function () {
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://unauthorized.com/image.jpg',
'hero_image_key' => 'unauthorized/image.jpg',
]);
$response->assertRedirect(route('login'));
});
@ -257,9 +257,9 @@
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/hero.jpg',
'about_image_url' => 'https://example.com/about.jpg',
'gallery_images' => ['https://example.com/g1.jpg', 'https://example.com/g2.jpg'],
'hero_image_key' => 'homepage/hero/2026/07/31/abc123/hero.jpg',
'about_image_key' => 'homepage/about/2026/07/31/def456/about.jpg',
'gallery_image_keys' => ['homepage/gallery/2026/07/31/g1/img.jpg', 'homepage/gallery/2026/07/31/g2/img.jpg'],
]);
$response
@ -267,9 +267,9 @@
->assertRedirect(route('admin.settings.index'));
$settings = app(HomepageSettings::class);
expect($settings->hero_image_url)->toBe('https://example.com/hero.jpg');
expect($settings->about_image_url)->toBe('https://example.com/about.jpg');
expect($settings->gallery_images)->toBe(['https://example.com/g1.jpg', 'https://example.com/g2.jpg']);
expect($settings->hero_image_url)->toBe('homepage/hero/2026/07/31/abc123/hero.jpg');
expect($settings->about_image_url)->toBe('homepage/about/2026/07/31/def456/about.jpg');
expect($settings->gallery_images)->toBe(['homepage/gallery/2026/07/31/g1/img.jpg', 'homepage/gallery/2026/07/31/g2/img.jpg']);
});
test('homepage fields are nullable', function () {
@ -277,44 +277,44 @@
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => null,
'about_image_url' => null,
'hero_image_key' => null,
'about_image_key' => null,
]);
$response->assertSessionHasNoErrors();
});
test('homepage hero_image_url must not exceed 2000 characters', function () {
test('homepage hero_image_key must not exceed 2000 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => str_repeat('a', 2001),
'hero_image_key' => str_repeat('a', 2001),
]);
$response->assertSessionHasErrors('hero_image_url');
$response->assertSessionHasErrors('hero_image_key');
});
test('homepage gallery_images must be array', function () {
test('homepage gallery_image_keys must be array', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => 'not-an-array',
'gallery_image_keys' => 'not-an-array',
]);
$response->assertSessionHasErrors('gallery_images');
$response->assertSessionHasErrors('gallery_image_keys');
});
test('homepage gallery_images items must not exceed 2000 characters', function () {
test('homepage gallery_image_keys items must not exceed 2000 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-homepage'), [
'gallery_images' => [str_repeat('a', 2001)],
'gallery_image_keys' => [str_repeat('a', 2001)],
]);
$response->assertSessionHasErrors('gallery_images.0');
$response->assertSessionHasErrors('gallery_image_keys.0');
});
/*
@ -851,8 +851,8 @@
// Update homepage
$this->put(route('admin.settings.update-homepage'), [
'hero_image_url' => 'https://example.com/new-hero.jpg',
'about_image_url' => 'https://example.com/new-about.jpg',
'hero_image_key' => 'homepage/hero/2026/07/31/new-hero.jpg',
'about_image_key' => 'homepage/about/2026/07/31/new-about.jpg',
])->assertSessionHasNoErrors();
// Update social media
@ -898,7 +898,7 @@
$hr = app(HRSettings::class);
expect($system->app_name)->toBe('DST Updated');
expect($homepage->hero_image_url)->toBe('https://example.com/new-hero.jpg');
expect($homepage->hero_image_url)->toBe('homepage/hero/2026/07/31/new-hero.jpg');
expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst');
expect($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0);
expect($hr->scheduled_check_in_time)->toBe('08:30');