mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Merge branch feat/logo-crop
Crop the logo, keep the upload, and restore it
This commit is contained in:
@@ -15,6 +15,7 @@ use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use App\Modules\Platform\Branding\LogoCropper;
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||
use App\Modules\Platform\Branding\Watermark\WatermarkSample;
|
||||
@@ -41,6 +42,12 @@ class BrandingController extends Controller
|
||||
|
||||
return Inertia::render('branding/edit', [
|
||||
'logo_url' => $setting->logoUrl(),
|
||||
// The upload the logo was cut from, and the box it was cut
|
||||
// with, so the cropper opens on the whole picture with the
|
||||
// last crop already drawn.
|
||||
'logo_source_url' => $setting->logoSourceUrl(),
|
||||
'logo_crop' => $setting->logo_crop,
|
||||
'logo_cropped' => $setting->logoIsCropped(),
|
||||
// Read, never written here. Hiding attribution is the
|
||||
// white-label half and stays a hosted feature: the switch is
|
||||
// rendered only where Capability::AttributionHide is held, and
|
||||
@@ -60,7 +67,7 @@ class BrandingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request, LogoCropper $cropper): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'logo' => ['required', 'image', 'max:2048'],
|
||||
@@ -70,16 +77,64 @@ class BrandingController extends Controller
|
||||
$upload = $validated['logo'];
|
||||
|
||||
$setting = BrandingSetting::current();
|
||||
$previous = $cropper->files($setting);
|
||||
|
||||
if ($setting->logo_path !== null) {
|
||||
Storage::disk('public')->delete($setting->logo_path);
|
||||
}
|
||||
// A new upload starts uncropped: the old crop's box describes a
|
||||
// different picture.
|
||||
$setting->update([
|
||||
'logo_path' => $this->storeImage($upload),
|
||||
'logo_original_path' => null,
|
||||
'logo_crop' => null,
|
||||
]);
|
||||
|
||||
$setting->update(['logo_path' => $this->storeImage($upload)]);
|
||||
Storage::disk('public')->delete($previous);
|
||||
|
||||
return back()->with('success', __('Logo updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut the logo down to a box drawn on the uploaded image. Optional: an
|
||||
* uploaded logo is used whole until somebody crops it.
|
||||
*/
|
||||
public function cropLogo(Request $request, LogoCropper $cropper): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'x' => ['required', 'integer', 'min:0'],
|
||||
'y' => ['required', 'integer', 'min:0'],
|
||||
'width' => ['required', 'integer', 'min:1'],
|
||||
'height' => ['required', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting === null) {
|
||||
return back()->withErrors(['logo' => __('Upload a logo before cropping it.')]);
|
||||
}
|
||||
|
||||
$cropper->crop($setting, [
|
||||
'x' => (int) $validated['x'],
|
||||
'y' => (int) $validated['y'],
|
||||
'width' => (int) $validated['width'],
|
||||
'height' => (int) $validated['height'],
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Logo cropped.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the logo exactly as it was uploaded.
|
||||
*/
|
||||
public function restoreLogo(LogoCropper $cropper): RedirectResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting !== null) {
|
||||
$cropper->restore($setting);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Original logo restored.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sign-in and download pages print the site name under the
|
||||
* logo. Its own action rather than a field on the logo upload, because
|
||||
@@ -97,13 +152,14 @@ class BrandingController extends Controller
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(): RedirectResponse
|
||||
public function destroy(LogoCropper $cropper): RedirectResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting?->logo_path !== null) {
|
||||
Storage::disk('public')->delete($setting->logo_path);
|
||||
$setting->update(['logo_path' => null]);
|
||||
if ($setting !== null && $setting->logo_path !== null) {
|
||||
$files = $cropper->files($setting);
|
||||
$setting->update(['logo_path' => null, 'logo_original_path' => null, 'logo_crop' => null]);
|
||||
Storage::disk('public')->delete($files);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Logo removed.'));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding;
|
||||
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use claviska\SimpleImage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Cutting the logo down to part of itself, and putting the whole of it back.
|
||||
*
|
||||
* The uploaded file is never changed. A crop is a new file written from it,
|
||||
* so cropping again starts from everything that was uploaded rather than
|
||||
* from the last crop, and restoring is pointing back at the upload.
|
||||
*
|
||||
* Coordinates are in the upload's stored pixels, which is what GD reads. The
|
||||
* cropper shows the image the same way (`image-orientation: none`), because
|
||||
* the images here have no exif extension to rotate a phone photo by its
|
||||
* orientation tag: if the browser rotated it and GD did not, the box would
|
||||
* land on the wrong part of the picture.
|
||||
*/
|
||||
class LogoCropper
|
||||
{
|
||||
/**
|
||||
* GD holds four bytes a pixel, and a 2 MB file can still describe a very
|
||||
* large image if it compresses well. 25 million pixels is about 100 MB of
|
||||
* memory, and far beyond any logo.
|
||||
*/
|
||||
public const MAX_PIXELS = 25_000_000;
|
||||
|
||||
/**
|
||||
* @param array{x: int, y: int, width: int, height: int} $box
|
||||
*/
|
||||
public function crop(BrandingSetting $setting, array $box): void
|
||||
{
|
||||
$source = $setting->logoSourcePath();
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if ($source === null || ! $disk->exists($source)) {
|
||||
throw ValidationException::withMessages(['logo' => __('Upload a logo before cropping it.')]);
|
||||
}
|
||||
|
||||
$absolute = $disk->path($source);
|
||||
$size = @getimagesize($absolute);
|
||||
|
||||
if ($size === false) {
|
||||
throw ValidationException::withMessages(['logo' => __('This logo cannot be cropped. Upload it again.')]);
|
||||
}
|
||||
|
||||
[$width, $height] = $size;
|
||||
|
||||
if ($width * $height > self::MAX_PIXELS) {
|
||||
throw ValidationException::withMessages(['logo' => __('This image is too large to crop. Upload a smaller one.')]);
|
||||
}
|
||||
|
||||
if ($box['x'] + $box['width'] > $width || $box['y'] + $box['height'] > $height) {
|
||||
throw ValidationException::withMessages(['width' => __('The crop must stay inside the image.')]);
|
||||
}
|
||||
|
||||
// The whole picture is the upload itself: no second copy of it.
|
||||
if ($box['x'] === 0 && $box['y'] === 0 && $box['width'] === $width && $box['height'] === $height) {
|
||||
$this->restore($setting);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Same extension as the upload, which took it from the content
|
||||
// when it was stored (see BrandingController::storeImage).
|
||||
$cropped = 'branding/'.Str::uuid().'.'.pathinfo($source, PATHINFO_EXTENSION);
|
||||
|
||||
(new SimpleImage($absolute))
|
||||
->crop($box['x'], $box['y'], $box['x'] + $box['width'], $box['y'] + $box['height'])
|
||||
->toFile($disk->path($cropped), $size['mime']);
|
||||
|
||||
$previous = $setting->logoIsCropped() ? $setting->logo_path : null;
|
||||
|
||||
$setting->update([
|
||||
'logo_original_path' => $source,
|
||||
'logo_path' => $cropped,
|
||||
'logo_crop' => $box,
|
||||
]);
|
||||
|
||||
if ($previous !== null) {
|
||||
$disk->delete($previous);
|
||||
}
|
||||
}
|
||||
|
||||
public function restore(BrandingSetting $setting): void
|
||||
{
|
||||
if (! $setting->logoIsCropped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cropped = $setting->logo_path;
|
||||
|
||||
$setting->update([
|
||||
'logo_path' => $setting->logo_original_path,
|
||||
'logo_original_path' => null,
|
||||
'logo_crop' => null,
|
||||
]);
|
||||
|
||||
if ($cropped !== null) {
|
||||
Storage::disk('public')->delete($cropped);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file the logo occupies: the one shown, and the upload behind it.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function files(BrandingSetting $setting): array
|
||||
{
|
||||
return array_values(array_filter([$setting->logo_path, $setting->logo_original_path]));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $logo_path
|
||||
* @property string|null $logo_original_path
|
||||
* @property array{x: int, y: int, width: int, height: int}|null $logo_crop
|
||||
* @property bool $watermark_enabled
|
||||
* @property string|null $watermark_path
|
||||
* @property WatermarkPosition $watermark_position
|
||||
@@ -33,6 +35,7 @@ class BrandingSetting extends Model
|
||||
protected $casts = [
|
||||
'watermark_enabled' => 'boolean',
|
||||
'show_site_name' => 'boolean',
|
||||
'logo_crop' => 'array',
|
||||
'watermark_position' => WatermarkPosition::class,
|
||||
'watermark_size' => 'integer',
|
||||
'watermark_opacity' => 'integer',
|
||||
@@ -65,6 +68,28 @@ class BrandingSetting extends Model
|
||||
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file the logo was cut from: the upload itself when it was never
|
||||
* cropped, since then `logo_path` is that upload. A crop always starts
|
||||
* from here, never from a previous crop.
|
||||
*/
|
||||
public function logoSourcePath(): ?string
|
||||
{
|
||||
return $this->logo_original_path ?? $this->logo_path;
|
||||
}
|
||||
|
||||
public function logoSourceUrl(): ?string
|
||||
{
|
||||
$path = $this->logoSourcePath();
|
||||
|
||||
return $path === null ? null : Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
public function logoIsCropped(): bool
|
||||
{
|
||||
return $this->logo_original_path !== null;
|
||||
}
|
||||
|
||||
public function watermarkUrl(): ?string
|
||||
{
|
||||
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Cropping the logo keeps the uploaded file, so the crop can be redone or
|
||||
* undone. Both columns are null for a logo that was never cropped, which is
|
||||
* every logo that exists when this runs: `logo_path` keeps meaning "the
|
||||
* image to show", and nothing about an existing installation changes.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
// The uploaded file, while `logo_path` points at a crop of it.
|
||||
$table->string('logo_original_path')->nullable()->after('logo_path');
|
||||
// The box that crop was cut with, in the original's pixels:
|
||||
// {x, y, width, height}. So the cropper reopens where it was.
|
||||
$table->json('logo_crop')->nullable()->after('logo_original_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
$table->dropColumn(['logo_original_path', 'logo_crop']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+10
@@ -47,6 +47,7 @@
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.1.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -7278,6 +7279,15 @@
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-image-crop": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
|
||||
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.1.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import ReactCrop, { type PercentCrop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
export interface LogoCropBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LogoCropDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The uploaded image, never a previous crop of it. */
|
||||
sourceUrl: string;
|
||||
/** The last crop, in the upload's pixels, or null to start from the whole image. */
|
||||
savedCrop: LogoCropBox | null;
|
||||
}
|
||||
|
||||
const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 };
|
||||
|
||||
/**
|
||||
* Draw a box on the uploaded logo and keep only that part of it.
|
||||
*
|
||||
* The box is held in percent while it is being drawn, so it survives the
|
||||
* dialog resizing, and turned into the upload's own pixels only when it is
|
||||
* saved. The server cuts the new file from the upload: nothing is cropped
|
||||
* in the browser.
|
||||
*
|
||||
* `image-orientation: none` shows the stored pixels exactly as the server
|
||||
* reads them. A browser would otherwise rotate a phone photo by its
|
||||
* orientation tag, the server cannot, and the box would land on the wrong
|
||||
* part of the picture.
|
||||
*/
|
||||
export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [crop, setCrop] = useState<PercentCrop>(WHOLE);
|
||||
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const onImageLoad = (image: HTMLImageElement) => {
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
|
||||
setNatural({ width, height });
|
||||
setError(undefined);
|
||||
setCrop(
|
||||
savedCrop === null
|
||||
? WHOLE
|
||||
: {
|
||||
unit: '%',
|
||||
x: (savedCrop.x / width) * 100,
|
||||
y: (savedCrop.y / height) * 100,
|
||||
width: (savedCrop.width / width) * 100,
|
||||
height: (savedCrop.height / height) * 100,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => {
|
||||
const x = Math.max(0, Math.round((box.x / 100) * size.width));
|
||||
const y = Math.max(0, Math.round((box.y / 100) * size.height));
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))),
|
||||
height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))),
|
||||
};
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (natural === null || crop.width === 0 || crop.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
router.patch(
|
||||
route('branding.logo.crop'),
|
||||
{ ...toPixels(crop, natural) },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]),
|
||||
onFinish: () => setSaving(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Crop logo')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-muted/40 flex justify-center rounded border p-2">
|
||||
{/* The height limit goes on the crop wrapper: the library's
|
||||
stylesheet gives the image `max-height: inherit`, so a
|
||||
limit on the image itself is overridden, and a tall logo
|
||||
would push the bottom handles out of reach. */}
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percent) => setCrop(percent)}
|
||||
keepSelection
|
||||
minWidth={8}
|
||||
minHeight={8}
|
||||
style={{ maxHeight: '56vh' }}
|
||||
>
|
||||
<img src={sourceUrl} alt="" onLoad={(e) => onImageLoad(e.currentTarget)} style={{ imageOrientation: 'none' }} />
|
||||
</ReactCrop>
|
||||
</div>
|
||||
|
||||
{natural !== null && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{(() => {
|
||||
const box = toPixels(crop, natural);
|
||||
|
||||
return t(':width × :height pixels', { width: String(box.width), height: String(box.height) });
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InputError message={error} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCrop(WHOLE)} disabled={saving}>
|
||||
{t('Select all')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || natural === null}>
|
||||
{t('Save crop')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import { FormEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,6 +23,9 @@ interface Watermark {
|
||||
|
||||
interface BrandingEditProps {
|
||||
logo_url: string | null;
|
||||
logo_source_url: string | null;
|
||||
logo_crop: LogoCropBox | null;
|
||||
logo_cropped: boolean;
|
||||
hide_attribution: boolean;
|
||||
show_site_name: boolean;
|
||||
watermark: Watermark;
|
||||
@@ -30,7 +34,7 @@ interface BrandingEditProps {
|
||||
|
||||
type Tab = 'logo' | 'watermark' | 'attribution';
|
||||
|
||||
export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
|
||||
export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
|
||||
const { t } = useTranslation();
|
||||
const { capabilities } = usePage<SharedData>().props;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
|
||||
removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true });
|
||||
};
|
||||
|
||||
// Cropping is optional: an upload is used whole until somebody crops it.
|
||||
const [cropping, setCropping] = useState(false);
|
||||
const restoreForm = useForm({});
|
||||
|
||||
const restore = () => {
|
||||
restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true });
|
||||
};
|
||||
|
||||
const submitWatermark: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
watermarkForm.post(route('branding.watermark.update'), {
|
||||
@@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{logo_url && (
|
||||
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
|
||||
{t('Remove logo')}
|
||||
</Button>
|
||||
{logo_url && logo_source_url && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCropping(true)}>
|
||||
{t('Crop')}
|
||||
</Button>
|
||||
{logo_cropped && (
|
||||
<Button variant="outline" onClick={restore} disabled={restoreForm.processing}>
|
||||
{t('Restore original')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
|
||||
{t('Remove logo')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logo_source_url && (
|
||||
// Keyed by the upload so a new logo opens on a fresh box.
|
||||
<LogoCropDialog
|
||||
key={logo_source_url}
|
||||
open={cropping}
|
||||
onOpenChange={setCropping}
|
||||
sourceUrl={logo_source_url}
|
||||
savedCrop={logo_crop}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={submitSiteName} className="space-y-4 border-t pt-6">
|
||||
|
||||
@@ -292,6 +292,8 @@ Route::middleware('auth')->group(function () {
|
||||
Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store');
|
||||
Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy');
|
||||
Route::patch('system/settings/branding/site-name', [BrandingController::class, 'updateSiteName'])->name('branding.site-name.update');
|
||||
Route::patch('system/settings/branding/logo/crop', [BrandingController::class, 'cropLogo'])->name('branding.logo.crop');
|
||||
Route::delete('system/settings/branding/logo/crop', [BrandingController::class, 'restoreLogo'])->name('branding.logo.restore');
|
||||
|
||||
// POST rather than PATCH: the form carries a file, so it is
|
||||
// multipart, and PHP only populates $_FILES for POST.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
$this->actingAs(staffWithPermissions(['edit_settings']));
|
||||
});
|
||||
|
||||
/**
|
||||
* A 200 × 100 image, red on the left half and blue on the right, so a test
|
||||
* can tell which part of it a crop actually kept.
|
||||
*/
|
||||
function twoColourLogo(string $format = 'png'): UploadedFile
|
||||
{
|
||||
$image = imagecreatetruecolor(200, 100);
|
||||
imagefilledrectangle($image, 0, 0, 99, 99, imagecolorallocate($image, 255, 0, 0));
|
||||
imagefilledrectangle($image, 100, 0, 199, 99, imagecolorallocate($image, 0, 0, 255));
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'logo');
|
||||
$format === 'jpg' ? imagejpeg($image, $temp, 100) : imagepng($image, $temp);
|
||||
|
||||
return new UploadedFile($temp, "logo.{$format}", $format === 'jpg' ? 'image/jpeg' : 'image/png', null, true);
|
||||
}
|
||||
|
||||
/** The colour of one pixel of a stored logo, as [r, g, b]. */
|
||||
function logoPixel(string $path, int $x, int $y): array
|
||||
{
|
||||
$image = imagecreatefromstring(Storage::disk('public')->get($path));
|
||||
$rgb = imagecolorsforindex($image, imagecolorat($image, $x, $y));
|
||||
|
||||
return [$rgb['red'], $rgb['green'], $rgb['blue']];
|
||||
}
|
||||
|
||||
function uploadTwoColourLogo(string $format = 'png'): string
|
||||
{
|
||||
test()->post(route('branding.store'), ['logo' => twoColourLogo($format)])->assertRedirect();
|
||||
|
||||
return BrandingSetting::query()->sole()->logo_path;
|
||||
}
|
||||
|
||||
test('an uploaded logo is used whole until somebody crops it', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
|
||||
expect($setting->logo_original_path)->toBeNull()
|
||||
->and($setting->logo_crop)->toBeNull()
|
||||
->and($setting->logoSourcePath())->toBe($upload);
|
||||
});
|
||||
|
||||
test('cropping keeps the part of the picture the box was drawn on', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
$size = getimagesizefromstring(Storage::disk('public')->get($setting->logo_path));
|
||||
|
||||
expect($setting->logo_path)->not->toBe($upload)
|
||||
->and($setting->logo_original_path)->toBe($upload)
|
||||
->and($setting->logo_crop)->toBe(['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
|
||||
->and([$size[0], $size[1]])->toBe([100, 100])
|
||||
->and(logoPixel($setting->logo_path, 5, 50))->toBe([0, 0, 255])
|
||||
->and(logoPixel($setting->logo_path, 95, 50))->toBe([0, 0, 255]);
|
||||
|
||||
// The upload itself is untouched.
|
||||
Storage::disk('public')->assertExists($upload);
|
||||
expect(logoPixel($upload, 5, 50))->toBe([255, 0, 0]);
|
||||
});
|
||||
|
||||
test('cropping again starts from the upload, not from the last crop', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
$firstCrop = BrandingSetting::query()->sole()->logo_path;
|
||||
|
||||
// The red half is not in the first crop at all; it is in the upload.
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 50, 'height' => 50])->assertSessionHasNoErrors();
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
|
||||
expect($setting->logo_original_path)->toBe($upload)
|
||||
->and(logoPixel($setting->logo_path, 10, 10))->toBe([255, 0, 0]);
|
||||
|
||||
Storage::disk('public')->assertMissing($firstCrop);
|
||||
});
|
||||
|
||||
test('restoring puts the upload back and deletes the crop', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
$crop = BrandingSetting::query()->sole()->logo_path;
|
||||
|
||||
$this->delete(route('branding.logo.restore'))->assertRedirect();
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
|
||||
expect($setting->logo_path)->toBe($upload)
|
||||
->and($setting->logo_original_path)->toBeNull()
|
||||
->and($setting->logo_crop)->toBeNull();
|
||||
|
||||
Storage::disk('public')->assertMissing($crop);
|
||||
Storage::disk('public')->assertExists($upload);
|
||||
});
|
||||
|
||||
test('a box covering the whole picture is the same as restoring', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 200, 'height' => 100])->assertSessionHasNoErrors();
|
||||
|
||||
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
|
||||
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
|
||||
});
|
||||
|
||||
test('a box reaching outside the picture is refused and changes nothing', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 150, 'y' => 0, 'width' => 100, 'height' => 100])
|
||||
->assertSessionHasErrors('width');
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => -1, 'y' => 0, 'width' => 10, 'height' => 10])
|
||||
->assertSessionHasErrors('x');
|
||||
|
||||
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
|
||||
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
|
||||
});
|
||||
|
||||
test('there is nothing to crop before a logo is uploaded', function () {
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])
|
||||
->assertSessionHasErrors('logo');
|
||||
});
|
||||
|
||||
test('a crop keeps the format of the upload', function () {
|
||||
uploadTwoColourLogo('jpg');
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])->assertSessionHasNoErrors();
|
||||
|
||||
$path = BrandingSetting::query()->sole()->logo_path;
|
||||
|
||||
expect(pathinfo($path, PATHINFO_EXTENSION))->toBe('jpg')
|
||||
->and(getimagesizefromstring(Storage::disk('public')->get($path))['mime'])->toBe('image/jpeg');
|
||||
});
|
||||
|
||||
/*
|
||||
* A small file can declare a huge picture. The size is read from the header
|
||||
* and refused before GD is asked to hold the pixels.
|
||||
*/
|
||||
test('an image too large to hold in memory is refused before it is decoded', function () {
|
||||
$ihdr = pack('NNCCCCC', 6000, 5000, 8, 2, 0, 0, 0);
|
||||
$png = "\x89PNG\r\n\x1a\n"
|
||||
.pack('N', 13).'IHDR'.$ihdr.pack('N', crc32('IHDR'.$ihdr))
|
||||
.pack('N', 0).'IEND'.pack('N', crc32('IEND'));
|
||||
|
||||
Storage::disk('public')->put('branding/huge.png', $png);
|
||||
BrandingSetting::current()->update(['logo_path' => 'branding/huge.png']);
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])
|
||||
->assertSessionHasErrors('logo');
|
||||
|
||||
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
|
||||
});
|
||||
|
||||
test('a new upload after a crop deletes both files and starts uncropped', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
$crop = BrandingSetting::query()->sole()->logo_path;
|
||||
|
||||
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('new.png', 80, 80)]);
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
|
||||
expect($setting->logo_original_path)->toBeNull()
|
||||
->and($setting->logo_crop)->toBeNull()
|
||||
->and(Storage::disk('public')->allFiles('branding'))->toBe([$setting->logo_path]);
|
||||
|
||||
Storage::disk('public')->assertMissing($upload);
|
||||
Storage::disk('public')->assertMissing($crop);
|
||||
});
|
||||
|
||||
test('removing a cropped logo deletes both files', function () {
|
||||
uploadTwoColourLogo();
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
|
||||
$this->delete(route('branding.destroy'))->assertRedirect();
|
||||
|
||||
$setting = BrandingSetting::query()->sole();
|
||||
|
||||
expect($setting->logo_path)->toBeNull()
|
||||
->and($setting->logo_original_path)->toBeNull()
|
||||
->and(Storage::disk('public')->allFiles('branding'))->toBe([]);
|
||||
});
|
||||
|
||||
test('the screen opens the cropper on the upload, with the last box drawn', function () {
|
||||
$upload = uploadTwoColourLogo();
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
|
||||
|
||||
$this->get(route('branding.edit'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('logo_source_url', Storage::disk('public')->url($upload))
|
||||
->where('logo_crop', ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
|
||||
->where('logo_cropped', true));
|
||||
});
|
||||
|
||||
test('cropping and restoring need what the rest of the screen needs', function () {
|
||||
uploadTwoColourLogo();
|
||||
|
||||
$this->actingAs(staffWithPermissions([]));
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertForbidden();
|
||||
$this->delete(route('branding.logo.restore'))->assertForbidden();
|
||||
|
||||
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
|
||||
});
|
||||
|
||||
test('cropping and restoring 404 when the capability has been taken away', function () {
|
||||
// Before any request: the capability set is worked out once, on the
|
||||
// first one, as the other branding tests rely on.
|
||||
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
||||
Storage::disk('public')->put('branding/logo.png', twoColourLogo()->getContent());
|
||||
BrandingSetting::current()->update(['logo_path' => 'branding/logo.png']);
|
||||
|
||||
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertNotFound();
|
||||
$this->delete(route('branding.logo.restore'))->assertNotFound();
|
||||
|
||||
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user