From c33cde6f04e57e65f95e5d2ab14e2b1bfe5bc957 Mon Sep 17 00:00:00 2001 From: tracer Date: Wed, 28 Jan 2026 19:34:25 +0100 Subject: [PATCH] added attchments --- app/Actions/BbcodeFormatter.php | 58 + app/Http/Controllers/AttachmentController.php | 300 +++++ .../AttachmentExtensionController.php | 147 ++ .../Controllers/AttachmentGroupController.php | 190 +++ app/Http/Controllers/PostController.php | 68 + app/Http/Controllers/PreviewController.php | 20 + app/Http/Controllers/ThreadController.php | 70 + app/Models/Attachment.php | 70 + app/Models/AttachmentExtension.php | 31 + app/Models/AttachmentGroup.php | 46 + app/Models/Post.php | 6 + app/Models/Thread.php | 6 + composer.json | 1 + composer.lock | 524 +++++--- ..._01_24_140100_create_attachments_table.php | 40 + ..._150000_create_attachment_groups_table.php | 54 + ...100_create_attachment_extensions_table.php | 65 + ..._attachment_group_to_attachments_table.php | 53 + ...achment_type_id_from_attachments_table.php | 35 + ..._24_150400_drop_attachment_types_table.php | 23 + ..._150500_move_group_mimes_to_extensions.php | 77 ++ ...000_add_hierarchy_to_attachment_groups.php | 41 + package.json | 2 +- resources/js/api/client.js | 91 ++ resources/js/index.css | 443 +++++- resources/js/pages/Acp.jsx | 1190 ++++++++++++++++- resources/js/pages/ForumView.jsx | 503 ++++++- resources/js/pages/Home.jsx | 2 +- resources/js/pages/ThreadView.jsx | 528 +++++++- resources/lang/de.json | 63 + resources/lang/en.json | 63 + routes/api.php | 21 + 32 files changed, 4618 insertions(+), 213 deletions(-) create mode 100644 app/Actions/BbcodeFormatter.php create mode 100644 app/Http/Controllers/AttachmentController.php create mode 100644 app/Http/Controllers/AttachmentExtensionController.php create mode 100644 app/Http/Controllers/AttachmentGroupController.php create mode 100644 app/Http/Controllers/PreviewController.php create mode 100644 app/Models/Attachment.php create mode 100644 app/Models/AttachmentExtension.php create mode 100644 app/Models/AttachmentGroup.php create mode 100644 database/migrations/2026_01_24_140100_create_attachments_table.php create mode 100644 database/migrations/2026_01_24_150000_create_attachment_groups_table.php create mode 100644 database/migrations/2026_01_24_150100_create_attachment_extensions_table.php create mode 100644 database/migrations/2026_01_24_150200_add_attachment_group_to_attachments_table.php create mode 100644 database/migrations/2026_01_24_150300_drop_attachment_type_id_from_attachments_table.php create mode 100644 database/migrations/2026_01_24_150400_drop_attachment_types_table.php create mode 100644 database/migrations/2026_01_24_150500_move_group_mimes_to_extensions.php create mode 100644 database/migrations/2026_01_25_120000_add_hierarchy_to_attachment_groups.php diff --git a/app/Actions/BbcodeFormatter.php b/app/Actions/BbcodeFormatter.php new file mode 100644 index 0000000..36e4a38 --- /dev/null +++ b/app/Actions/BbcodeFormatter.php @@ -0,0 +1,58 @@ +parse($text); + + return self::$renderer->render($xml); + } + + private static function build(): array + { + $configurator = new Configurator(); + $bbcodes = $configurator->plugins->load('BBCodes'); + $bbcodes->addFromRepository('B'); + $bbcodes->addFromRepository('I'); + $bbcodes->addFromRepository('U'); + $bbcodes->addFromRepository('S'); + $bbcodes->addFromRepository('URL'); + $bbcodes->addFromRepository('IMG'); + $bbcodes->addFromRepository('QUOTE'); + $bbcodes->addFromRepository('CODE'); + $bbcodes->addFromRepository('LIST'); + $bbcodes->addFromRepository('*'); + + $configurator->tags->add('BR')->template = '
'; + + $bundle = $configurator->finalize(); + $parser = $bundle['parser'] ?? null; + $renderer = $bundle['renderer'] ?? null; + + if (!$parser || !$renderer) { + throw new \RuntimeException('Unable to initialize BBCode formatter.'); + } + + return [$parser, $renderer]; + } +} diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php new file mode 100644 index 0000000..2268886 --- /dev/null +++ b/app/Http/Controllers/AttachmentController.php @@ -0,0 +1,300 @@ +with(['extension', 'group']) + ->withoutTrashed(); + + $threadParam = $request->query('thread'); + $postParam = $request->query('post'); + + if ($threadParam) { + $threadId = $this->parseThreadId($threadParam); + if ($threadId !== null) { + $query->where('thread_id', $threadId); + } + } + + if ($postParam) { + $postId = $this->parsePostId($postParam); + if ($postId !== null) { + $query->where('post_id', $postId); + } + } + + $attachments = $query + ->latest('created_at') + ->get() + ->map(fn (Attachment $attachment) => $this->serializeAttachment($attachment)); + + return response()->json($attachments); + } + + public function store(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + + $data = $request->validate([ + 'thread' => ['nullable', 'string'], + 'post' => ['nullable', 'string'], + 'file' => ['required', 'file'], + ]); + + $threadId = $this->parseThreadId($data['thread'] ?? null); + $postId = $this->parsePostId($data['post'] ?? null); + + if (($threadId && $postId) || (!$threadId && !$postId)) { + return response()->json(['message' => 'Provide either thread or post.'], 422); + } + + $thread = null; + $post = null; + if ($threadId) { + $thread = Thread::query()->findOrFail($threadId); + if (!$this->canManageAttachments($user, $thread->user_id)) { + return response()->json(['message' => 'Not authorized to add attachments.'], 403); + } + } + + if ($postId) { + $post = Post::query()->findOrFail($postId); + if (!$this->canManageAttachments($user, $post->user_id)) { + return response()->json(['message' => 'Not authorized to add attachments.'], 403); + } + } + + $file = $request->file('file'); + if (!$file) { + return response()->json(['message' => 'File missing.'], 422); + } + + $mime = $file->getMimeType() ?? 'application/octet-stream'; + $extension = strtolower((string) $file->getClientOriginalExtension()); + + $extensionRow = $this->resolveExtension($extension); + if (!$extensionRow || !$extensionRow->group || !$extensionRow->group->is_active) { + return response()->json(['message' => 'File type not allowed.'], 422); + } + + $group = $extensionRow->group; + if (!$this->matchesAllowed($mime, $extensionRow->allowed_mimes)) { + return response()->json(['message' => 'File type not allowed.'], 422); + } + + $maxSizeBytes = (int) $group->max_size_kb * 1024; + if ($file->getSize() > $maxSizeBytes) { + return response()->json(['message' => 'File exceeds allowed size.'], 422); + } + + $scopeFolder = $threadId ? "threads/{$threadId}" : "posts/{$postId}"; + $filename = Str::uuid()->toString(); + if ($extension !== '') { + $filename .= ".{$extension}"; + } + + $disk = 'local'; + $path = "attachments/{$scopeFolder}/{$filename}"; + Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename); + + $attachment = Attachment::create([ + 'thread_id' => $threadId, + 'post_id' => $postId, + 'attachment_extension_id' => $extensionRow->id, + 'attachment_group_id' => $group->id, + 'user_id' => $user->id, + 'disk' => $disk, + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'extension' => $extension !== '' ? $extension : null, + 'mime_type' => $mime, + 'size_bytes' => (int) $file->getSize(), + ]); + + $attachment->loadMissing(['extension', 'group']); + + return response()->json($this->serializeAttachment($attachment), 201); + } + + public function show(Attachment $attachment): JsonResponse + { + if (!$this->canViewAttachment($attachment)) { + return response()->json(['message' => 'Not found.'], 404); + } + + $attachment->loadMissing(['extension', 'group']); + + return response()->json($this->serializeAttachment($attachment)); + } + + public function download(Attachment $attachment): Response + { + if (!$this->canViewAttachment($attachment)) { + abort(404); + } + + $disk = Storage::disk($attachment->disk); + if (!$disk->exists($attachment->path)) { + abort(404); + } + + $mime = $attachment->mime_type ?: 'application/octet-stream'; + + return $disk->download($attachment->path, $attachment->original_name, [ + 'Content-Type' => $mime, + ]); + } + + public function destroy(Request $request, Attachment $attachment): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + + if (!$this->canManageAttachments($user, $attachment->user_id)) { + return response()->json(['message' => 'Not authorized to delete attachments.'], 403); + } + + $attachment->delete(); + + return response()->json(null, 204); + } + + private function resolveExtension(string $extension): ?AttachmentExtension + { + if ($extension === '') { + return null; + } + + return AttachmentExtension::query() + ->where('extension', strtolower($extension)) + ->with('group') + ->first(); + } + + private function matchesAllowed(string $value, ?array $allowed): bool + { + if (!$allowed || count($allowed) === 0) { + return true; + } + + $normalized = strtolower(trim($value)); + + foreach ($allowed as $entry) { + if (strtolower(trim((string) $entry)) === $normalized) { + return true; + } + } + + return false; + } + + private function parseThreadId(?string $value): ?int + { + if (!$value) { + return null; + } + + if (preg_match('#/threads/(\d+)$#', $value, $matches)) { + return (int) $matches[1]; + } + + if (is_numeric($value)) { + return (int) $value; + } + + return null; + } + + private function parsePostId(?string $value): ?int + { + if (!$value) { + return null; + } + + if (preg_match('#/posts/(\d+)$#', $value, $matches)) { + return (int) $matches[1]; + } + + if (is_numeric($value)) { + return (int) $value; + } + + return null; + } + + private function canViewAttachment(Attachment $attachment): bool + { + if ($attachment->trashed()) { + return false; + } + + if ($attachment->thread_id) { + $thread = Thread::withTrashed()->find($attachment->thread_id); + return $thread && !$thread->trashed(); + } + + if ($attachment->post_id) { + $post = Post::withTrashed()->find($attachment->post_id); + if (!$post || $post->trashed()) { + return false; + } + $thread = Thread::withTrashed()->find($post->thread_id); + return $thread && !$thread->trashed(); + } + + return false; + } + + private function canManageAttachments($user, ?int $ownerId): bool + { + if (!$user) { + return false; + } + + if ($user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return true; + } + + return $ownerId !== null && $ownerId === $user->id; + } + + private function serializeAttachment(Attachment $attachment): array + { + return [ + 'id' => $attachment->id, + 'thread_id' => $attachment->thread_id, + 'post_id' => $attachment->post_id, + 'extension' => $attachment->extension, + 'group' => $attachment->group ? [ + 'id' => $attachment->group->id, + 'name' => $attachment->group->name, + 'category' => $attachment->group->category, + 'max_size_kb' => $attachment->group->max_size_kb, + ] : null, + 'original_name' => $attachment->original_name, + 'mime_type' => $attachment->mime_type, + 'size_bytes' => $attachment->size_bytes, + 'download_url' => "/api/attachments/{$attachment->id}/download", + 'created_at' => $attachment->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/AttachmentExtensionController.php b/app/Http/Controllers/AttachmentExtensionController.php new file mode 100644 index 0000000..bf614b9 --- /dev/null +++ b/app/Http/Controllers/AttachmentExtensionController.php @@ -0,0 +1,147 @@ +user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return null; + } + + public function index(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $extensions = AttachmentExtension::query() + ->with('group') + ->orderBy('extension') + ->get() + ->map(fn (AttachmentExtension $extension) => $this->serializeExtension($extension)); + + return response()->json($extensions); + } + + public function publicIndex(): JsonResponse + { + $extensions = AttachmentExtension::query() + ->whereNotNull('attachment_group_id') + ->whereHas('group', fn ($query) => $query->where('is_active', true)) + ->orderBy('extension') + ->pluck('extension') + ->filter() + ->values(); + + return response()->json($extensions); + } + + public function store(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $this->validatePayload($request, true); + $extension = $this->normalizeExtension($data['extension']); + if ($extension === '') { + return response()->json(['message' => 'Invalid extension.'], 422); + } + + if (AttachmentExtension::query()->where('extension', $extension)->exists()) { + return response()->json(['message' => 'Extension already exists.'], 422); + } + + $created = AttachmentExtension::create([ + 'extension' => $extension, + 'attachment_group_id' => $data['attachment_group_id'] ?? null, + 'allowed_mimes' => $data['allowed_mimes'] ?? null, + ]); + + $created->load('group'); + + return response()->json($this->serializeExtension($created), 201); + } + + public function update(Request $request, AttachmentExtension $attachmentExtension): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $this->validatePayload($request, false); + + if (array_key_exists('attachment_group_id', $data)) { + $attachmentExtension->attachment_group_id = $data['attachment_group_id']; + } + if (array_key_exists('allowed_mimes', $data)) { + $attachmentExtension->allowed_mimes = $data['allowed_mimes']; + } + + $attachmentExtension->save(); + $attachmentExtension->load('group'); + + return response()->json($this->serializeExtension($attachmentExtension)); + } + + public function destroy(Request $request, AttachmentExtension $attachmentExtension): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + if (Attachment::query()->where('attachment_extension_id', $attachmentExtension->id)->exists()) { + return response()->json(['message' => 'Extension is in use.'], 422); + } + + $attachmentExtension->delete(); + + return response()->json(null, 204); + } + + private function validatePayload(Request $request, bool $requireExtension): array + { + $rules = [ + 'attachment_group_id' => ['nullable', 'integer', 'exists:attachment_groups,id'], + 'allowed_mimes' => ['nullable', 'array'], + 'allowed_mimes.*' => ['string', 'max:150'], + ]; + + if ($requireExtension) { + $rules['extension'] = ['required', 'string', 'max:30']; + } + + return $request->validate($rules); + } + + private function normalizeExtension(string $value): string + { + return ltrim(strtolower(trim($value)), '.'); + } + + private function serializeExtension(AttachmentExtension $extension): array + { + return [ + 'id' => $extension->id, + 'extension' => $extension->extension, + 'attachment_group_id' => $extension->attachment_group_id, + 'allowed_mimes' => $extension->allowed_mimes, + 'group' => $extension->group ? [ + 'id' => $extension->group->id, + 'name' => $extension->group->name, + 'is_active' => $extension->group->is_active, + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/AttachmentGroupController.php b/app/Http/Controllers/AttachmentGroupController.php new file mode 100644 index 0000000..7145839 --- /dev/null +++ b/app/Http/Controllers/AttachmentGroupController.php @@ -0,0 +1,190 @@ +user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return null; + } + + public function index(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $groups = AttachmentGroup::query() + ->withCount('extensions') + ->orderBy('parent_id') + ->orderBy('position') + ->orderBy('name') + ->get() + ->map(fn (AttachmentGroup $group) => $this->serializeGroup($group)); + + return response()->json($groups); + } + + public function store(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $this->validatePayload($request); + $name = trim($data['name']); + $parentId = $this->normalizeParentId($data['parent_id'] ?? null); + + if (AttachmentGroup::query()->whereRaw('LOWER(name) = ?', [strtolower($name)])->exists()) { + return response()->json(['message' => 'Attachment group already exists.'], 422); + } + + $position = (AttachmentGroup::query() + ->where('parent_id', $parentId) + ->max('position') ?? 0) + 1; + + $group = AttachmentGroup::create([ + 'name' => $name, + 'parent_id' => $parentId, + 'position' => $position, + 'max_size_kb' => $data['max_size_kb'], + 'is_active' => $data['is_active'], + ]); + + $group->loadCount('extensions'); + + return response()->json($this->serializeGroup($group), 201); + } + + public function update(Request $request, AttachmentGroup $attachmentGroup): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $this->validatePayload($request); + $name = trim($data['name']); + $parentId = $this->normalizeParentId($data['parent_id'] ?? null); + $position = $attachmentGroup->position ?? 1; + + if (AttachmentGroup::query() + ->where('id', '!=', $attachmentGroup->id) + ->whereRaw('LOWER(name) = ?', [strtolower($name)]) + ->exists() + ) { + return response()->json(['message' => 'Attachment group already exists.'], 422); + } + + if ($attachmentGroup->parent_id !== $parentId) { + $position = (AttachmentGroup::query() + ->where('parent_id', $parentId) + ->max('position') ?? 0) + 1; + } + + $attachmentGroup->update([ + 'name' => $name, + 'parent_id' => $parentId, + 'position' => $position, + 'max_size_kb' => $data['max_size_kb'], + 'is_active' => $data['is_active'], + ]); + + $attachmentGroup->loadCount('extensions'); + + return response()->json($this->serializeGroup($attachmentGroup)); + } + + public function destroy(Request $request, AttachmentGroup $attachmentGroup): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + if ($attachmentGroup->extensions()->exists()) { + return response()->json(['message' => 'Attachment group has extensions.'], 422); + } + + if (Attachment::query()->where('attachment_group_id', $attachmentGroup->id)->exists()) { + return response()->json(['message' => 'Attachment group is in use.'], 422); + } + + $attachmentGroup->delete(); + + return response()->json(null, 204); + } + + public function reorder(Request $request): JsonResponse + { + if ($error = $this->ensureAdmin($request)) { + return $error; + } + + $data = $request->validate([ + 'parentId' => ['nullable'], + 'orderedIds' => ['required', 'array'], + 'orderedIds.*' => ['integer'], + ]); + + $parentId = $data['parentId'] ?? null; + if ($parentId === '' || $parentId === 'null') { + $parentId = null; + } elseif ($parentId !== null) { + $parentId = (int) $parentId; + } + + foreach ($data['orderedIds'] as $index => $groupId) { + AttachmentGroup::where('id', $groupId) + ->where('parent_id', $parentId) + ->update(['position' => $index + 1]); + } + + return response()->json(['status' => 'ok']); + } + + private function validatePayload(Request $request): array + { + return $request->validate([ + 'name' => ['required', 'string', 'max:150'], + 'parent_id' => ['nullable', 'integer', 'exists:attachment_groups,id'], + 'max_size_kb' => ['required', 'integer', 'min:1', 'max:512000'], + 'is_active' => ['required', 'boolean'], + ]); + } + + private function serializeGroup(AttachmentGroup $group): array + { + return [ + 'id' => $group->id, + 'name' => $group->name, + 'parent_id' => $group->parent_id, + 'position' => $group->position, + 'max_size_kb' => $group->max_size_kb, + 'is_active' => $group->is_active, + 'extensions_count' => $group->extensions_count ?? null, + ]; + } + + private function normalizeParentId($value): ?int + { + if ($value === '' || $value === 'null') { + return null; + } + + if ($value === null) { + return null; + } + + return (int) $value; + } +} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index bda5433..8b252a0 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Actions\BbcodeFormatter; use App\Models\Post; use App\Models\Thread; use Illuminate\Http\JsonResponse; @@ -16,6 +17,8 @@ class PostController extends Controller 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', ]); $threadParam = $request->query('thread'); @@ -54,6 +57,8 @@ class PostController extends Controller 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', ]); return response()->json($this->serializePost($post), 201); @@ -87,9 +92,12 @@ class PostController extends Controller private function serializePost(Post $post): array { + $attachments = $post->relationLoaded('attachments') ? $post->attachments : collect(); + $bodyHtml = $this->renderBody($post->body, $attachments); return [ 'id' => $post->id, 'body' => $post->body, + 'body_html' => $bodyHtml, 'thread' => "/api/threads/{$post->thread_id}", 'user_id' => $post->user_id, 'user_name' => $post->user?->name, @@ -111,9 +119,69 @@ class PostController extends Controller 'user_group_color' => $this->resolveGroupColor($post->user), 'created_at' => $post->created_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(), + 'attachments' => $post->relationLoaded('attachments') + ? $attachments + ->map(fn ($attachment) => [ + 'id' => $attachment->id, + 'group' => $attachment->group ? [ + 'id' => $attachment->group->id, + 'name' => $attachment->group->name, + ] : null, + 'original_name' => $attachment->original_name, + 'extension' => $attachment->extension, + 'mime_type' => $attachment->mime_type, + 'size_bytes' => $attachment->size_bytes, + 'download_url' => "/api/attachments/{$attachment->id}/download", + 'created_at' => $attachment->created_at?->toIso8601String(), + ]) + ->values() + : [], ]; } + private function renderBody(string $body, $attachments): string + { + $replaced = $this->replaceAttachmentTags($body, $attachments); + return BbcodeFormatter::format($replaced); + } + + private function replaceAttachmentTags(string $body, $attachments): string + { + if (!$attachments || count($attachments) === 0) { + return $body; + } + + $map = []; + foreach ($attachments as $attachment) { + $name = strtolower($attachment->original_name ?? ''); + if ($name !== '') { + $map[$name] = [ + 'url' => "/api/attachments/{$attachment->id}/download", + 'mime' => $attachment->mime_type ?? '', + ]; + } + } + + if (!$map) { + return $body; + } + + return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) { + $rawName = trim($matches[1]); + $key = strtolower($rawName); + if (!array_key_exists($key, $map)) { + return $matches[0]; + } + $entry = $map[$key]; + $url = $entry['url']; + $mime = $entry['mime'] ?? ''; + if (str_starts_with($mime, 'image/')) { + return "[img]{$url}[/img]"; + } + return "[url={$url}]{$rawName}[/url]"; + }, $body) ?? $body; + } + private function resolveGroupColor(?\App\Models\User $user): ?string { if (!$user) { diff --git a/app/Http/Controllers/PreviewController.php b/app/Http/Controllers/PreviewController.php new file mode 100644 index 0000000..9a4c3a3 --- /dev/null +++ b/app/Http/Controllers/PreviewController.php @@ -0,0 +1,20 @@ +validate([ + 'body' => ['required', 'string'], + ]); + + return response()->json([ + 'html' => BbcodeFormatter::format($data['body']), + ]); + } +} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index f82488a..e813165 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Forum; use App\Models\Thread; +use App\Actions\BbcodeFormatter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -49,6 +50,8 @@ class ThreadController extends Controller 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', 'latestPost.user.rank', 'latestPost.user.roles', ])->loadCount('posts'); @@ -81,6 +84,8 @@ class ThreadController extends Controller 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', 'latestPost.user.rank', 'latestPost.user.roles', ])->loadCount('posts'); @@ -120,6 +125,8 @@ class ThreadController extends Controller 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', 'latestPost.user.rank', 'latestPost.user.roles', ])->loadCount('posts'); @@ -146,10 +153,13 @@ class ThreadController extends Controller private function serializeThread(Thread $thread): array { + $attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect(); + $bodyHtml = $this->renderBody($thread->body, $attachments); return [ 'id' => $thread->id, 'title' => $thread->title, 'body' => $thread->body, + 'body_html' => $bodyHtml, 'solved' => (bool) $thread->solved, 'forum' => "/api/forums/{$thread->forum_id}", 'user_id' => $thread->user_id, @@ -184,9 +194,69 @@ class ThreadController extends Controller ?? $this->resolveGroupColor($thread->user), 'created_at' => $thread->created_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(), + 'attachments' => $thread->relationLoaded('attachments') + ? $attachments + ->map(fn ($attachment) => [ + 'id' => $attachment->id, + 'group' => $attachment->group ? [ + 'id' => $attachment->group->id, + 'name' => $attachment->group->name, + ] : null, + 'original_name' => $attachment->original_name, + 'extension' => $attachment->extension, + 'mime_type' => $attachment->mime_type, + 'size_bytes' => $attachment->size_bytes, + 'download_url' => "/api/attachments/{$attachment->id}/download", + 'created_at' => $attachment->created_at?->toIso8601String(), + ]) + ->values() + : [], ]; } + private function renderBody(string $body, $attachments): string + { + $replaced = $this->replaceAttachmentTags($body, $attachments); + return BbcodeFormatter::format($replaced); + } + + private function replaceAttachmentTags(string $body, $attachments): string + { + if (!$attachments || count($attachments) === 0) { + return $body; + } + + $map = []; + foreach ($attachments as $attachment) { + $name = strtolower($attachment->original_name ?? ''); + if ($name !== '') { + $map[$name] = [ + 'url' => "/api/attachments/{$attachment->id}/download", + 'mime' => $attachment->mime_type ?? '', + ]; + } + } + + if (!$map) { + return $body; + } + + return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) { + $rawName = trim($matches[1]); + $key = strtolower($rawName); + if (!array_key_exists($key, $map)) { + return $matches[0]; + } + $entry = $map[$key]; + $url = $entry['url']; + $mime = $entry['mime'] ?? ''; + if (str_starts_with($mime, 'image/')) { + return "[img]{$url}[/img]"; + } + return "[url={$url}]{$rawName}[/url]"; + }, $body) ?? $body; + } + private function resolveGroupColor(?\App\Models\User $user): ?string { if (!$user) { diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php new file mode 100644 index 0000000..42fd5af --- /dev/null +++ b/app/Models/Attachment.php @@ -0,0 +1,70 @@ + 'int', + ]; + + public function thread(): BelongsTo + { + return $this->belongsTo(Thread::class); + } + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(AttachmentExtension::class, 'attachment_extension_id'); + } + + public function group(): BelongsTo + { + return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/AttachmentExtension.php b/app/Models/AttachmentExtension.php new file mode 100644 index 0000000..d457936 --- /dev/null +++ b/app/Models/AttachmentExtension.php @@ -0,0 +1,31 @@ + 'array', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id'); + } +} diff --git a/app/Models/AttachmentGroup.php b/app/Models/AttachmentGroup.php new file mode 100644 index 0000000..828c2c5 --- /dev/null +++ b/app/Models/AttachmentGroup.php @@ -0,0 +1,46 @@ + 'bool', + ]; + + public function extensions(): HasMany + { + return $this->hasMany(AttachmentExtension::class, 'attachment_group_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index 1db2323..b41f74b 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property string $body * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $attachments * @property-read \App\Models\Thread $thread * @property-read \App\Models\User|null $user * @method static \Illuminate\Database\Eloquent\Builder|Post newModelQuery() @@ -51,4 +52,9 @@ class Post extends Model { return $this->hasMany(PostThank::class); } + + public function attachments(): HasMany + { + return $this->hasMany(Attachment::class); + } } diff --git a/app/Models/Thread.php b/app/Models/Thread.php index 755c8e9..8a5cb09 100644 --- a/app/Models/Thread.php +++ b/app/Models/Thread.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Forum $forum + * @property-read \Illuminate\Database\Eloquent\Collection $attachments * @property-read \Illuminate\Database\Eloquent\Collection $posts * @property-read int|null $posts_count * @property-read \App\Models\User|null $user @@ -64,6 +65,11 @@ class Thread extends Model return $this->hasMany(Post::class); } + public function attachments(): HasMany + { + return $this->hasMany(Attachment::class); + } + public function latestPost(): HasOne { return $this->hasOne(Post::class)->latestOfMany(); diff --git a/composer.json b/composer.json index ff38159..d5a935b 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "laravel/framework": "^12.0", "laravel/sanctum": "*", "laravel/tinker": "^2.10.1", + "s9e/text-formatter": "^2.5", "ext-pdo": "*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index e5c2469..f9ff8a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e91d3287080a532070e38f61106f41c", + "content-hash": "7db69f19f78b34bf8fe28b66c94c5ea5", "packages": [ { "name": "bacon/bacon-qr-code", @@ -686,24 +686,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -732,7 +732,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -744,7 +744,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1222,16 +1222,16 @@ }, { "name": "laravel/framework", - "version": "v12.44.0", + "version": "v12.48.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "592bbf1c036042958332eb98e3e8131b29102f33" + "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33", - "reference": "592bbf1c036042958332eb98e3e8131b29102f33", + "url": "https://api.github.com/repos/laravel/framework/zipball/0f0974a9769378ccd9c9935c09b9927f3a606830", + "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830", "shasum": "" }, "require": { @@ -1344,7 +1344,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.1", + "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1440,20 +1440,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-23T15:29:43+00:00" + "time": "2026-01-20T16:12:36+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.8", + "version": "v0.3.10", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" + "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", + "url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3", + "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3", "shasum": "" }, "require": { @@ -1497,22 +1497,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.8" + "source": "https://github.com/laravel/prompts/tree/v0.3.10" }, - "time": "2025-11-21T20:52:52+00:00" + "time": "2026-01-13T20:29:29+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.1", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664" + "reference": "dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9", + "reference": "dadd2277ff0f05cdb435c8b6a0bcedcf3b5519a9", "shasum": "" }, "require": { @@ -1562,20 +1562,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-11-21T13:59:03+00:00" + "time": "2026-01-15T14:37:16+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.7", + "version": "v2.0.8", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", "shasum": "" }, "require": { @@ -1623,20 +1623,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-11-21T20:52:36+00:00" + "time": "2026-01-08T16:22:46+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.2", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", "shasum": "" }, "require": { @@ -1645,7 +1645,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -1687,9 +1687,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.2" + "source": "https://github.com/laravel/tinker/tree/v2.11.0" }, - "time": "2025-11-20T16:29:12+00:00" + "time": "2025-12-19T19:16:45+00:00" }, { "name": "league/commonmark", @@ -1882,16 +1882,16 @@ }, { "name": "league/flysystem", - "version": "3.30.2", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", "shasum": "" }, "require": { @@ -1959,22 +1959,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" }, - "time": "2025-11-10T17:13:11+00:00" + "time": "2026-01-23T15:38:47+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.2", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "shasum": "" }, "require": { @@ -2008,9 +2008,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" }, - "time": "2025-11-10T11:23:37+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { "name": "league/mime-type-detection", @@ -2070,20 +2070,20 @@ }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", + "league/uri-interfaces": "^7.8", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2097,11 +2097,11 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2156,7 +2156,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -2164,20 +2164,20 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { @@ -2190,7 +2190,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2240,7 +2240,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -2248,20 +2248,20 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2279,7 +2279,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2339,7 +2339,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2351,7 +2351,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", @@ -2828,16 +2828,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -2887,7 +2887,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -2899,7 +2899,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "pragmarx/google2fa", @@ -3642,6 +3642,169 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "s9e/regexp-builder", + "version": "1.4.6", + "source": { + "type": "git", + "url": "https://github.com/s9e/RegexpBuilder.git", + "reference": "3a646bc7c40dba41903b7065f32230721e00df3a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/s9e/RegexpBuilder/zipball/3a646bc7c40dba41903b7065f32230721e00df3a", + "reference": "3a646bc7c40dba41903b7065f32230721e00df3a", + "shasum": "" + }, + "require": { + "lib-pcre": ">=7.2", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": ">=9.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "s9e\\RegexpBuilder\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Single-purpose library that generates regular expressions that match a list of strings.", + "homepage": "https://github.com/s9e/RegexpBuilder/", + "keywords": [ + "regexp" + ], + "support": { + "issues": "https://github.com/s9e/RegexpBuilder/issues", + "source": "https://github.com/s9e/RegexpBuilder/tree/1.4.6" + }, + "time": "2022-03-05T16:22:35+00:00" + }, + { + "name": "s9e/sweetdom", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/s9e/SweetDOM.git", + "reference": "ef3a7d2745b30b4ad0d1d3d60be391a3604c69dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/s9e/SweetDOM/zipball/ef3a7d2745b30b4ad0d1d3d60be391a3604c69dd", + "reference": "ef3a7d2745b30b4ad0d1d3d60be391a3604c69dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.52", + "phpunit/phpunit": "^10.0", + "s9e/repdoc": "dev-wip" + }, + "type": "library", + "autoload": { + "psr-4": { + "s9e\\SweetDOM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Syntactic sugar for the DOM API with a focus on XSLT 1.0 template manipulation.", + "homepage": "https://github.com/s9e/SweetDOM/", + "keywords": [ + "dom", + "xsl", + "xslt" + ], + "support": { + "issues": "https://github.com/s9e/SweetDOM/issues", + "source": "https://github.com/s9e/SweetDOM/tree/3.4.1" + }, + "time": "2024-03-23T14:03:01+00:00" + }, + { + "name": "s9e/text-formatter", + "version": "2.19.3", + "source": { + "type": "git", + "url": "https://github.com/s9e/TextFormatter.git", + "reference": "aee579c12d05ca3053f9b9abdb8c479c0f2fbe69" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/s9e/TextFormatter/zipball/aee579c12d05ca3053f9b9abdb8c479c0f2fbe69", + "reference": "aee579c12d05ca3053f9b9abdb8c479c0f2fbe69", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "lib-pcre": ">=8.13", + "php": "^8.1", + "s9e/regexp-builder": "^1.4", + "s9e/sweetdom": "^3.4" + }, + "require-dev": { + "code-lts/doctum": "*", + "friendsofphp/php-cs-fixer": "^3.52", + "matthiasmullie/minify": "*", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "Improves the performance of the MediaEmbed plugin and some JavaScript minifiers", + "ext-intl": "Allows international URLs to be accepted by the URL filter", + "ext-json": "Enables the generation of a JavaScript parser", + "ext-mbstring": "Improves the performance of the PHP renderer", + "ext-tokenizer": "Improves the performance of the PHP renderer", + "ext-xsl": "Enables the XSLT renderer", + "ext-zlib": "Enables gzip compression when scraping content via the MediaEmbed plugin" + }, + "type": "library", + "extra": { + "version": "2.19.3" + }, + "autoload": { + "psr-4": { + "s9e\\TextFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Multi-purpose text formatting and markup library. Plugins offer support for BBCodes, Markdown, emoticons, HTML, embedding third-party media (YouTube, etc...), enhanced typography and more.", + "homepage": "https://github.com/s9e/TextFormatter/", + "keywords": [ + "bbcode", + "bbcodes", + "blog", + "censor", + "embed", + "emoji", + "emoticons", + "engine", + "forum", + "html", + "markdown", + "markup", + "media", + "parser", + "shortcodes" + ], + "support": { + "issues": "https://github.com/s9e/TextFormatter/issues", + "source": "https://github.com/s9e/TextFormatter/tree/2.19.3" + }, + "time": "2025-11-14T21:26:59+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", @@ -3721,16 +3884,16 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { @@ -3795,7 +3958,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -3815,7 +3978,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", @@ -4198,16 +4361,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { @@ -4242,7 +4405,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -4262,20 +4425,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { @@ -4324,7 +4487,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -4344,20 +4507,20 @@ "type": "tidelift" } ], - "time": "2025-12-07T11:13:10+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.2", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { @@ -4443,7 +4606,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -4463,20 +4626,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:43:37+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { @@ -4527,7 +4690,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.0" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -4547,7 +4710,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T15:26:00+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", @@ -5469,16 +5632,16 @@ }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { @@ -5510,7 +5673,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -5530,20 +5693,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/routing", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4720254cb2644a0b876233d258a32bf017330db7" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", - "reference": "4720254cb2644a0b876233d258a32bf017330db7", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { @@ -5595,7 +5758,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.0" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -5615,7 +5778,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/service-contracts", @@ -5796,16 +5959,16 @@ }, { "name": "symfony/translation", - "version": "v8.0.1", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", + "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", "shasum": "" }, "require": { @@ -5865,7 +6028,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.1" + "source": "https://github.com/symfony/translation/tree/v8.0.3" }, "funding": [ { @@ -5885,7 +6048,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-12-21T10:59:45+00:00" }, { "name": "symfony/translation-contracts", @@ -6049,16 +6212,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { @@ -6112,7 +6275,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -6132,7 +6295,7 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6191,26 +6354,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -6259,7 +6422,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -6271,7 +6434,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -6497,16 +6660,16 @@ }, { "name": "composer/class-map-generator", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6" + "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/2373419b7709815ed323ebf18c3c72d03ff4a8a6", - "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/8f5fa3cc214230e71f54924bd0197a3bcc705eb1", + "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1", "shasum": "" }, "require": { @@ -6550,7 +6713,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.0" + "source": "https://github.com/composer/class-map-generator/tree/1.7.1" }, "funding": [ { @@ -6562,7 +6725,7 @@ "type": "github" } ], - "time": "2025-11-19T10:41:15+00:00" + "time": "2025-12-29T13:15:25+00:00" }, { "name": "composer/pcre", @@ -6909,16 +7072,16 @@ }, { "name": "laravel/pint", - "version": "v1.26.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -6929,9 +7092,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.90.0", - "illuminate/view": "^12.40.1", - "larastan/larastan": "^3.8.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", @@ -6972,20 +7135,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-11-25T21:15:52+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "laravel/sail", - "version": "v1.51.0", + "version": "v1.52.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068" + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068", + "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", "shasum": "" }, "require": { @@ -7035,7 +7198,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-12-09T13:33:49+00:00" + "time": "2026-01-01T02:46:03+00:00" }, { "name": "mockery/mockery", @@ -7734,16 +7897,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.46", + "version": "11.5.49", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" + "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", - "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4f1750675ba411dd6c2d5fa8a3cca07f6742020e", + "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e", "shasum": "" }, "require": { @@ -7757,14 +7920,14 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.2", @@ -7815,7 +7978,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.49" }, "funding": [ { @@ -7839,7 +8002,7 @@ "type": "tidelift" } ], - "time": "2025-12-06T08:01:15+00:00" + "time": "2026-01-24T16:09:28+00:00" }, { "name": "sebastian/cli-parser", @@ -8013,16 +8176,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -8081,7 +8244,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -8101,7 +8264,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -9091,7 +9254,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.4", + "ext-pdo": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/database/migrations/2026_01_24_140100_create_attachments_table.php b/database/migrations/2026_01_24_140100_create_attachments_table.php new file mode 100644 index 0000000..ac84c6a --- /dev/null +++ b/database/migrations/2026_01_24_140100_create_attachments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('thread_id')->nullable()->constrained('threads')->nullOnDelete(); + $table->foreignId('post_id')->nullable()->constrained('posts')->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('disk', 50)->default('local'); + $table->string('path'); + $table->string('original_name'); + $table->string('extension', 30)->nullable(); + $table->string('mime_type', 150); + $table->unsignedBigInteger('size_bytes'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('thread_id', 'idx_attachments_thread_id'); + $table->index('post_id', 'idx_attachments_post_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attachments'); + } +}; diff --git a/database/migrations/2026_01_24_150000_create_attachment_groups_table.php b/database/migrations/2026_01_24_150000_create_attachment_groups_table.php new file mode 100644 index 0000000..68fcec1 --- /dev/null +++ b/database/migrations/2026_01_24_150000_create_attachment_groups_table.php @@ -0,0 +1,54 @@ +id(); + $table->string('name', 150); + $table->unsignedInteger('max_size_kb')->default(25600); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + if (Schema::hasTable('attachment_types')) { + $types = DB::table('attachment_types')->orderBy('id')->get(); + foreach ($types as $type) { + DB::table('attachment_groups')->insert([ + 'name' => $type->label ?? $type->key ?? 'General', + 'max_size_kb' => $type->max_size_kb ?? 25600, + 'is_active' => $type->is_active ?? true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + if (DB::table('attachment_groups')->count() === 0) { + DB::table('attachment_groups')->insert([ + 'name' => 'General', + 'max_size_kb' => 25600, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attachment_groups'); + } +}; diff --git a/database/migrations/2026_01_24_150100_create_attachment_extensions_table.php b/database/migrations/2026_01_24_150100_create_attachment_extensions_table.php new file mode 100644 index 0000000..37625d5 --- /dev/null +++ b/database/migrations/2026_01_24_150100_create_attachment_extensions_table.php @@ -0,0 +1,65 @@ +id(); + $table->string('extension', 30)->unique(); + $table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete(); + $table->json('allowed_mimes')->nullable(); + $table->timestamps(); + }); + + if (Schema::hasTable('attachment_types') && Schema::hasTable('attachment_groups')) { + $groups = DB::table('attachment_groups')->orderBy('id')->get()->values(); + $types = DB::table('attachment_types')->orderBy('id')->get()->values(); + + foreach ($types as $index => $type) { + $group = $groups[$index] ?? null; + if (!$group) { + continue; + } + $extensions = []; + if (!empty($type->allowed_extensions)) { + $decoded = json_decode($type->allowed_extensions, true); + if (is_array($decoded)) { + $extensions = $decoded; + } + } + foreach ($extensions as $ext) { + $ext = strtolower(trim((string) $ext)); + if ($ext === '') { + continue; + } + DB::table('attachment_extensions')->updateOrInsert( + ['extension' => $ext], + [ + 'attachment_group_id' => $group->id, + 'allowed_mimes' => $type->allowed_mimes, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attachment_extensions'); + } +}; diff --git a/database/migrations/2026_01_24_150200_add_attachment_group_to_attachments_table.php b/database/migrations/2026_01_24_150200_add_attachment_group_to_attachments_table.php new file mode 100644 index 0000000..62f809a --- /dev/null +++ b/database/migrations/2026_01_24_150200_add_attachment_group_to_attachments_table.php @@ -0,0 +1,53 @@ +foreignId('attachment_extension_id')->nullable()->constrained('attachment_extensions')->nullOnDelete(); + $table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete(); + $table->index('attachment_extension_id', 'idx_attachments_extension_id'); + $table->index('attachment_group_id', 'idx_attachments_group_id'); + }); + + if (Schema::hasTable('attachment_extensions')) { + $extensions = DB::table('attachment_extensions')->get()->keyBy('extension'); + $attachments = DB::table('attachments')->select('id', 'extension')->get(); + foreach ($attachments as $attachment) { + $ext = strtolower(trim((string) $attachment->extension)); + if ($ext === '' || !$extensions->has($ext)) { + continue; + } + $extRow = $extensions->get($ext); + DB::table('attachments') + ->where('id', $attachment->id) + ->update([ + 'attachment_extension_id' => $extRow->id, + 'attachment_group_id' => $extRow->attachment_group_id, + ]); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('attachments', function (Blueprint $table) { + $table->dropIndex('idx_attachments_extension_id'); + $table->dropIndex('idx_attachments_group_id'); + $table->dropConstrainedForeignId('attachment_extension_id'); + $table->dropConstrainedForeignId('attachment_group_id'); + }); + } +}; diff --git a/database/migrations/2026_01_24_150300_drop_attachment_type_id_from_attachments_table.php b/database/migrations/2026_01_24_150300_drop_attachment_type_id_from_attachments_table.php new file mode 100644 index 0000000..5433b99 --- /dev/null +++ b/database/migrations/2026_01_24_150300_drop_attachment_type_id_from_attachments_table.php @@ -0,0 +1,35 @@ +dropForeign(['attachment_type_id']); + $table->dropIndex('idx_attachments_type_id'); + $table->dropColumn('attachment_type_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (!Schema::hasColumn('attachments', 'attachment_type_id')) { + Schema::table('attachments', function (Blueprint $table) { + $table->foreignId('attachment_type_id')->constrained('attachment_types'); + $table->index('attachment_type_id', 'idx_attachments_type_id'); + }); + } + } +}; diff --git a/database/migrations/2026_01_24_150400_drop_attachment_types_table.php b/database/migrations/2026_01_24_150400_drop_attachment_types_table.php new file mode 100644 index 0000000..62ac3a9 --- /dev/null +++ b/database/migrations/2026_01_24_150400_drop_attachment_types_table.php @@ -0,0 +1,23 @@ +dropColumn('category'); + }); + } + + if (Schema::hasColumn('attachment_groups', 'allowed_mimes')) { + if (Schema::hasTable('attachment_extensions')) { + if (!Schema::hasColumn('attachment_extensions', 'allowed_mimes')) { + Schema::table('attachment_extensions', function (Blueprint $table) { + $table->json('allowed_mimes')->nullable(); + }); + } + + $groups = DB::table('attachment_groups') + ->select('id', 'allowed_mimes') + ->get() + ->keyBy('id'); + + $extensions = DB::table('attachment_extensions') + ->select('id', 'attachment_group_id', 'allowed_mimes') + ->get(); + + foreach ($extensions as $extension) { + if (!empty($extension->allowed_mimes)) { + continue; + } + $group = $groups->get($extension->attachment_group_id); + if (!$group || empty($group->allowed_mimes)) { + continue; + } + DB::table('attachment_extensions') + ->where('id', $extension->id) + ->update([ + 'allowed_mimes' => $group->allowed_mimes, + ]); + } + } + + Schema::table('attachment_groups', function (Blueprint $table) { + $table->dropColumn('allowed_mimes'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (!Schema::hasColumn('attachment_groups', 'category')) { + Schema::table('attachment_groups', function (Blueprint $table) { + $table->string('category', 50)->default('other'); + }); + } + + if (!Schema::hasColumn('attachment_groups', 'allowed_mimes')) { + Schema::table('attachment_groups', function (Blueprint $table) { + $table->json('allowed_mimes')->nullable(); + }); + } + } +}; diff --git a/database/migrations/2026_01_25_120000_add_hierarchy_to_attachment_groups.php b/database/migrations/2026_01_25_120000_add_hierarchy_to_attachment_groups.php new file mode 100644 index 0000000..247eddc --- /dev/null +++ b/database/migrations/2026_01_25_120000_add_hierarchy_to_attachment_groups.php @@ -0,0 +1,41 @@ +foreignId('parent_id')->nullable()->constrained('attachment_groups')->nullOnDelete(); + $table->unsignedInteger('position')->default(1); + $table->index(['parent_id', 'position'], 'idx_attachment_groups_parent_position'); + }); + + $groups = DB::table('attachment_groups')->orderBy('id')->get(); + $position = 1; + foreach ($groups as $group) { + DB::table('attachment_groups') + ->where('id', $group->id) + ->update(['position' => $position++]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('attachment_groups', function (Blueprint $table) { + $table->dropIndex('idx_attachment_groups_parent_position'); + $table->dropConstrainedForeignId('parent_id'); + $table->dropColumn('position'); + }); + } +}; diff --git a/package.json b/package.json index 883b26f..ebcd7ab 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "build": "vite build", - "build:watch": "vite build --watch", "dev": "vite", + "watch": "vite build --watch", "lint": "eslint ." }, "dependencies": { diff --git a/resources/js/api/client.js b/resources/js/api/client.js index b63901a..b6655d8 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -117,6 +117,13 @@ export async function fetchPortalSummary() { return apiFetch('/portal/summary') } +export async function previewBbcode(body) { + return apiFetch('/preview', { + method: 'POST', + body: JSON.stringify({ body }), + }) +} + export async function fetchSetting(key) { // TODO: Prefer fetchSettings() when multiple settings are needed. const cacheBust = Date.now() @@ -256,6 +263,90 @@ export async function updateThreadSolved(threadId, solved) { }) } +export async function listAttachmentsByThread(threadId) { + return getCollection(`/attachments?thread=/api/threads/${threadId}`) +} + +export async function listAttachmentsByPost(postId) { + return getCollection(`/attachments?post=/api/posts/${postId}`) +} + +export async function uploadAttachment({ threadId, postId, file }) { + const body = new FormData() + if (threadId) body.append('thread', `/api/threads/${threadId}`) + if (postId) body.append('post', `/api/posts/${postId}`) + body.append('file', file) + return apiFetch('/attachments', { + method: 'POST', + body, + }) +} + +export async function deleteAttachment(id) { + return apiFetch(`/attachments/${id}`, { + method: 'DELETE', + }) +} + +export async function listAttachmentGroups() { + return getCollection('/attachment-groups') +} + +export async function createAttachmentGroup(payload) { + return apiFetch('/attachment-groups', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export async function updateAttachmentGroup(id, payload) { + return apiFetch(`/attachment-groups/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export async function deleteAttachmentGroup(id) { + return apiFetch(`/attachment-groups/${id}`, { + method: 'DELETE', + }) +} + +export async function reorderAttachmentGroups(parentId, orderedIds) { + return apiFetch('/attachment-groups/reorder', { + method: 'POST', + body: JSON.stringify({ parentId, orderedIds }), + }) +} + +export async function listAttachmentExtensions() { + return getCollection('/attachment-extensions') +} + +export async function listAttachmentExtensionsPublic() { + return getCollection('/attachment-extensions/public') +} + +export async function createAttachmentExtension(payload) { + return apiFetch('/attachment-extensions', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export async function updateAttachmentExtension(id, payload) { + return apiFetch(`/attachment-extensions/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export async function deleteAttachmentExtension(id) { + return apiFetch(`/attachment-extensions/${id}`, { + method: 'DELETE', + }) +} + export async function listPostsByThread(threadId) { return getCollection(`/posts?thread=/api/threads/${threadId}`) } diff --git a/resources/js/index.css b/resources/js/index.css index 4946e58..e7e33f2 100644 --- a/resources/js/index.css +++ b/resources/js/index.css @@ -170,6 +170,322 @@ a { flex-wrap: wrap; } +.bb-thread-attachments { + border: 1px solid var(--bb-border); + border-radius: 12px; + padding: 0.8rem 1rem; + background: #141822; +} + +.bb-thread-attachments-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + font-weight: 600; + color: var(--bb-ink); + margin-bottom: 0.6rem; +} + +.bb-thread-attachments-actions { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.bb-thread-attachments-actions input[type='file'] { + max-width: 280px; + color: var(--bb-ink-muted); +} + +.bb-thread-modal.modal-dialog { + max-width: 95vw !important; + width: 95vw !important; + height: 95vh; + margin: 2.5vh auto; +} + +.bb-thread-modal.modal-dialog .modal-content { + height: 95vh; + width: 100%; +} + +.bb-thread-modal.modal-dialog .modal-body { + overflow-y: auto; +} + +.bb-attachment-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.bb-post-content .bb-attachment-list { + margin-top: 0.4rem; +} + +.bb-attachment-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--bb-ink); + text-decoration: none; + padding: 0.35rem 0.5rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.bb-attachment-item .bi-paperclip { + color: var(--bb-accent, #f29b3f); +} + +.bb-attachment-item:hover { + border-color: var(--bb-accent, #f29b3f); + color: var(--bb-accent, #f29b3f); +} + +.bb-attachment-name { + font-weight: 600; +} + +.bb-attachment-meta { + color: var(--bb-ink-muted); + font-size: 0.8rem; +} + +.bb-attachment-panel { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(18, 23, 33, 0.9); + overflow: hidden; +} + +.bb-attachment-tabs { + display: flex; + gap: 0.4rem; + padding: 0.6rem 0.8rem 0; +} + +.bb-attachment-tab { + border: none; + background: #1a202b; + color: var(--bb-ink-muted); + padding: 0.35rem 0.75rem; + border-radius: 8px 8px 0 0; + font-size: 0.85rem; + font-weight: 600; +} + +.bb-attachment-tab.is-active { + color: var(--bb-accent, #f29b3f); + background: #202735; +} + +.bb-attachment-body { + padding: 0.8rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.bb-attachment-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.8rem; +} + +.bb-attachment-drop { + border: 2px dashed color-mix(in srgb, var(--bb-accent, #f29b3f) 65%, transparent); + border-radius: 12px; + padding: 0.85rem 1rem; + background: rgba(18, 23, 33, 0.6); + color: var(--bb-ink-muted); + text-align: center; + margin-bottom: 0.8rem; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.bb-attachment-drop.is-dragover { + border-color: var(--bb-accent, #f29b3f); + background: rgba(242, 155, 63, 0.12); + color: var(--bb-ink); +} + +.bb-attachment-drop-link { + border: 0; + padding: 0; + background: transparent; + color: var(--bb-accent, #f29b3f); + font-weight: 600; + text-decoration: underline; +} + +.bb-attachment-drop-link:hover { + color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff); +} + +.bb-attachment-input { + display: none; +} + +.bb-attachment-table { + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + overflow: hidden; + margin-bottom: 0; + color: var(--bb-ink); +} + +.bb-attachment-table thead th { + font-size: 0.7rem; + text-transform: none; + letter-spacing: 0.02em; + color: var(--bb-ink-muted); + background: rgba(255, 255, 255, 0.03); + text-align: left; + border-bottom: 0; + padding: 0.6rem 0.8rem; +} + +.bb-attachment-table tbody td { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.6rem 0.8rem; + vertical-align: middle; +} + +.bb-attachment-table thead th:nth-child(3), +.bb-attachment-table tbody td:nth-child(3) { + width: 90px; +} + +.bb-attachment-table thead th:nth-child(4), +.bb-attachment-table tbody td:nth-child(4) { + width: 80px; +} + +.bb-attachment-table thead th:nth-child(5), +.bb-attachment-table tbody td:nth-child(5) { + width: 1%; + white-space: nowrap; +} + +.bb-attachment-name { + color: var(--bb-accent, #f29b3f); + font-weight: 600; +} + +.bb-attachment-size { + color: var(--bb-ink-muted); + font-size: 0.85rem; +} + +.bb-attachment-status { + color: #8bd98b; + font-size: 0.85rem; +} + +.bb-attachment-comment { + background: #202734; + border-color: rgba(255, 255, 255, 0.08); + color: var(--bb-ink); + font-size: 0.85rem; +} + +.bb-attachment-row-actions { + display: inline-flex; + align-items: center; + gap: 0; + justify-content: flex-end; + background: var(--bb-accent, #f29b3f); + border-radius: 10px; + padding: 0.2rem; + width: fit-content; +} + +.bb-attachment-action { + border: 0; + background: transparent; + color: #0e121b; + width: 36px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.bb-attachment-action:hover { + transform: translateY(-1px); + background: color-mix(in srgb, #fff 18%, transparent); + color: #0e121b; +} + +.bb-attachment-remove { + border: none; + background: rgba(255, 255, 255, 0.06); + color: var(--bb-ink-muted); + width: 32px; + height: 28px; + border-radius: 6px; +} + +.bb-attachment-remove:hover { + color: #f07f7f; + background: rgba(240, 127, 127, 0.12); +} + +.bb-attachment-empty { + padding: 0.8rem; + color: var(--bb-ink-muted); + font-size: 0.85rem; +} + +.bb-attachment-options { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.2rem 0; +} + +.bb-attachment-options .form-check-label { + color: var(--bb-ink); +} + +.bb-attachment-options .form-check-input { + background-color: #1a202b; + border-color: rgba(255, 255, 255, 0.2); +} + +.bb-attachment-options .form-check-input:checked { + background-color: var(--bb-accent, #f29b3f); + border-color: var(--bb-accent, #f29b3f); +} + +.form-control:focus, +.form-select:focus, +.form-check-input:focus { + border-color: var(--bb-accent, #f29b3f); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 30%, transparent); +} + +.tr-header { + border-bottom: 3px solid var(--bb-accent, #f29b3f); +} + +.tr-header th { + border-bottom: 3px solid var(--bb-accent, #f29b3f); +} + +.rdt_TableHeadRow { + border-bottom: 3px solid var(--bb-accent, #f29b3f); +} + + .bb-thread-actions { display: flex; align-items: center; @@ -351,6 +667,9 @@ a { .bb-post-content { padding: 1rem 1.35rem 1.2rem; + display: flex; + flex-direction: column; + gap: 0.8rem; } .bb-post-header { @@ -412,21 +731,19 @@ a { .bb-post-content { position: relative; - padding-bottom: 3.5rem; } .bb-post-body { white-space: pre-wrap; color: var(--bb-ink); line-height: 1.6; + flex: 1 1 auto; } .bb-post-footer { - position: absolute; - right: 1rem; - bottom: 1rem; display: flex; justify-content: flex-end; + margin-top: auto; } .bb-thread-reply { @@ -1805,6 +2122,14 @@ a { color: #0e121b; } +.bb-accent-button:disabled, +.bb-accent-button.disabled { + background: var(--bb-accent, #f29b3f); + border-color: var(--bb-accent, #f29b3f); + color: #0e121b; + opacity: 0.6; +} + .modal-content .modal-header { background: #0f1218; color: #e6e8eb; @@ -1921,6 +2246,115 @@ a { gap: 0.6rem; } +.bb-attachment-type-main { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.bb-attachment-type-title { + font-weight: 600; + color: var(--bb-ink); +} + +.bb-attachment-type-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + color: var(--bb-ink-muted); + font-size: 0.85rem; +} + +.bb-attachment-type-rules { + margin-top: 0.2rem; + color: var(--bb-ink-muted); + font-size: 0.8rem; +} + +.bb-attachment-admin { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.bb-attachment-extension-form { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.2fr) minmax(160px, 1fr) auto; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} + +.bb-attachment-extension-table { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.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-header { + font-size: 0.8rem; + color: var(--bb-ink-muted); + background: rgba(15, 19, 27, 0.7); +} + +.bb-attachment-extension-row { + background: rgba(18, 23, 33, 0.8); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.bb-attachment-extension-name { + font-weight: 600; + color: var(--bb-ink); +} + +.bb-attachment-extension-actions { + display: flex; + justify-content: flex-end; +} + +.bb-attachment-extension-meta { + color: var(--bb-ink-muted); + font-size: 0.9rem; +} + +.bb-attachment-tree-toggle { + cursor: pointer; + flex: 1 1 auto; +} + +.bb-attachment-tree-toggle:focus-visible { + outline: 2px solid color-mix(in srgb, var(--bb-accent, #f29b3f) 70%, #000); + outline-offset: 2px; + border-radius: 10px; +} + +@media (max-width: 900px) { + .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 { height: 22px; width: auto; @@ -2184,6 +2618,7 @@ a { } } + .bb-collapse-toggle { width: 20px; height: 20px; diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index 57a17fa..e0f8080 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -27,6 +27,15 @@ import { uploadFavicon, uploadLogo, updateForum, + listAttachmentGroups, + createAttachmentGroup, + updateAttachmentGroup, + deleteAttachmentGroup, + reorderAttachmentGroups, + listAttachmentExtensions, + createAttachmentExtension, + updateAttachmentExtension, + deleteAttachmentExtension, } from '../api/client' export default function Acp({ isAdmin }) { @@ -89,6 +98,38 @@ export default function Acp({ isAdmin }) { color: '', isCore: false, }) + const [attachmentGroups, setAttachmentGroups] = useState([]) + const [attachmentGroupsLoading, setAttachmentGroupsLoading] = useState(false) + const [attachmentGroupsError, setAttachmentGroupsError] = useState('') + const [attachmentGroupSaving, setAttachmentGroupSaving] = useState(false) + const [showAttachmentGroupModal, setShowAttachmentGroupModal] = useState(false) + const [attachmentGroupEdit, setAttachmentGroupEdit] = useState({ + id: null, + name: '', + parentId: '', + max_size_kb: 25600, + is_active: true, + }) + const [attachmentExtensions, setAttachmentExtensions] = useState([]) + const [attachmentExtensionsLoading, setAttachmentExtensionsLoading] = useState(false) + const [attachmentExtensionsError, setAttachmentExtensionsError] = useState('') + const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false) + const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null) + const [attachmentSeedSaving, setAttachmentSeedSaving] = useState(false) + const [showAttachmentExtensionModal, setShowAttachmentExtensionModal] = useState(false) + const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null) + const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false) + const [attachmentExtensionDeleteTarget, setAttachmentExtensionDeleteTarget] = useState(null) + const [newAttachmentExtension, setNewAttachmentExtension] = useState({ + extension: '', + groupId: '', + allowedMimes: '', + }) + const attachmentExtensionInputRef = useRef(null) + const [attachmentGroupCollapsed, setAttachmentGroupCollapsed] = useState(new Set()) + const [attachmentGroupDraggingId, setAttachmentGroupDraggingId] = useState(null) + const [attachmentGroupOverId, setAttachmentGroupOverId] = useState(null) + const attachmentGroupPendingOrder = useRef(null) const [userSaving, setUserSaving] = useState(false) const [generalSaving, setGeneralSaving] = useState(false) const [generalUploading, setGeneralUploading] = useState(false) @@ -797,6 +838,841 @@ export default function Acp({ isAdmin }) { ) }, [roles, roleQuery]) + const formatSizeKb = (kb) => { + if (!kb && kb !== 0) return '' + if (kb < 1024) return `${kb} KB` + return `${(kb / 1024).toFixed(1)} MB` + } + + const attachmentDefaultSeed = [ + { + name: 'Media', + max_size_kb: 25600, + children: [ + { + name: 'Image', + max_size_kb: 25600, + extensions: [ + { ext: 'png', mimes: ['image/png'] }, + { ext: 'jpg', mimes: ['image/jpeg'] }, + { ext: 'jpeg', mimes: ['image/jpeg'] }, + { ext: 'gif', mimes: ['image/gif'] }, + { ext: 'webp', mimes: ['image/webp'] }, + { ext: 'bmp', mimes: ['image/bmp'] }, + { ext: 'svg', mimes: ['image/svg+xml'] }, + ], + }, + { + name: 'Video', + max_size_kb: 102400, + extensions: [ + { ext: 'mp4', mimes: ['video/mp4'] }, + { ext: 'webm', mimes: ['video/webm'] }, + { ext: 'mov', mimes: ['video/quicktime'] }, + { ext: 'avi', mimes: ['video/x-msvideo'] }, + { ext: 'mkv', mimes: ['video/x-matroska'] }, + ], + }, + { + name: 'Audio', + max_size_kb: 25600, + extensions: [ + { ext: 'mp3', mimes: ['audio/mpeg'] }, + { ext: 'wav', mimes: ['audio/wav'] }, + { ext: 'ogg', mimes: ['audio/ogg'] }, + { ext: 'flac', mimes: ['audio/flac'] }, + { ext: 'm4a', mimes: ['audio/mp4'] }, + ], + }, + ], + }, + { + name: 'Files', + max_size_kb: 25600, + children: [ + { + name: 'Archive', + max_size_kb: 51200, + extensions: [ + { ext: 'zip', mimes: ['application/zip'] }, + { ext: 'rar', mimes: ['application/vnd.rar'] }, + { ext: '7z', mimes: ['application/x-7z-compressed'] }, + { ext: 'tar', mimes: ['application/x-tar'] }, + { ext: 'gz', mimes: ['application/gzip'] }, + ], + }, + { + name: 'Documents', + max_size_kb: 25600, + extensions: [ + { ext: 'txt', mimes: ['text/plain'] }, + { ext: 'pdf', mimes: ['application/pdf'] }, + { ext: 'doc', mimes: ['application/msword'] }, + { ext: 'docx', mimes: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'] }, + { ext: 'rtf', mimes: ['application/rtf'] }, + { ext: 'odt', mimes: ['application/vnd.oasis.opendocument.text'] }, + { ext: 'xls', mimes: ['application/vnd.ms-excel'] }, + { ext: 'xlsx', mimes: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] }, + { ext: 'ppt', mimes: ['application/vnd.ms-powerpoint'] }, + { ext: 'pptx', mimes: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'] }, + { ext: 'csv', mimes: ['text/csv'] }, + ], + }, + { + name: 'Other', + max_size_kb: 25600, + extensions: [ + { ext: 'json', mimes: ['application/json'] }, + { ext: 'xml', mimes: ['application/xml', 'text/xml'] }, + { ext: 'log', mimes: ['text/plain'] }, + ], + }, + ], + }, + ] + + const normalizeList = (value) => + value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean) + + const loadAttachmentGroups = async () => { + setAttachmentGroupsLoading(true) + setAttachmentGroupsError('') + try { + const data = await listAttachmentGroups() + setAttachmentGroups(data) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentGroupsLoading(false) + } + } + + const loadAttachmentExtensions = async () => { + setAttachmentExtensionsLoading(true) + setAttachmentExtensionsError('') + try { + const data = await listAttachmentExtensions() + setAttachmentExtensions(data) + } catch (err) { + setAttachmentExtensionsError(err.message) + } finally { + setAttachmentExtensionsLoading(false) + } + } + + const openAttachmentGroupModal = (group = null) => { + if (group) { + setAttachmentGroupEdit({ + id: group.id, + name: group.name || '', + parentId: group.parent_id ? String(group.parent_id) : '', + max_size_kb: group.max_size_kb ?? 25600, + is_active: group.is_active ?? true, + }) + } else { + setAttachmentGroupEdit({ + id: null, + name: '', + parentId: '', + max_size_kb: 25600, + is_active: true, + }) + } + setShowAttachmentGroupModal(true) + } + + const openAttachmentGroupChildModal = (parentId) => { + setAttachmentGroupEdit({ + id: null, + name: '', + parentId: parentId ? String(parentId) : '', + max_size_kb: 25600, + is_active: true, + }) + setShowAttachmentGroupModal(true) + } + + const handleAttachmentGroupSubmit = async (event) => { + event.preventDefault() + setAttachmentGroupSaving(true) + setAttachmentGroupsError('') + try { + const payload = { + name: attachmentGroupEdit.name.trim(), + parent_id: attachmentGroupEdit.parentId ? Number(attachmentGroupEdit.parentId) : null, + max_size_kb: Number(attachmentGroupEdit.max_size_kb) || 1, + is_active: Boolean(attachmentGroupEdit.is_active), + } + + if (attachmentGroupEdit.id) { + const updated = await updateAttachmentGroup(attachmentGroupEdit.id, payload) + setAttachmentGroups((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)) + ) + } else { + const created = await createAttachmentGroup(payload) + setAttachmentGroups((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) + } + + setShowAttachmentGroupModal(false) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentGroupSaving(false) + } + } + + const handleAttachmentGroupDelete = async (group) => { + if (!window.confirm(t('attachment.group_delete_confirm'))) return + setAttachmentGroupSaving(true) + setAttachmentGroupsError('') + try { + await deleteAttachmentGroup(group.id) + setAttachmentGroups((prev) => prev.filter((item) => item.id !== group.id)) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentGroupSaving(false) + } + } + + const handleAttachmentExtensionCreate = async (event) => { + event.preventDefault() + if (!newAttachmentExtension.extension.trim()) return + setAttachmentExtensionSaving(true) + setAttachmentExtensionsError('') + try { + if (attachmentExtensionEdit) { + const payload = { + attachment_group_id: newAttachmentExtension.groupId + ? Number(newAttachmentExtension.groupId) + : null, + allowed_mimes: newAttachmentExtension.allowedMimes + ? normalizeList(newAttachmentExtension.allowedMimes) + : null, + } + const updated = await updateAttachmentExtension(attachmentExtensionEdit.id, payload) + setAttachmentExtensions((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)) + ) + } else { + const payload = { + extension: newAttachmentExtension.extension.trim(), + attachment_group_id: newAttachmentExtension.groupId + ? Number(newAttachmentExtension.groupId) + : null, + allowed_mimes: newAttachmentExtension.allowedMimes + ? normalizeList(newAttachmentExtension.allowedMimes) + : null, + } + const created = await createAttachmentExtension(payload) + setAttachmentExtensions((prev) => + [...prev, created].sort((a, b) => a.extension.localeCompare(b.extension)) + ) + } + setNewAttachmentExtension({ extension: '', groupId: '', allowedMimes: '' }) + setAttachmentExtensionEdit(null) + setShowAttachmentExtensionModal(false) + } catch (err) { + setAttachmentExtensionsError(err.message) + } finally { + setAttachmentExtensionSaving(false) + } + } + + const handleAttachmentExtensionUpdate = async (extensionId, groupId, allowedMimes) => { + setAttachmentExtensionSavingId(extensionId) + setAttachmentExtensionsError('') + try { + const payload = { + attachment_group_id: groupId ? Number(groupId) : null, + allowed_mimes: allowedMimes ? normalizeList(allowedMimes) : null, + } + const updated = await updateAttachmentExtension(extensionId, payload) + setAttachmentExtensions((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)) + ) + } catch (err) { + setAttachmentExtensionsError(err.message) + } finally { + setAttachmentExtensionSavingId(null) + } + } + + const handleAttachmentExtensionDelete = async (extension) => { + setAttachmentExtensionDeleteTarget(extension) + setShowAttachmentExtensionDelete(true) + } + + const confirmAttachmentExtensionDelete = async () => { + if (!attachmentExtensionDeleteTarget) return + setAttachmentExtensionSaving(true) + setAttachmentExtensionsError('') + try { + await deleteAttachmentExtension(attachmentExtensionDeleteTarget.id) + setAttachmentExtensions((prev) => + prev.filter((item) => item.id !== attachmentExtensionDeleteTarget.id) + ) + setShowAttachmentExtensionDelete(false) + setAttachmentExtensionDeleteTarget(null) + } catch (err) { + setAttachmentExtensionsError(err.message) + } finally { + setAttachmentExtensionSaving(false) + } + } + + const openAttachmentExtensionEdit = (extension) => { + setAttachmentExtensionEdit(extension) + setNewAttachmentExtension({ + extension: extension.extension || '', + groupId: extension.attachment_group_id ? String(extension.attachment_group_id) : '', + allowedMimes: (extension.allowed_mimes || []).join(', '), + }) + setShowAttachmentExtensionModal(true) + } + + const handleSeedAttachmentDefaults = async () => { + setAttachmentSeedSaving(true) + setAttachmentGroupsError('') + setAttachmentExtensionsError('') + try { + const createdGroups = [] + const createdExtensions = [] + + for (const group of attachmentDefaultSeed) { + const parent = await createAttachmentGroup({ + name: group.name, + max_size_kb: group.max_size_kb, + is_active: true, + }) + createdGroups.push(parent) + + for (const child of group.children || []) { + const createdChild = await createAttachmentGroup({ + name: child.name, + parent_id: parent.id, + max_size_kb: child.max_size_kb, + is_active: true, + }) + createdGroups.push(createdChild) + + for (const ext of child.extensions || []) { + const createdExt = await createAttachmentExtension({ + extension: ext.ext, + attachment_group_id: createdChild.id, + allowed_mimes: ext.mimes, + }) + createdExtensions.push(createdExt) + } + } + } + + setAttachmentGroups((prev) => + [...prev, ...createdGroups].sort((a, b) => a.name.localeCompare(b.name)) + ) + setAttachmentExtensions((prev) => + [...prev, ...createdExtensions].sort((a, b) => a.extension.localeCompare(b.extension)) + ) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentSeedSaving(false) + } + } + + const handleAttachmentGroupAutoNest = async () => { + const groupsByName = new Map(attachmentGroups.map((group) => [group.name, group])) + const needsParent = attachmentGroups.every((group) => !group.parent_id) + if (!needsParent) return + + setAttachmentSeedSaving(true) + setAttachmentGroupsError('') + try { + const ensureParent = async (name) => { + const existing = groupsByName.get(name) + if (existing) return existing + const created = await createAttachmentGroup({ + name, + max_size_kb: 25600, + is_active: true, + }) + groupsByName.set(name, created) + setAttachmentGroups((prev) => [...prev, created]) + return created + } + + const mediaParent = await ensureParent('Media') + const filesParent = await ensureParent('Files') + + const mapping = { + Image: mediaParent.id, + Video: mediaParent.id, + Audio: mediaParent.id, + Archive: filesParent.id, + Documents: filesParent.id, + Other: filesParent.id, + } + + const updatedGroups = [] + for (const group of attachmentGroups) { + const targetParent = mapping[group.name] + if (!targetParent) continue + const updated = await updateAttachmentGroup(group.id, { + name: group.name, + parent_id: targetParent, + max_size_kb: group.max_size_kb, + is_active: group.is_active, + }) + updatedGroups.push(updated) + } + + setAttachmentGroups((prev) => + prev.map((group) => updatedGroups.find((u) => u.id === group.id) || group) + ) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentSeedSaving(false) + } + } + + const handleAddExtensionForGroup = (groupId) => { + setNewAttachmentExtension({ + extension: '', + groupId: groupId ? String(groupId) : '', + allowedMimes: '', + }) + setAttachmentExtensionEdit(null) + setShowAttachmentExtensionModal(true) + } + + const openAttachmentExtensionModal = () => { + setNewAttachmentExtension({ + extension: '', + groupId: '', + allowedMimes: '', + }) + setAttachmentExtensionEdit(null) + setShowAttachmentExtensionModal(true) + } + + useEffect(() => { + if (isAdmin) { + loadAttachmentGroups() + loadAttachmentExtensions() + } + }, [isAdmin]) + + useEffect(() => { + if (attachmentGroups.length === 0) return + const ids = attachmentGroups.map((group) => String(group.id)) + setAttachmentGroupCollapsed(new Set(ids)) + }, [attachmentGroups.length]) + + const getAttachmentGroupParentId = (group) => group.parent_id ?? null + + const buildAttachmentGroupTree = useMemo(() => { + const map = new Map() + const roots = [] + + attachmentGroups.forEach((group) => { + map.set(String(group.id), { ...group, children: [] }) + }) + + attachmentGroups.forEach((group) => { + const parentId = group.parent_id + const node = map.get(String(group.id)) + if (parentId && map.has(String(parentId))) { + map.get(String(parentId)).children.push(node) + } else { + roots.push(node) + } + }) + + const sortNodes = (nodes) => { + nodes.sort((a, b) => { + const aPos = a.position ?? 0 + const bPos = b.position ?? 0 + if (aPos !== bPos) return aPos - bPos + return (a.name || '').localeCompare(b.name || '') + }) + nodes.forEach((node) => { + if (node.children?.length) { + sortNodes(node.children) + } + }) + } + + sortNodes(roots) + return roots + }, [attachmentGroups]) + + const attachmentGroupOptions = useMemo(() => { + const options = [] + const walk = (nodes, depth) => { + nodes.forEach((node) => { + const prefix = depth > 0 ? `${'—'.repeat(depth)} ` : '' + options.push({ id: node.id, label: `${prefix}${node.name}` }) + if (node.children?.length) { + walk(node.children, depth + 1) + } + }) + } + walk(buildAttachmentGroupTree, 0) + return options + }, [buildAttachmentGroupTree]) + + const isAttachmentGroupExpanded = (groupId) => !attachmentGroupCollapsed.has(String(groupId)) + + const toggleAttachmentGroupExpanded = (groupId) => { + const key = String(groupId) + setAttachmentGroupCollapsed((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const handleAttachmentGroupCollapseAll = () => { + const ids = attachmentGroups.map((group) => String(group.id)) + setAttachmentGroupCollapsed(new Set(ids)) + } + + const handleAttachmentGroupExpandAll = () => { + setAttachmentGroupCollapsed(new Set()) + } + + const applyAttachmentGroupLocalOrder = (parentId, orderedIds) => { + setAttachmentGroups((prev) => + prev.map((group) => { + const pid = getAttachmentGroupParentId(group) + if (String(pid ?? '') !== String(parentId ?? '')) { + return group + } + const newIndex = orderedIds.indexOf(String(group.id)) + return newIndex === -1 ? group : { ...group, position: newIndex + 1 } + }) + ) + } + + const handleAttachmentGroupDragStart = (event, groupId) => { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', String(groupId)) + setAttachmentGroupDraggingId(String(groupId)) + } + + const handleAttachmentGroupDragEnd = () => { + if (attachmentGroupPendingOrder.current) { + const { parentId, ordered } = attachmentGroupPendingOrder.current + attachmentGroupPendingOrder.current = null + reorderAttachmentGroups(parentId, ordered).catch((err) => setAttachmentGroupsError(err.message)) + } + setAttachmentGroupDraggingId(null) + setAttachmentGroupOverId(null) + } + + const handleAttachmentGroupDragOver = (event, targetId, parentId) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + if (!attachmentGroupDraggingId || String(attachmentGroupDraggingId) === String(targetId)) { + return + } + + const draggedGroup = attachmentGroups.find( + (group) => String(group.id) === String(attachmentGroupDraggingId) + ) + if (!draggedGroup) { + return + } + + const draggedParentId = getAttachmentGroupParentId(draggedGroup) + if (String(draggedParentId ?? '') !== String(parentId ?? '')) { + return + } + + const siblings = attachmentGroups.filter((group) => { + const pid = getAttachmentGroupParentId(group) + return String(pid ?? '') === String(parentId ?? '') + }) + + const ordered = siblings + .slice() + .sort((a, b) => { + if (a.position !== b.position) return a.position - b.position + return a.name.localeCompare(b.name) + }) + .map((group) => String(group.id)) + + const fromIndex = ordered.indexOf(String(attachmentGroupDraggingId)) + const toIndex = ordered.indexOf(String(targetId)) + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return + } + + ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0]) + setAttachmentGroupOverId(String(targetId)) + applyAttachmentGroupLocalOrder(parentId, ordered) + attachmentGroupPendingOrder.current = { parentId, ordered } + } + + const handleAttachmentGroupDragEnter = (groupId) => { + if (attachmentGroupDraggingId && String(groupId) !== String(attachmentGroupDraggingId)) { + setAttachmentGroupOverId(String(groupId)) + } + } + + const handleAttachmentGroupDragLeave = (event, groupId) => { + if (event.currentTarget.contains(event.relatedTarget)) { + return + } + if (attachmentGroupOverId === String(groupId)) { + setAttachmentGroupOverId(null) + } + } + + const handleAttachmentGroupDrop = async (event, targetId, parentId) => { + event.preventDefault() + const draggedId = event.dataTransfer.getData('text/plain') + if (!draggedId || String(draggedId) === String(targetId)) { + setAttachmentGroupDraggingId(null) + setAttachmentGroupOverId(null) + return + } + + const siblings = attachmentGroups.filter((group) => { + const pid = getAttachmentGroupParentId(group) + return String(pid ?? '') === String(parentId ?? '') + }) + + const ordered = siblings + .slice() + .sort((a, b) => { + if (a.position !== b.position) return a.position - b.position + return a.name.localeCompare(b.name) + }) + .map((group) => String(group.id)) + + const fromIndex = ordered.indexOf(String(draggedId)) + const toIndex = ordered.indexOf(String(targetId)) + if (fromIndex === -1 || toIndex === -1) { + return + } + + ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0]) + attachmentGroupPendingOrder.current = null + + try { + await reorderAttachmentGroups(parentId, ordered) + const updated = attachmentGroups.map((group) => { + const pid = getAttachmentGroupParentId(group) + if (String(pid ?? '') !== String(parentId ?? '')) { + return group + } + const newIndex = ordered.indexOf(String(group.id)) + return newIndex === -1 ? group : { ...group, position: newIndex + 1 } + }) + setAttachmentGroups(updated) + } catch (err) { + setAttachmentGroupsError(err.message) + } finally { + setAttachmentGroupDraggingId(null) + setAttachmentGroupOverId(null) + } + } + + const renderAttachmentGroupTree = (nodes, depth = 0) => + nodes.map((node) => { + const groupExtensions = attachmentExtensions.filter( + (ext) => Number(ext.attachment_group_id) === Number(node.id) + ) + const isExpandable = (node.children?.length || 0) > 0 || groupExtensions.length > 0 + return ( +
+
handleAttachmentGroupDragStart(event, node.id)} + onDragEnd={handleAttachmentGroupDragEnd} + onDragOver={(event) => + handleAttachmentGroupDragOver(event, node.id, getAttachmentGroupParentId(node)) + } + onDragEnter={() => handleAttachmentGroupDragEnter(node.id)} + onDragLeave={(event) => handleAttachmentGroupDragLeave(event, node.id)} + onDrop={(event) => handleAttachmentGroupDrop(event, node.id, getAttachmentGroupParentId(node))} + > +
{ + if (isExpandable) { + toggleAttachmentGroupExpanded(node.id) + } + }} + onKeyDown={(event) => { + if (isExpandable && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault() + toggleAttachmentGroupExpanded(node.id) + } + }} + > + + + {isExpandable && ( +