diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 2268886..3593d32 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Attachment; use App\Models\AttachmentExtension; use App\Models\Post; +use App\Models\Setting; use App\Models\Thread; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -114,6 +115,8 @@ class AttachmentController extends Controller $path = "attachments/{$scopeFolder}/{$filename}"; Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename); + $thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder); + $attachment = Attachment::create([ 'thread_id' => $threadId, 'post_id' => $postId, @@ -122,6 +125,9 @@ class AttachmentController extends Controller 'user_id' => $user->id, 'disk' => $disk, 'path' => $path, + 'thumbnail_path' => $thumbnailPayload['path'] ?? null, + 'thumbnail_mime_type' => $thumbnailPayload['mime'] ?? null, + 'thumbnail_size_bytes' => $thumbnailPayload['size'] ?? null, 'original_name' => $file->getClientOriginalName(), 'extension' => $extension !== '' ? $extension : null, 'mime_type' => $mime, @@ -162,6 +168,28 @@ class AttachmentController extends Controller ]); } + public function thumbnail(Attachment $attachment): Response + { + if (!$this->canViewAttachment($attachment)) { + abort(404); + } + + if (!$attachment->thumbnail_path) { + abort(404); + } + + $disk = Storage::disk($attachment->disk); + if (!$disk->exists($attachment->thumbnail_path)) { + abort(404); + } + + $mime = $attachment->thumbnail_mime_type ?: 'image/jpeg'; + + return $disk->response($attachment->thumbnail_path, null, [ + 'Content-Type' => $mime, + ]); + } + public function destroy(Request $request, Attachment $attachment): JsonResponse { $user = $request->user(); @@ -279,6 +307,8 @@ class AttachmentController extends Controller private function serializeAttachment(Attachment $attachment): array { + $isImage = str_starts_with((string) $attachment->mime_type, 'image/'); + return [ 'id' => $attachment->id, 'thread_id' => $attachment->thread_id, @@ -294,7 +324,159 @@ class AttachmentController extends Controller 'mime_type' => $attachment->mime_type, 'size_bytes' => $attachment->size_bytes, 'download_url' => "/api/attachments/{$attachment->id}/download", + 'thumbnail_url' => $attachment->thumbnail_path + ? "/api/attachments/{$attachment->id}/thumbnail" + : null, + 'is_image' => $isImage, 'created_at' => $attachment->created_at?->toIso8601String(), ]; } + + private function maybeCreateThumbnail($file, string $scopeFolder): ?array + { + $enabled = $this->settingBool('attachments.create_thumbnails', true); + if (!$enabled) { + return null; + } + + $mime = $file->getMimeType() ?? ''; + if (!str_starts_with($mime, 'image/')) { + return null; + } + + $maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300); + $maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300); + if ($maxWidth <= 0 || $maxHeight <= 0) { + return null; + } + + $sourcePath = $file->getPathname(); + $info = @getimagesize($sourcePath); + if (!$info) { + return null; + } + + [$width, $height] = $info; + if ($width <= 0 || $height <= 0) { + return null; + } + + if ($width <= $maxWidth && $height <= $maxHeight) { + return null; + } + + $ratio = min($maxWidth / $width, $maxHeight / $height); + $targetWidth = max(1, (int) round($width * $ratio)); + $targetHeight = max(1, (int) round($height * $ratio)); + + $sourceImage = $this->createImageFromFile($sourcePath, $mime); + if (!$sourceImage) { + return null; + } + + $thumbImage = imagecreatetruecolor($targetWidth, $targetHeight); + if (!$thumbImage) { + imagedestroy($sourceImage); + return null; + } + + if (in_array($mime, ['image/png', 'image/gif'], true)) { + imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127)); + imagealphablending($thumbImage, false); + imagesavealpha($thumbImage, true); + } + + imagecopyresampled( + $thumbImage, + $sourceImage, + 0, + 0, + 0, + 0, + $targetWidth, + $targetHeight, + $width, + $height + ); + + $quality = $this->settingInt('attachments.thumbnail_quality', 85); + $thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality); + + imagedestroy($sourceImage); + imagedestroy($thumbImage); + + if ($thumbBinary === null) { + return null; + } + + $filename = Str::uuid()->toString(); + $extension = strtolower((string) $file->getClientOriginalExtension()); + if ($extension !== '') { + $filename .= ".{$extension}"; + } + + $disk = 'local'; + $thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}"; + Storage::disk($disk)->put($thumbPath, $thumbBinary); + + return [ + 'path' => $thumbPath, + 'mime' => $mime, + 'size' => strlen($thumbBinary), + ]; + } + + private function createImageFromFile(string $path, string $mime) + { + return match ($mime) { + 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path), + 'image/png' => @imagecreatefrompng($path), + 'image/gif' => @imagecreatefromgif($path), + 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null, + default => null, + }; + } + + private function renderImageBinary($image, string $mime, int $quality): ?string + { + ob_start(); + $success = false; + + if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) { + $success = imagejpeg($image, null, max(10, min(95, $quality))); + } elseif ($mime === 'image/png') { + $compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9); + $success = imagepng($image, null, $compression); + } elseif ($mime === 'image/gif') { + $success = imagegif($image); + } elseif ($mime === 'image/webp' && function_exists('imagewebp')) { + $success = imagewebp($image, null, max(10, min(95, $quality))); + } + + $data = ob_get_clean(); + + if (!$success) { + return null; + } + + return $data !== false ? $data : null; + } + + private function settingBool(string $key, bool $default): bool + { + $value = Setting::query()->where('key', $key)->value('value'); + if ($value === null) { + return $default; + } + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + private function settingInt(string $key, int $default): int + { + $value = Setting::query()->where('key', $key)->value('value'); + if ($value === null) { + return $default; + } + return (int) $value; + } } diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 8b252a0..32e9b60 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Actions\BbcodeFormatter; use App\Models\Post; use App\Models\Thread; +use App\Models\Setting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -132,6 +133,10 @@ class PostController extends Controller 'mime_type' => $attachment->mime_type, 'size_bytes' => $attachment->size_bytes, 'download_url' => "/api/attachments/{$attachment->id}/download", + 'thumbnail_url' => $attachment->thumbnail_path + ? "/api/attachments/{$attachment->id}/thumbnail" + : null, + 'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'), 'created_at' => $attachment->created_at?->toIso8601String(), ]) ->values() @@ -175,13 +180,22 @@ class PostController extends Controller $entry = $map[$key]; $url = $entry['url']; $mime = $entry['mime'] ?? ''; - if (str_starts_with($mime, 'image/')) { + if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { return "[img]{$url}[/img]"; } return "[url={$url}]{$rawName}[/url]"; }, $body) ?? $body; } + private function displayImagesInline(): bool + { + $value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value'); + if ($value === null) { + return true; + } + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + private function resolveGroupColor(?\App\Models\User $user): ?string { if (!$user) { diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index e813165..f5963d4 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Forum; use App\Models\Thread; use App\Actions\BbcodeFormatter; +use App\Models\Setting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -207,6 +208,10 @@ class ThreadController extends Controller 'mime_type' => $attachment->mime_type, 'size_bytes' => $attachment->size_bytes, 'download_url' => "/api/attachments/{$attachment->id}/download", + 'thumbnail_url' => $attachment->thumbnail_path + ? "/api/attachments/{$attachment->id}/thumbnail" + : null, + 'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'), 'created_at' => $attachment->created_at?->toIso8601String(), ]) ->values() @@ -250,13 +255,22 @@ class ThreadController extends Controller $entry = $map[$key]; $url = $entry['url']; $mime = $entry['mime'] ?? ''; - if (str_starts_with($mime, 'image/')) { + if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { return "[img]{$url}[/img]"; } return "[url={$url}]{$rawName}[/url]"; }, $body) ?? $body; } + private function displayImagesInline(): bool + { + $value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value'); + if ($value === null) { + return true; + } + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + private function resolveGroupColor(?\App\Models\User $user): ?string { if (!$user) { diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php index 42fd5af..05f0112 100644 --- a/app/Models/Attachment.php +++ b/app/Models/Attachment.php @@ -33,6 +33,9 @@ class Attachment extends Model 'user_id', 'disk', 'path', + 'thumbnail_path', + 'thumbnail_mime_type', + 'thumbnail_size_bytes', 'original_name', 'extension', 'mime_type', @@ -41,6 +44,7 @@ class Attachment extends Model protected $casts = [ 'size_bytes' => 'int', + 'thumbnail_size_bytes' => 'int', ]; public function thread(): BelongsTo diff --git a/database/migrations/2026_01_29_000100_add_attachment_thumbnails.php b/database/migrations/2026_01_29_000100_add_attachment_thumbnails.php new file mode 100644 index 0000000..406f506 --- /dev/null +++ b/database/migrations/2026_01_29_000100_add_attachment_thumbnails.php @@ -0,0 +1,24 @@ +string('thumbnail_path')->nullable()->after('path'); + $table->string('thumbnail_mime_type', 150)->nullable()->after('thumbnail_path'); + $table->unsignedBigInteger('thumbnail_size_bytes')->nullable()->after('thumbnail_mime_type'); + }); + } + + public function down(): void + { + Schema::table('attachments', function (Blueprint $table) { + $table->dropColumn(['thumbnail_path', 'thumbnail_mime_type', 'thumbnail_size_bytes']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 04e067c..63d9a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2072,18 +2072,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2888,18 +2876,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3010,280 +2986,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/resources/js/index.css b/resources/js/index.css index e7e33f2..73ebc11 100644 --- a/resources/js/index.css +++ b/resources/js/index.css @@ -2176,7 +2176,57 @@ a { } .bb-acp { - max-width: 1880px; + max-width: 100%; +} + +.bb-acp-sidebar { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.bb-acp-sidebar-section { + background: rgba(16, 20, 30, 0.7); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 0.75rem; +} + +.bb-acp-sidebar-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bb-ink-muted); + margin-bottom: 0.5rem; +} + +.bb-acp-sidebar .list-group-item { + background: transparent; + color: var(--bb-ink); + border: 0; + padding: 0.35rem 0.25rem; +} + +.bb-acp-sidebar .list-group-item.is-active, +.bb-acp-sidebar .list-group-item:hover { + color: var(--bb-accent, #f29b3f); +} + +.bb-acp-panel { + background: rgba(18, 23, 33, 0.8); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; +} + +.bb-acp-panel-header { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.bb-acp-panel-body { + padding: 1rem; } .bb-icon { @@ -2287,30 +2337,29 @@ a { } .bb-attachment-extension-table { - display: flex; - flex-direction: column; - gap: 0.4rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + overflow: hidden; + background: rgba(18, 23, 33, 0.8); } -.bb-attachment-extension-header, -.bb-attachment-extension-row { - display: grid; - grid-template-columns: minmax(120px, 0.8fr) minmax(200px, 1.2fr) minmax(180px, 1fr) auto; - gap: 0.75rem; - align-items: center; - padding: 0.5rem 0.8rem; - border-radius: 10px; +.bb-attachment-extension-table table { + margin-bottom: 0; + color: var(--bb-ink); } -.bb-attachment-extension-header { +.bb-attachment-extension-table thead th { font-size: 0.8rem; color: var(--bb-ink-muted); background: rgba(15, 19, 27, 0.7); + border-bottom: 0; + padding: 0.55rem 0.8rem; } -.bb-attachment-extension-row { - background: rgba(18, 23, 33, 0.8); - border: 1px solid rgba(255, 255, 255, 0.08); +.bb-attachment-extension-table tbody td { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding: 0.55rem 0.8rem; + vertical-align: middle; } .bb-attachment-extension-name { @@ -2343,16 +2392,6 @@ a { .bb-attachment-extension-form { grid-template-columns: 1fr; } - - .bb-attachment-extension-header, - .bb-attachment-extension-row { - grid-template-columns: 1fr; - gap: 0.4rem; - } - - .bb-attachment-extension-actions { - justify-content: flex-start; - } } .bb-rank-main img { diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index 35c633b..a9f4ff8 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -116,6 +116,14 @@ export default function Acp({ isAdmin }) { const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false) const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null) const [attachmentSeedSaving, setAttachmentSeedSaving] = useState(false) + const [attachmentSettingsSaving, setAttachmentSettingsSaving] = useState(false) + const [attachmentSettings, setAttachmentSettings] = useState({ + display_images_inline: 'true', + create_thumbnails: 'true', + thumbnail_max_width: '300', + thumbnail_max_height: '300', + thumbnail_quality: '85', + }) const [showAttachmentExtensionModal, setShowAttachmentExtensionModal] = useState(false) const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null) const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false) @@ -241,6 +249,13 @@ export default function Acp({ isAdmin }) { favicon_256: settingsMap.get('favicon_256') || '', } setGeneralSettings(next) + setAttachmentSettings({ + display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true', + create_thumbnails: settingsMap.get('attachments.create_thumbnails') || 'true', + thumbnail_max_width: settingsMap.get('attachments.thumbnail_max_width') || '300', + thumbnail_max_height: settingsMap.get('attachments.thumbnail_max_height') || '300', + thumbnail_quality: settingsMap.get('attachments.thumbnail_quality') || '85', + }) } catch (err) { if (active) setGeneralError(err.message) } @@ -297,6 +312,24 @@ export default function Acp({ isAdmin }) { } } + const handleAttachmentSettingsSave = async (event) => { + event.preventDefault() + setAttachmentSettingsSaving(true) + setAttachmentGroupsError('') + try { + await saveSettings( + Object.entries(attachmentSettings).map(([key, value]) => ({ + key: `attachments.${key}`, + value: typeof value === 'string' ? value.trim() : String(value ?? ''), + })) + ) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentSettingsSaving(false) + } + } + const handleLogoUpload = async (file, settingKey) => { if (!file) return setGeneralUploading(true) @@ -1656,44 +1689,60 @@ export default function Acp({ isAdmin }) { className="bb-attachment-extension-table" style={{ marginLeft: (depth + 1) * 16 }} > -
| + {t('attachment.extension')} + | ++ {t('attachment.allowed_mimes')} + | ++ {t('attachment.extension_group')} + | ++ {t('attachment.actions')} + | +
|---|---|---|---|
| + {extension.extension} + | ++ {(extension.allowed_mimes || []).join(', ')} + | ++ {attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name + || t('attachment.extension_unassigned')} + | +
+
+
+ |
+
{t('acp.general_hint')}
- {generalError &&{generalError}
} -