Add attachment thumbnails and ACP refinements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s

This commit is contained in:
2026-01-31 12:02:54 +01:00
parent 7fbc566129
commit 64244567c0
12 changed files with 933 additions and 664 deletions

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->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']);
});
}
};

298
package-lock.json generated
View File

@@ -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",

View File

@@ -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 {

View File

@@ -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,24 +1689,37 @@ export default function Acp({ isAdmin }) {
className="bb-attachment-extension-table"
style={{ marginLeft: (depth + 1) * 16 }}
>
<div className="bb-attachment-extension-header tr-header">
<span>{t('attachment.extension')}</span>
<span>{t('attachment.allowed_mimes')}</span>
<span>{t('attachment.extension_group')}</span>
<span>{t('attachment.actions')}</span>
</div>
<table className="table table-sm mb-0">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">
{t('attachment.extension')}
</th>
<th scope="col" className="text-start">
{t('attachment.allowed_mimes')}
</th>
<th scope="col" className="text-start">
{t('attachment.extension_group')}
</th>
<th scope="col" className="text-start">
{t('attachment.actions')}
</th>
</tr>
</thead>
<tbody>
{groupExtensions.map((extension) => (
<div key={extension.id} className="bb-attachment-extension-row">
<span className="bb-attachment-extension-name">
<tr key={extension.id} className="bb-attachment-extension-row">
<td className="bb-attachment-extension-name text-start">
{extension.extension}
</span>
<span className="bb-attachment-extension-meta">
</td>
<td className="bb-attachment-extension-meta text-start">
{(extension.allowed_mimes || []).join(', ')}
</span>
<span className="bb-attachment-extension-meta">
</td>
<td className="bb-attachment-extension-meta text-start">
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|| t('attachment.extension_unassigned')}
</span>
</td>
<td>
<div className="bb-attachment-extension-actions">
<ButtonGroup size="sm" className="bb-action-group">
<Button
@@ -1692,8 +1738,11 @@ export default function Acp({ isAdmin }) {
</Button>
</ButtonGroup>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
@@ -2143,8 +2192,80 @@ export default function Acp({ isAdmin }) {
<h2 className="mb-4">{t('acp.title')}</h2>
<Tabs defaultActiveKey="general" className="mb-3">
<Tab eventKey="general" title={t('acp.general')}>
<p className="bb-muted">{t('acp.general_hint')}</p>
<Row className="g-4">
<Col lg={3} xl={2}>
<div className="bb-acp-sidebar">
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.groups')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.ranks')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.attachments')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.board_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action is-active">
{t('acp.general')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.client_communication')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.authentication')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.email_settings')}
</button>
</div>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.server_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.security_settings')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.search_settings')}
</button>
</div>
</div>
</div>
</Col>
<Col lg={9} xl={10}>
<div className="bb-acp-panel mb-4">
<div className="bb-acp-panel-header">
<h5 className="mb-1">{t('acp.welcome_title')}</h5>
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
</div>
</div>
{generalError && <p className="text-danger">{generalError}</p>}
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">{t('acp.general_settings')}</h5>
</div>
<div className="bb-acp-panel-body">
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
<Row className="g-3">
<Col lg={6}>
@@ -2154,7 +2275,10 @@ export default function Acp({ isAdmin }) {
type="text"
value={generalSettings.forum_name}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forum_name: event.target.value }))
setGeneralSettings((prev) => ({
...prev,
forum_name: event.target.value,
}))
}
/>
</Form.Group>
@@ -2434,6 +2558,10 @@ export default function Acp({ isAdmin }) {
</Col>
</Row>
</Form>
</div>
</div>
</Col>
</Row>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
@@ -2702,6 +2830,95 @@ export default function Acp({ isAdmin }) {
{attachmentGroupsError && <p className="text-danger">{attachmentGroupsError}</p>}
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
<div className="bb-attachment-admin">
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.settings_title')}</h5>
</div>
<Form onSubmit={handleAttachmentSettingsSave}>
<div className="row g-3 align-items-end">
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-display-inline"
label={t('attachment.display_images_inline')}
checked={attachmentSettings.display_images_inline !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
display_images_inline: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-create-thumbnails"
label={t('attachment.create_thumbnails')}
checked={attachmentSettings.create_thumbnails !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
create_thumbnails: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_width')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_width}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_width: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_height')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_height}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_height: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_quality')}</Form.Label>
<Form.Control
type="number"
min="10"
max="95"
value={attachmentSettings.thumbnail_quality}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_quality: event.target.value,
}))
}
/>
</div>
</div>
<div className="mt-3">
<Button
type="submit"
className="bb-accent-button"
disabled={attachmentSettingsSaving}
>
{attachmentSettingsSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</div>
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.groups_title')}</h5>

View File

@@ -34,6 +34,7 @@ export default function ThreadView() {
const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([])
const [lightboxImage, setLightboxImage] = useState('')
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
disableBbcode: false,
@@ -452,6 +453,26 @@ export default function ThreadView() {
return (
<div className="bb-attachment-list">
{attachments.map((attachment) => (
attachment.is_image ? (
<button
key={attachment.id}
type="button"
className="bb-attachment-item border-0 text-start"
onClick={() => setLightboxImage(attachment.download_url)}
>
<img
src={attachment.thumbnail_url || attachment.download_url}
alt={attachment.original_name}
className="img-fluid rounded"
style={{ width: 72, height: 72, objectFit: 'cover' }}
/>
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</button>
) : (
<a
key={attachment.id}
href={attachment.download_url}
@@ -465,6 +486,7 @@ export default function ThreadView() {
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</a>
)
))}
</div>
)
@@ -727,6 +749,12 @@ export default function ThreadView() {
</div>
<div
className="bb-post-body"
onClick={(event) => {
if (event.target?.tagName === 'IMG') {
event.preventDefault()
setLightboxImage(event.target.src)
}
}}
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
/>
{renderAttachments(post.attachments)}
@@ -852,6 +880,18 @@ export default function ThreadView() {
/>
</Modal.Body>
</Modal>
<Modal
show={Boolean(lightboxImage)}
onHide={() => setLightboxImage('')}
centered
size="lg"
>
<Modal.Body className="text-center">
{lightboxImage && (
<img src={lightboxImage} alt="" className="img-fluid rounded" />
)}
</Modal.Body>
</Modal>
</Container>
)
}

View File

@@ -51,6 +51,16 @@
"acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ",
"acp.general": "Allgemein",
"acp.quick_access": "Schnellzugriff",
"acp.board_configuration": "Board-Konfiguration",
"acp.client_communication": "Client-Kommunikation",
"acp.server_configuration": "Server-Konfiguration",
"acp.authentication": "Authentifizierung",
"acp.email_settings": "E-Mail-Einstellungen",
"acp.security_settings": "Sicherheitseinstellungen",
"acp.search_settings": "Sucheinstellungen",
"acp.welcome_title": "Willkommen bei speedBB",
"acp.general_settings": "Allgemeine Einstellungen",
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
"acp.loading": "Laden...",
"acp.new_category": "Neue Kategorie",
@@ -217,6 +227,12 @@
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
"ucp.custom_color": "Eigene Farbe",
"attachment.groups_title": "Anhanggruppen",
"attachment.settings_title": "Anhang-Einstellungen",
"attachment.display_images_inline": "Bilder inline anzeigen",
"attachment.create_thumbnails": "Vorschaubilder erstellen",
"attachment.thumbnail_max_width": "Maximale Vorschaubreite (px)",
"attachment.thumbnail_max_height": "Maximale Vorschaubildhöhe (px)",
"attachment.thumbnail_quality": "Vorschaubild-Qualität (JPEG/WebP)",
"attachment.group_create": "Neue Anhanggruppe",
"attachment.group_create_title": "Anhanggruppe erstellen",
"attachment.group_edit_title": "Anhanggruppe bearbeiten",

View File

@@ -51,6 +51,16 @@
"acp.forums_tree": "Forum tree",
"acp.forums_type": "Type",
"acp.general": "General",
"acp.quick_access": "Quick access",
"acp.board_configuration": "Board configuration",
"acp.client_communication": "Client communication",
"acp.server_configuration": "Server configuration",
"acp.authentication": "Authentication",
"acp.email_settings": "Email settings",
"acp.security_settings": "Security settings",
"acp.search_settings": "Search settings",
"acp.welcome_title": "Welcome to speedBB",
"acp.general_settings": "General settings",
"acp.general_hint": "Global settings and board configuration will appear here.",
"acp.loading": "Loading...",
"acp.new_category": "New category",
@@ -217,6 +227,12 @@
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
"ucp.custom_color": "Custom color",
"attachment.groups_title": "Attachment groups",
"attachment.settings_title": "Attachment settings",
"attachment.display_images_inline": "Display images inline",
"attachment.create_thumbnails": "Create thumbnails",
"attachment.thumbnail_max_width": "Maximum thumbnail width (px)",
"attachment.thumbnail_max_height": "Maximum thumbnail height (px)",
"attachment.thumbnail_quality": "Thumbnail quality (JPEG/WebP)",
"attachment.group_create": "New attachment group",
"attachment.group_create_title": "Create attachment group",
"attachment.group_edit_title": "Edit attachment group",

View File

@@ -77,6 +77,7 @@ Route::get('/attachments', [AttachmentController::class, 'index']);
Route::post('/attachments', [AttachmentController::class, 'store'])->middleware('auth:sanctum');
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download']);
Route::get('/attachments/{attachment}/thumbnail', [AttachmentController::class, 'thumbnail']);
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/forums', [ForumController::class, 'index']);