Add attachment thumbnails and ACP refinements
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
298
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user