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\Attachment;
|
||||||
use App\Models\AttachmentExtension;
|
use App\Models\AttachmentExtension;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -114,6 +115,8 @@ class AttachmentController extends Controller
|
|||||||
$path = "attachments/{$scopeFolder}/{$filename}";
|
$path = "attachments/{$scopeFolder}/{$filename}";
|
||||||
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
|
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
|
||||||
|
|
||||||
|
$thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder);
|
||||||
|
|
||||||
$attachment = Attachment::create([
|
$attachment = Attachment::create([
|
||||||
'thread_id' => $threadId,
|
'thread_id' => $threadId,
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
@@ -122,6 +125,9 @@ class AttachmentController extends Controller
|
|||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'disk' => $disk,
|
'disk' => $disk,
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
|
'thumbnail_path' => $thumbnailPayload['path'] ?? null,
|
||||||
|
'thumbnail_mime_type' => $thumbnailPayload['mime'] ?? null,
|
||||||
|
'thumbnail_size_bytes' => $thumbnailPayload['size'] ?? null,
|
||||||
'original_name' => $file->getClientOriginalName(),
|
'original_name' => $file->getClientOriginalName(),
|
||||||
'extension' => $extension !== '' ? $extension : null,
|
'extension' => $extension !== '' ? $extension : null,
|
||||||
'mime_type' => $mime,
|
'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
|
public function destroy(Request $request, Attachment $attachment): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -279,6 +307,8 @@ class AttachmentController extends Controller
|
|||||||
|
|
||||||
private function serializeAttachment(Attachment $attachment): array
|
private function serializeAttachment(Attachment $attachment): array
|
||||||
{
|
{
|
||||||
|
$isImage = str_starts_with((string) $attachment->mime_type, 'image/');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $attachment->id,
|
'id' => $attachment->id,
|
||||||
'thread_id' => $attachment->thread_id,
|
'thread_id' => $attachment->thread_id,
|
||||||
@@ -294,7 +324,159 @@ class AttachmentController extends Controller
|
|||||||
'mime_type' => $attachment->mime_type,
|
'mime_type' => $attachment->mime_type,
|
||||||
'size_bytes' => $attachment->size_bytes,
|
'size_bytes' => $attachment->size_bytes,
|
||||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
'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(),
|
'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\Actions\BbcodeFormatter;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -132,6 +133,10 @@ class PostController extends Controller
|
|||||||
'mime_type' => $attachment->mime_type,
|
'mime_type' => $attachment->mime_type,
|
||||||
'size_bytes' => $attachment->size_bytes,
|
'size_bytes' => $attachment->size_bytes,
|
||||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
'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(),
|
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||||
])
|
])
|
||||||
->values()
|
->values()
|
||||||
@@ -175,13 +180,22 @@ class PostController extends Controller
|
|||||||
$entry = $map[$key];
|
$entry = $map[$key];
|
||||||
$url = $entry['url'];
|
$url = $entry['url'];
|
||||||
$mime = $entry['mime'] ?? '';
|
$mime = $entry['mime'] ?? '';
|
||||||
if (str_starts_with($mime, 'image/')) {
|
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
||||||
return "[img]{$url}[/img]";
|
return "[img]{$url}[/img]";
|
||||||
}
|
}
|
||||||
return "[url={$url}]{$rawName}[/url]";
|
return "[url={$url}]{$rawName}[/url]";
|
||||||
}, $body) ?? $body;
|
}, $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
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use App\Actions\BbcodeFormatter;
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -207,6 +208,10 @@ class ThreadController extends Controller
|
|||||||
'mime_type' => $attachment->mime_type,
|
'mime_type' => $attachment->mime_type,
|
||||||
'size_bytes' => $attachment->size_bytes,
|
'size_bytes' => $attachment->size_bytes,
|
||||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
'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(),
|
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||||
])
|
])
|
||||||
->values()
|
->values()
|
||||||
@@ -250,13 +255,22 @@ class ThreadController extends Controller
|
|||||||
$entry = $map[$key];
|
$entry = $map[$key];
|
||||||
$url = $entry['url'];
|
$url = $entry['url'];
|
||||||
$mime = $entry['mime'] ?? '';
|
$mime = $entry['mime'] ?? '';
|
||||||
if (str_starts_with($mime, 'image/')) {
|
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
||||||
return "[img]{$url}[/img]";
|
return "[img]{$url}[/img]";
|
||||||
}
|
}
|
||||||
return "[url={$url}]{$rawName}[/url]";
|
return "[url={$url}]{$rawName}[/url]";
|
||||||
}, $body) ?? $body;
|
}, $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
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class Attachment extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'disk',
|
'disk',
|
||||||
'path',
|
'path',
|
||||||
|
'thumbnail_path',
|
||||||
|
'thumbnail_mime_type',
|
||||||
|
'thumbnail_size_bytes',
|
||||||
'original_name',
|
'original_name',
|
||||||
'extension',
|
'extension',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
@@ -41,6 +44,7 @@ class Attachment extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'size_bytes' => 'int',
|
'size_bytes' => 'int',
|
||||||
|
'thumbnail_size_bytes' => 'int',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function thread(): BelongsTo
|
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": ">=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": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -2888,18 +2876,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3010,280 +2986,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|||||||
@@ -2176,7 +2176,57 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-acp {
|
.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 {
|
.bb-icon {
|
||||||
@@ -2287,30 +2337,29 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-attachment-extension-table {
|
.bb-attachment-extension-table {
|
||||||
display: flex;
|
border-radius: 12px;
|
||||||
flex-direction: column;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
gap: 0.4rem;
|
overflow: hidden;
|
||||||
|
background: rgba(18, 23, 33, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-attachment-extension-header,
|
.bb-attachment-extension-table table {
|
||||||
.bb-attachment-extension-row {
|
margin-bottom: 0;
|
||||||
display: grid;
|
color: var(--bb-ink);
|
||||||
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-header {
|
.bb-attachment-extension-table thead th {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--bb-ink-muted);
|
color: var(--bb-ink-muted);
|
||||||
background: rgba(15, 19, 27, 0.7);
|
background: rgba(15, 19, 27, 0.7);
|
||||||
|
border-bottom: 0;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-attachment-extension-row {
|
.bb-attachment-extension-table tbody td {
|
||||||
background: rgba(18, 23, 33, 0.8);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
padding: 0.55rem 0.8rem;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-attachment-extension-name {
|
.bb-attachment-extension-name {
|
||||||
@@ -2343,16 +2392,6 @@ a {
|
|||||||
.bb-attachment-extension-form {
|
.bb-attachment-extension-form {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.bb-rank-main img {
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export default function Acp({ isAdmin }) {
|
|||||||
const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false)
|
const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false)
|
||||||
const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null)
|
const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null)
|
||||||
const [attachmentSeedSaving, setAttachmentSeedSaving] = useState(false)
|
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 [showAttachmentExtensionModal, setShowAttachmentExtensionModal] = useState(false)
|
||||||
const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null)
|
const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null)
|
||||||
const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false)
|
const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false)
|
||||||
@@ -241,6 +249,13 @@ export default function Acp({ isAdmin }) {
|
|||||||
favicon_256: settingsMap.get('favicon_256') || '',
|
favicon_256: settingsMap.get('favicon_256') || '',
|
||||||
}
|
}
|
||||||
setGeneralSettings(next)
|
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) {
|
} catch (err) {
|
||||||
if (active) setGeneralError(err.message)
|
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) => {
|
const handleLogoUpload = async (file, settingKey) => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
setGeneralUploading(true)
|
setGeneralUploading(true)
|
||||||
@@ -1656,44 +1689,60 @@ export default function Acp({ isAdmin }) {
|
|||||||
className="bb-attachment-extension-table"
|
className="bb-attachment-extension-table"
|
||||||
style={{ marginLeft: (depth + 1) * 16 }}
|
style={{ marginLeft: (depth + 1) * 16 }}
|
||||||
>
|
>
|
||||||
<div className="bb-attachment-extension-header tr-header">
|
<table className="table table-sm mb-0">
|
||||||
<span>{t('attachment.extension')}</span>
|
<thead className="tr-header">
|
||||||
<span>{t('attachment.allowed_mimes')}</span>
|
<tr>
|
||||||
<span>{t('attachment.extension_group')}</span>
|
<th scope="col" className="text-start">
|
||||||
<span>{t('attachment.actions')}</span>
|
{t('attachment.extension')}
|
||||||
</div>
|
</th>
|
||||||
{groupExtensions.map((extension) => (
|
<th scope="col" className="text-start">
|
||||||
<div key={extension.id} className="bb-attachment-extension-row">
|
{t('attachment.allowed_mimes')}
|
||||||
<span className="bb-attachment-extension-name">
|
</th>
|
||||||
{extension.extension}
|
<th scope="col" className="text-start">
|
||||||
</span>
|
{t('attachment.extension_group')}
|
||||||
<span className="bb-attachment-extension-meta">
|
</th>
|
||||||
{(extension.allowed_mimes || []).join(', ')}
|
<th scope="col" className="text-start">
|
||||||
</span>
|
{t('attachment.actions')}
|
||||||
<span className="bb-attachment-extension-meta">
|
</th>
|
||||||
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|
</tr>
|
||||||
|| t('attachment.extension_unassigned')}
|
</thead>
|
||||||
</span>
|
<tbody>
|
||||||
<div className="bb-attachment-extension-actions">
|
{groupExtensions.map((extension) => (
|
||||||
<ButtonGroup size="sm" className="bb-action-group">
|
<tr key={extension.id} className="bb-attachment-extension-row">
|
||||||
<Button
|
<td className="bb-attachment-extension-name text-start">
|
||||||
variant="dark"
|
{extension.extension}
|
||||||
onClick={() => openAttachmentExtensionEdit(extension)}
|
</td>
|
||||||
disabled={attachmentExtensionSaving}
|
<td className="bb-attachment-extension-meta text-start">
|
||||||
>
|
{(extension.allowed_mimes || []).join(', ')}
|
||||||
<i className="bi bi-pencil" aria-hidden="true" />
|
</td>
|
||||||
</Button>
|
<td className="bb-attachment-extension-meta text-start">
|
||||||
<Button
|
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|
||||||
variant="dark"
|
|| t('attachment.extension_unassigned')}
|
||||||
onClick={() => handleAttachmentExtensionDelete(extension)}
|
</td>
|
||||||
disabled={attachmentExtensionSaving}
|
<td>
|
||||||
>
|
<div className="bb-attachment-extension-actions">
|
||||||
<i className="bi bi-trash" aria-hidden="true" />
|
<ButtonGroup size="sm" className="bb-action-group">
|
||||||
</Button>
|
<Button
|
||||||
</ButtonGroup>
|
variant="dark"
|
||||||
</div>
|
onClick={() => openAttachmentExtensionEdit(extension)}
|
||||||
</div>
|
disabled={attachmentExtensionSaving}
|
||||||
))}
|
>
|
||||||
|
<i className="bi bi-pencil" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="dark"
|
||||||
|
onClick={() => handleAttachmentExtensionDelete(extension)}
|
||||||
|
disabled={attachmentExtensionSaving}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2143,297 +2192,376 @@ export default function Acp({ isAdmin }) {
|
|||||||
<h2 className="mb-4">{t('acp.title')}</h2>
|
<h2 className="mb-4">{t('acp.title')}</h2>
|
||||||
<Tabs defaultActiveKey="general" className="mb-3">
|
<Tabs defaultActiveKey="general" className="mb-3">
|
||||||
<Tab eventKey="general" title={t('acp.general')}>
|
<Tab eventKey="general" title={t('acp.general')}>
|
||||||
<p className="bb-muted">{t('acp.general_hint')}</p>
|
<Row className="g-4">
|
||||||
{generalError && <p className="text-danger">{generalError}</p>}
|
<Col lg={3} xl={2}>
|
||||||
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
|
<div className="bb-acp-sidebar">
|
||||||
<Row className="g-3">
|
<div className="bb-acp-sidebar-section">
|
||||||
<Col lg={6}>
|
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
|
||||||
<Form.Group>
|
<div className="list-group">
|
||||||
<Form.Label>{t('acp.forum_name')}</Form.Label>
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
<Form.Control
|
{t('acp.users')}
|
||||||
type="text"
|
</button>
|
||||||
value={generalSettings.forum_name}
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
onChange={(event) =>
|
{t('acp.groups')}
|
||||||
setGeneralSettings((prev) => ({ ...prev, forum_name: event.target.value }))
|
</button>
|
||||||
}
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
/>
|
{t('acp.forums')}
|
||||||
</Form.Group>
|
</button>
|
||||||
<Form.Group className="mt-2">
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
<Form.Check
|
{t('acp.ranks')}
|
||||||
type="checkbox"
|
</button>
|
||||||
id="acp-show-header-name"
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
label={t('acp.show_header_name')}
|
{t('acp.attachments')}
|
||||||
checked={generalSettings.show_header_name !== 'false'}
|
</button>
|
||||||
onChange={(event) =>
|
|
||||||
setGeneralSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
show_header_name: event.target.checked ? 'true' : 'false',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
</Col>
|
|
||||||
<Col lg={6}>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>{t('acp.default_theme')}</Form.Label>
|
|
||||||
<Form.Select
|
|
||||||
value={generalSettings.default_theme}
|
|
||||||
onChange={(event) => handleDefaultThemeChange(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="auto">{t('ucp.system_default')}</option>
|
|
||||||
<option value="dark">{t('nav.theme_dark')}</option>
|
|
||||||
<option value="light">{t('nav.theme_light')}</option>
|
|
||||||
</Form.Select>
|
|
||||||
</Form.Group>
|
|
||||||
</Col>
|
|
||||||
<Col lg={6}>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>{t('acp.accent_dark')}</Form.Label>
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
<Form.Control
|
|
||||||
type="text"
|
|
||||||
value={generalSettings.accent_color_dark}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGeneralSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
accent_color_dark: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="#f29b3f"
|
|
||||||
/>
|
|
||||||
<Form.Control
|
|
||||||
type="color"
|
|
||||||
value={generalSettings.accent_color_dark || '#f29b3f'}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGeneralSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
accent_color_dark: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</div>
|
||||||
</Col>
|
<div className="bb-acp-sidebar-section">
|
||||||
<Col lg={6}>
|
<div className="bb-acp-sidebar-title">{t('acp.board_configuration')}</div>
|
||||||
<Form.Group>
|
<div className="list-group">
|
||||||
<Form.Label>{t('acp.accent_light')}</Form.Label>
|
<button type="button" className="list-group-item list-group-item-action is-active">
|
||||||
<div className="d-flex align-items-center gap-2">
|
{t('acp.general')}
|
||||||
<Form.Control
|
</button>
|
||||||
type="text"
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
value={generalSettings.accent_color_light}
|
{t('acp.forums')}
|
||||||
onChange={(event) =>
|
</button>
|
||||||
setGeneralSettings((prev) => ({
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
...prev,
|
{t('acp.users')}
|
||||||
accent_color_light: event.target.value,
|
</button>
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="#f29b3f"
|
|
||||||
/>
|
|
||||||
<Form.Control
|
|
||||||
type="color"
|
|
||||||
value={generalSettings.accent_color_light || '#f29b3f'}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGeneralSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
accent_color_light: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</div>
|
||||||
</Col>
|
<div className="bb-acp-sidebar-section">
|
||||||
<Col lg={6}>
|
<div className="bb-acp-sidebar-title">{t('acp.client_communication')}</div>
|
||||||
<Form.Group>
|
<div className="list-group">
|
||||||
<Form.Label>{t('acp.logo_dark')}</Form.Label>
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
<div
|
{t('acp.authentication')}
|
||||||
{...darkLogoDropzone.getRootProps({
|
</button>
|
||||||
className: 'bb-dropzone',
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
})}
|
{t('acp.email_settings')}
|
||||||
>
|
</button>
|
||||||
<input {...darkLogoDropzone.getInputProps()} />
|
|
||||||
{generalSettings.logo_dark ? (
|
|
||||||
<div className="bb-dropzone-preview">
|
|
||||||
<img src={generalSettings.logo_dark} alt={t('acp.logo_dark')} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bb-dropzone-placeholder">
|
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
|
||||||
<span>{t('acp.logo_upload')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</div>
|
||||||
</Col>
|
<div className="bb-acp-sidebar-section">
|
||||||
<Col lg={6}>
|
<div className="bb-acp-sidebar-title">{t('acp.server_configuration')}</div>
|
||||||
<Form.Group>
|
<div className="list-group">
|
||||||
<Form.Label>{t('acp.logo_light')}</Form.Label>
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
<div
|
{t('acp.security_settings')}
|
||||||
{...lightLogoDropzone.getRootProps({
|
</button>
|
||||||
className: 'bb-dropzone',
|
<button type="button" className="list-group-item list-group-item-action">
|
||||||
})}
|
{t('acp.search_settings')}
|
||||||
>
|
</button>
|
||||||
<input {...lightLogoDropzone.getInputProps()} />
|
|
||||||
{generalSettings.logo_light ? (
|
|
||||||
<div className="bb-dropzone-preview">
|
|
||||||
<img src={generalSettings.logo_light} alt={t('acp.logo_light')} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bb-dropzone-placeholder">
|
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
|
||||||
<span>{t('acp.logo_upload')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={12}>
|
</Col>
|
||||||
<Accordion className="bb-acp-accordion">
|
<Col lg={9} xl={10}>
|
||||||
<Accordion.Item eventKey="favicons">
|
<div className="bb-acp-panel mb-4">
|
||||||
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
|
<div className="bb-acp-panel-header">
|
||||||
<Accordion.Body>
|
<h5 className="mb-1">{t('acp.welcome_title')}</h5>
|
||||||
<Row className="g-3">
|
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
|
||||||
<Col lg={4}>
|
</div>
|
||||||
<Form.Group>
|
</div>
|
||||||
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
|
{generalError && <p className="text-danger">{generalError}</p>}
|
||||||
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
|
<div className="bb-acp-panel">
|
||||||
<input {...faviconIcoDropzone.getInputProps()} />
|
<div className="bb-acp-panel-header">
|
||||||
{generalSettings.favicon_ico ? (
|
<h5 className="mb-0">{t('acp.general_settings')}</h5>
|
||||||
<div className="bb-dropzone-preview">
|
</div>
|
||||||
<img src={generalSettings.favicon_ico} alt={t('acp.favicon_ico')} />
|
<div className="bb-acp-panel-body">
|
||||||
</div>
|
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
|
||||||
) : (
|
<Row className="g-3">
|
||||||
<div className="bb-dropzone-placeholder">
|
<Col lg={6}>
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
<Form.Group>
|
||||||
<span>{t('acp.logo_upload')}</span>
|
<Form.Label>{t('acp.forum_name')}</Form.Label>
|
||||||
</div>
|
<Form.Control
|
||||||
)}
|
type="text"
|
||||||
</div>
|
value={generalSettings.forum_name}
|
||||||
</Form.Group>
|
onChange={(event) =>
|
||||||
</Col>
|
setGeneralSettings((prev) => ({
|
||||||
<Col lg={4}>
|
...prev,
|
||||||
<Form.Group>
|
forum_name: event.target.value,
|
||||||
<Form.Label>{t('acp.favicon_16')}</Form.Label>
|
}))
|
||||||
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
}
|
||||||
<input {...favicon16Dropzone.getInputProps()} />
|
/>
|
||||||
{generalSettings.favicon_16 ? (
|
</Form.Group>
|
||||||
<div className="bb-dropzone-preview">
|
<Form.Group className="mt-2">
|
||||||
<img src={generalSettings.favicon_16} alt={t('acp.favicon_16')} />
|
<Form.Check
|
||||||
</div>
|
type="checkbox"
|
||||||
) : (
|
id="acp-show-header-name"
|
||||||
<div className="bb-dropzone-placeholder">
|
label={t('acp.show_header_name')}
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
checked={generalSettings.show_header_name !== 'false'}
|
||||||
<span>{t('acp.logo_upload')}</span>
|
onChange={(event) =>
|
||||||
</div>
|
setGeneralSettings((prev) => ({
|
||||||
)}
|
...prev,
|
||||||
</div>
|
show_header_name: event.target.checked ? 'true' : 'false',
|
||||||
</Form.Group>
|
}))
|
||||||
</Col>
|
}
|
||||||
<Col lg={4}>
|
/>
|
||||||
<Form.Group>
|
</Form.Group>
|
||||||
<Form.Label>{t('acp.favicon_32')}</Form.Label>
|
</Col>
|
||||||
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
<Col lg={6}>
|
||||||
<input {...favicon32Dropzone.getInputProps()} />
|
<Form.Group>
|
||||||
{generalSettings.favicon_32 ? (
|
<Form.Label>{t('acp.default_theme')}</Form.Label>
|
||||||
<div className="bb-dropzone-preview">
|
<Form.Select
|
||||||
<img src={generalSettings.favicon_32} alt={t('acp.favicon_32')} />
|
value={generalSettings.default_theme}
|
||||||
</div>
|
onChange={(event) => handleDefaultThemeChange(event.target.value)}
|
||||||
) : (
|
>
|
||||||
<div className="bb-dropzone-placeholder">
|
<option value="auto">{t('ucp.system_default')}</option>
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
<option value="dark">{t('nav.theme_dark')}</option>
|
||||||
<span>{t('acp.logo_upload')}</span>
|
<option value="light">{t('nav.theme_light')}</option>
|
||||||
</div>
|
</Form.Select>
|
||||||
)}
|
</Form.Group>
|
||||||
</div>
|
</Col>
|
||||||
</Form.Group>
|
<Col lg={6}>
|
||||||
</Col>
|
<Form.Group>
|
||||||
<Col lg={4}>
|
<Form.Label>{t('acp.accent_dark')}</Form.Label>
|
||||||
<Form.Group>
|
<div className="d-flex align-items-center gap-2">
|
||||||
<Form.Label>{t('acp.favicon_48')}</Form.Label>
|
<Form.Control
|
||||||
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
type="text"
|
||||||
<input {...favicon48Dropzone.getInputProps()} />
|
value={generalSettings.accent_color_dark}
|
||||||
{generalSettings.favicon_48 ? (
|
onChange={(event) =>
|
||||||
<div className="bb-dropzone-preview">
|
setGeneralSettings((prev) => ({
|
||||||
<img src={generalSettings.favicon_48} alt={t('acp.favicon_48')} />
|
...prev,
|
||||||
</div>
|
accent_color_dark: event.target.value,
|
||||||
) : (
|
}))
|
||||||
<div className="bb-dropzone-placeholder">
|
}
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
placeholder="#f29b3f"
|
||||||
<span>{t('acp.logo_upload')}</span>
|
/>
|
||||||
</div>
|
<Form.Control
|
||||||
)}
|
type="color"
|
||||||
</div>
|
value={generalSettings.accent_color_dark || '#f29b3f'}
|
||||||
</Form.Group>
|
onChange={(event) =>
|
||||||
</Col>
|
setGeneralSettings((prev) => ({
|
||||||
<Col lg={4}>
|
...prev,
|
||||||
<Form.Group>
|
accent_color_dark: event.target.value,
|
||||||
<Form.Label>{t('acp.favicon_64')}</Form.Label>
|
}))
|
||||||
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
}
|
||||||
<input {...favicon64Dropzone.getInputProps()} />
|
/>
|
||||||
{generalSettings.favicon_64 ? (
|
</div>
|
||||||
<div className="bb-dropzone-preview">
|
</Form.Group>
|
||||||
<img src={generalSettings.favicon_64} alt={t('acp.favicon_64')} />
|
</Col>
|
||||||
</div>
|
<Col lg={6}>
|
||||||
) : (
|
<Form.Group>
|
||||||
<div className="bb-dropzone-placeholder">
|
<Form.Label>{t('acp.accent_light')}</Form.Label>
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
<div className="d-flex align-items-center gap-2">
|
||||||
<span>{t('acp.logo_upload')}</span>
|
<Form.Control
|
||||||
</div>
|
type="text"
|
||||||
)}
|
value={generalSettings.accent_color_light}
|
||||||
</div>
|
onChange={(event) =>
|
||||||
</Form.Group>
|
setGeneralSettings((prev) => ({
|
||||||
</Col>
|
...prev,
|
||||||
<Col lg={4}>
|
accent_color_light: event.target.value,
|
||||||
<Form.Group>
|
}))
|
||||||
<Form.Label>{t('acp.favicon_128')}</Form.Label>
|
}
|
||||||
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
placeholder="#f29b3f"
|
||||||
<input {...favicon128Dropzone.getInputProps()} />
|
/>
|
||||||
{generalSettings.favicon_128 ? (
|
<Form.Control
|
||||||
<div className="bb-dropzone-preview">
|
type="color"
|
||||||
<img src={generalSettings.favicon_128} alt={t('acp.favicon_128')} />
|
value={generalSettings.accent_color_light || '#f29b3f'}
|
||||||
</div>
|
onChange={(event) =>
|
||||||
) : (
|
setGeneralSettings((prev) => ({
|
||||||
<div className="bb-dropzone-placeholder">
|
...prev,
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
accent_color_light: event.target.value,
|
||||||
<span>{t('acp.logo_upload')}</span>
|
}))
|
||||||
</div>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Col>
|
</Col>
|
||||||
<Col lg={4}>
|
<Col lg={6}>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label>{t('acp.favicon_256')}</Form.Label>
|
<Form.Label>{t('acp.logo_dark')}</Form.Label>
|
||||||
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
<div
|
||||||
<input {...favicon256Dropzone.getInputProps()} />
|
{...darkLogoDropzone.getRootProps({
|
||||||
{generalSettings.favicon_256 ? (
|
className: 'bb-dropzone',
|
||||||
<div className="bb-dropzone-preview">
|
})}
|
||||||
<img src={generalSettings.favicon_256} alt={t('acp.favicon_256')} />
|
>
|
||||||
</div>
|
<input {...darkLogoDropzone.getInputProps()} />
|
||||||
) : (
|
{generalSettings.logo_dark ? (
|
||||||
<div className="bb-dropzone-placeholder">
|
<div className="bb-dropzone-preview">
|
||||||
<i className="bi bi-upload" aria-hidden="true" />
|
<img src={generalSettings.logo_dark} alt={t('acp.logo_dark')} />
|
||||||
<span>{t('acp.logo_upload')}</span>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="bb-dropzone-placeholder">
|
||||||
</div>
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
</Form.Group>
|
<span>{t('acp.logo_upload')}</span>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
)}
|
||||||
</Accordion.Body>
|
</div>
|
||||||
</Accordion.Item>
|
</Form.Group>
|
||||||
</Accordion>
|
</Col>
|
||||||
</Col>
|
<Col lg={6}>
|
||||||
<Col xs={12} className="d-flex justify-content-end">
|
<Form.Group>
|
||||||
<Button
|
<Form.Label>{t('acp.logo_light')}</Form.Label>
|
||||||
type="submit"
|
<div
|
||||||
className="bb-accent-button"
|
{...lightLogoDropzone.getRootProps({
|
||||||
disabled={generalSaving || generalUploading}
|
className: 'bb-dropzone',
|
||||||
>
|
})}
|
||||||
{generalSaving ? t('form.saving') : t('acp.save')}
|
>
|
||||||
</Button>
|
<input {...lightLogoDropzone.getInputProps()} />
|
||||||
</Col>
|
{generalSettings.logo_light ? (
|
||||||
</Row>
|
<div className="bb-dropzone-preview">
|
||||||
</Form>
|
<img src={generalSettings.logo_light} alt={t('acp.logo_light')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12}>
|
||||||
|
<Accordion className="bb-acp-accordion">
|
||||||
|
<Accordion.Item eventKey="favicons">
|
||||||
|
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
|
||||||
|
<Accordion.Body>
|
||||||
|
<Row className="g-3">
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
|
||||||
|
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...faviconIcoDropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_ico ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_ico} alt={t('acp.favicon_ico')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_16')}</Form.Label>
|
||||||
|
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon16Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_16 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_16} alt={t('acp.favicon_16')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_32')}</Form.Label>
|
||||||
|
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon32Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_32 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_32} alt={t('acp.favicon_32')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_48')}</Form.Label>
|
||||||
|
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon48Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_48 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_48} alt={t('acp.favicon_48')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_64')}</Form.Label>
|
||||||
|
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon64Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_64 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_64} alt={t('acp.favicon_64')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_128')}</Form.Label>
|
||||||
|
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon128Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_128 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_128} alt={t('acp.favicon_128')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>{t('acp.favicon_256')}</Form.Label>
|
||||||
|
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
|
||||||
|
<input {...favicon256Dropzone.getInputProps()} />
|
||||||
|
{generalSettings.favicon_256 ? (
|
||||||
|
<div className="bb-dropzone-preview">
|
||||||
|
<img src={generalSettings.favicon_256} alt={t('acp.favicon_256')} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bb-dropzone-placeholder">
|
||||||
|
<i className="bi bi-upload" aria-hidden="true" />
|
||||||
|
<span>{t('acp.logo_upload')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Accordion.Body>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} className="d-flex justify-content-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={generalSaving || generalUploading}
|
||||||
|
>
|
||||||
|
{generalSaving ? t('form.saving') : t('acp.save')}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="forums" title={t('acp.forums')}>
|
<Tab eventKey="forums" title={t('acp.forums')}>
|
||||||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
<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>}
|
{attachmentGroupsError && <p className="text-danger">{attachmentGroupsError}</p>}
|
||||||
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
|
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
|
||||||
<div className="bb-attachment-admin">
|
<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="bb-attachment-admin-section">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 className="mb-0">{t('attachment.groups_title')}</h5>
|
<h5 className="mb-0">{t('attachment.groups_title')}</h5>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function ThreadView() {
|
|||||||
const [previewHtml, setPreviewHtml] = useState('')
|
const [previewHtml, setPreviewHtml] = useState('')
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
const [previewUrls, setPreviewUrls] = useState([])
|
const [previewUrls, setPreviewUrls] = useState([])
|
||||||
|
const [lightboxImage, setLightboxImage] = useState('')
|
||||||
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
|
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
|
||||||
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
|
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
|
||||||
disableBbcode: false,
|
disableBbcode: false,
|
||||||
@@ -452,19 +453,40 @@ export default function ThreadView() {
|
|||||||
return (
|
return (
|
||||||
<div className="bb-attachment-list">
|
<div className="bb-attachment-list">
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<a
|
attachment.is_image ? (
|
||||||
key={attachment.id}
|
<button
|
||||||
href={attachment.download_url}
|
key={attachment.id}
|
||||||
className="bb-attachment-item"
|
type="button"
|
||||||
download
|
className="bb-attachment-item border-0 text-start"
|
||||||
>
|
onClick={() => setLightboxImage(attachment.download_url)}
|
||||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
>
|
||||||
<span className="bb-attachment-name">{attachment.original_name}</span>
|
<img
|
||||||
<span className="bb-attachment-meta">
|
src={attachment.thumbnail_url || attachment.download_url}
|
||||||
{attachment.mime_type}
|
alt={attachment.original_name}
|
||||||
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
|
className="img-fluid rounded"
|
||||||
</span>
|
style={{ width: 72, height: 72, objectFit: 'cover' }}
|
||||||
</a>
|
/>
|
||||||
|
<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}
|
||||||
|
className="bb-attachment-item"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -727,6 +749,12 @@ export default function ThreadView() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bb-post-body"
|
className="bb-post-body"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target?.tagName === 'IMG') {
|
||||||
|
event.preventDefault()
|
||||||
|
setLightboxImage(event.target.src)
|
||||||
|
}
|
||||||
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
|
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
|
||||||
/>
|
/>
|
||||||
{renderAttachments(post.attachments)}
|
{renderAttachments(post.attachments)}
|
||||||
@@ -852,6 +880,18 @@ export default function ThreadView() {
|
|||||||
/>
|
/>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,16 @@
|
|||||||
"acp.forums_tree": "Forenbaum",
|
"acp.forums_tree": "Forenbaum",
|
||||||
"acp.forums_type": "Typ",
|
"acp.forums_type": "Typ",
|
||||||
"acp.general": "Allgemein",
|
"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.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
|
||||||
"acp.loading": "Laden...",
|
"acp.loading": "Laden...",
|
||||||
"acp.new_category": "Neue Kategorie",
|
"acp.new_category": "Neue Kategorie",
|
||||||
@@ -217,6 +227,12 @@
|
|||||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||||
"ucp.custom_color": "Eigene Farbe",
|
"ucp.custom_color": "Eigene Farbe",
|
||||||
"attachment.groups_title": "Anhanggruppen",
|
"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": "Neue Anhanggruppe",
|
||||||
"attachment.group_create_title": "Anhanggruppe erstellen",
|
"attachment.group_create_title": "Anhanggruppe erstellen",
|
||||||
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
|
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
|
||||||
|
|||||||
@@ -51,6 +51,16 @@
|
|||||||
"acp.forums_tree": "Forum tree",
|
"acp.forums_tree": "Forum tree",
|
||||||
"acp.forums_type": "Type",
|
"acp.forums_type": "Type",
|
||||||
"acp.general": "General",
|
"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.general_hint": "Global settings and board configuration will appear here.",
|
||||||
"acp.loading": "Loading...",
|
"acp.loading": "Loading...",
|
||||||
"acp.new_category": "New category",
|
"acp.new_category": "New category",
|
||||||
@@ -217,6 +227,12 @@
|
|||||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||||
"ucp.custom_color": "Custom color",
|
"ucp.custom_color": "Custom color",
|
||||||
"attachment.groups_title": "Attachment groups",
|
"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": "New attachment group",
|
||||||
"attachment.group_create_title": "Create attachment group",
|
"attachment.group_create_title": "Create attachment group",
|
||||||
"attachment.group_edit_title": "Edit 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::post('/attachments', [AttachmentController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
|
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
|
||||||
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download']);
|
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::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/forums', [ForumController::class, 'index']);
|
Route::get('/forums', [ForumController::class, 'index']);
|
||||||
|
|||||||
Reference in New Issue
Block a user