added attchments
This commit is contained in:
58
app/Actions/BbcodeFormatter.php
Normal file
58
app/Actions/BbcodeFormatter.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use s9e\TextFormatter\Configurator;
|
||||||
|
use s9e\TextFormatter\Parser;
|
||||||
|
use s9e\TextFormatter\Renderer;
|
||||||
|
|
||||||
|
class BbcodeFormatter
|
||||||
|
{
|
||||||
|
private static ?Parser $parser = null;
|
||||||
|
private static ?Renderer $renderer = null;
|
||||||
|
|
||||||
|
public static function format(?string $text): string
|
||||||
|
{
|
||||||
|
if ($text === null || $text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::$parser || !self::$renderer) {
|
||||||
|
[$parser, $renderer] = self::build();
|
||||||
|
self::$parser = $parser;
|
||||||
|
self::$renderer = $renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml = self::$parser->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 = '<br/>';
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
300
app/Http/Controllers/AttachmentController.php
Normal file
300
app/Http/Controllers/AttachmentController.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\AttachmentExtension;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Thread;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AttachmentController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Attachment::query()
|
||||||
|
->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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\AttachmentExtension;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AttachmentExtensionController extends Controller
|
||||||
|
{
|
||||||
|
private function ensureAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\AttachmentGroup;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AttachmentGroupController extends Controller
|
||||||
|
{
|
||||||
|
private function ensureAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -16,6 +17,8 @@ class PostController extends Controller
|
|||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$threadParam = $request->query('thread');
|
$threadParam = $request->query('thread');
|
||||||
@@ -54,6 +57,8 @@ class PostController extends Controller
|
|||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($this->serializePost($post), 201);
|
return response()->json($this->serializePost($post), 201);
|
||||||
@@ -87,9 +92,12 @@ class PostController extends Controller
|
|||||||
|
|
||||||
private function serializePost(Post $post): array
|
private function serializePost(Post $post): array
|
||||||
{
|
{
|
||||||
|
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($post->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'body' => $post->body,
|
'body' => $post->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
'thread' => "/api/threads/{$post->thread_id}",
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
'user_id' => $post->user_id,
|
'user_id' => $post->user_id,
|
||||||
'user_name' => $post->user?->name,
|
'user_name' => $post->user?->name,
|
||||||
@@ -111,9 +119,69 @@ class PostController extends Controller
|
|||||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||||
'created_at' => $post->created_at?->toIso8601String(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_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
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
20
app/Http/Controllers/PreviewController.php
Normal file
20
app/Http/Controllers/PreviewController.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PreviewController extends Controller
|
||||||
|
{
|
||||||
|
public function preview(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'html' => BbcodeFormatter::format($data['body']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -49,6 +50,8 @@ class ThreadController extends Controller
|
|||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])->loadCount('posts');
|
])->loadCount('posts');
|
||||||
@@ -81,6 +84,8 @@ class ThreadController extends Controller
|
|||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])->loadCount('posts');
|
])->loadCount('posts');
|
||||||
@@ -120,6 +125,8 @@ class ThreadController extends Controller
|
|||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])->loadCount('posts');
|
])->loadCount('posts');
|
||||||
@@ -146,10 +153,13 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
private function serializeThread(Thread $thread): array
|
private function serializeThread(Thread $thread): array
|
||||||
{
|
{
|
||||||
|
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $thread->id,
|
'id' => $thread->id,
|
||||||
'title' => $thread->title,
|
'title' => $thread->title,
|
||||||
'body' => $thread->body,
|
'body' => $thread->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
'solved' => (bool) $thread->solved,
|
'solved' => (bool) $thread->solved,
|
||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_id,
|
'user_id' => $thread->user_id,
|
||||||
@@ -184,9 +194,69 @@ class ThreadController extends Controller
|
|||||||
?? $this->resolveGroupColor($thread->user),
|
?? $this->resolveGroupColor($thread->user),
|
||||||
'created_at' => $thread->created_at?->toIso8601String(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_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
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
70
app/Models/Attachment.php
Normal file
70
app/Models/Attachment.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int|null $thread_id
|
||||||
|
* @property int|null $post_id
|
||||||
|
* @property int|null $attachment_extension_id
|
||||||
|
* @property int|null $attachment_group_id
|
||||||
|
* @property int|null $user_id
|
||||||
|
* @property string $disk
|
||||||
|
* @property string $path
|
||||||
|
* @property string $original_name
|
||||||
|
* @property string|null $extension
|
||||||
|
* @property string $mime_type
|
||||||
|
* @property int $size_bytes
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class Attachment extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'thread_id',
|
||||||
|
'post_id',
|
||||||
|
'attachment_extension_id',
|
||||||
|
'attachment_group_id',
|
||||||
|
'user_id',
|
||||||
|
'disk',
|
||||||
|
'path',
|
||||||
|
'original_name',
|
||||||
|
'extension',
|
||||||
|
'mime_type',
|
||||||
|
'size_bytes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'size_bytes' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/AttachmentExtension.php
Normal file
31
app/Models/AttachmentExtension.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $extension
|
||||||
|
* @property int|null $attachment_group_id
|
||||||
|
* @property array|null $allowed_mimes
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class AttachmentExtension extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'extension',
|
||||||
|
'attachment_group_id',
|
||||||
|
'allowed_mimes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'allowed_mimes' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function group(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/AttachmentGroup.php
Normal file
46
app/Models/AttachmentGroup.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $name
|
||||||
|
* @property int|null $parent_id
|
||||||
|
* @property int|null $position
|
||||||
|
* @property int $max_size_kb
|
||||||
|
* @property bool $is_active
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class AttachmentGroup extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'parent_id',
|
||||||
|
'position',
|
||||||
|
'max_size_kb',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property string $body
|
* @property string $body
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||||
* @property-read \App\Models\Thread $thread
|
* @property-read \App\Models\Thread $thread
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
||||||
@@ -51,4 +52,9 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PostThank::class);
|
return $this->hasMany(PostThank::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attachment::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Forum $forum
|
* @property-read \App\Models\Forum $forum
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
||||||
* @property-read int|null $posts_count
|
* @property-read int|null $posts_count
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
@@ -64,6 +65,11 @@ class Thread extends Model
|
|||||||
return $this->hasMany(Post::class);
|
return $this->hasMany(Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function latestPost(): HasOne
|
public function latestPost(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(Post::class)->latestOfMany();
|
return $this->hasOne(Post::class)->latestOfMany();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"s9e/text-formatter": "^2.5",
|
||||||
"ext-pdo": "*"
|
"ext-pdo": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
524
composer.lock
generated
524
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachment_extensions', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('attachments', 'attachment_type_id')) {
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('attachment_types');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Intentionally left empty. attachment_types is deprecated.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('attachment_groups', 'category')) {
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:watch": "vite build --watch",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"watch": "vite build --watch",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ export async function fetchPortalSummary() {
|
|||||||
return apiFetch('/portal/summary')
|
return apiFetch('/portal/summary')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function previewBbcode(body) {
|
||||||
|
return apiFetch('/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSetting(key) {
|
export async function fetchSetting(key) {
|
||||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||||
const cacheBust = Date.now()
|
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) {
|
export async function listPostsByThread(threadId) {
|
||||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,322 @@ a {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.bb-thread-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -351,6 +667,9 @@ a {
|
|||||||
|
|
||||||
.bb-post-content {
|
.bb-post-content {
|
||||||
padding: 1rem 1.35rem 1.2rem;
|
padding: 1rem 1.35rem 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-header {
|
.bb-post-header {
|
||||||
@@ -412,21 +731,19 @@ a {
|
|||||||
|
|
||||||
.bb-post-content {
|
.bb-post-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 3.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-body {
|
.bb-post-body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: var(--bb-ink);
|
color: var(--bb-ink);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-footer {
|
.bb-post-footer {
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-thread-reply {
|
.bb-thread-reply {
|
||||||
@@ -1805,6 +2122,14 @@ a {
|
|||||||
color: #0e121b;
|
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 {
|
.modal-content .modal-header {
|
||||||
background: #0f1218;
|
background: #0f1218;
|
||||||
color: #e6e8eb;
|
color: #e6e8eb;
|
||||||
@@ -1921,6 +2246,115 @@ a {
|
|||||||
gap: 0.6rem;
|
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 {
|
.bb-rank-main img {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -2184,6 +2618,7 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.bb-collapse-toggle {
|
.bb-collapse-toggle {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { createThread, getForum, listAllForums, listThreadsByForum } from '../api/client'
|
import {
|
||||||
|
createThread,
|
||||||
|
getForum,
|
||||||
|
listAllForums,
|
||||||
|
listThreadsByForum,
|
||||||
|
uploadAttachment,
|
||||||
|
listAttachmentExtensionsPublic,
|
||||||
|
previewBbcode,
|
||||||
|
} from '../api/client'
|
||||||
import PortalTopicRow from '../components/PortalTopicRow'
|
import PortalTopicRow from '../components/PortalTopicRow'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -18,6 +26,24 @@ export default function ForumView() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [threadFiles, setThreadFiles] = useState([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
|
||||||
|
const [attachmentValidationError, setAttachmentValidationError] = useState('')
|
||||||
|
const [threadDropActive, setThreadDropActive] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('')
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewUrls, setPreviewUrls] = useState([])
|
||||||
|
const [attachmentTab, setAttachmentTab] = useState('options')
|
||||||
|
const [attachmentOptions, setAttachmentOptions] = useState({
|
||||||
|
disableBbcode: false,
|
||||||
|
disableSmilies: false,
|
||||||
|
disableAutoUrls: false,
|
||||||
|
attachSignature: true,
|
||||||
|
notifyReplies: false,
|
||||||
|
lockTopic: false,
|
||||||
|
})
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const renderChildRows = (nodes) =>
|
const renderChildRows = (nodes) =>
|
||||||
@@ -178,24 +204,388 @@ export default function ForumView() {
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAttachmentExtensionsPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await createThread({ title, body, forumId: id })
|
const created = await createThread({ title, body, forumId: id })
|
||||||
|
if (threadFiles.length > 0 && created?.id) {
|
||||||
|
setUploading(true)
|
||||||
|
for (const entry of threadFiles) {
|
||||||
|
await uploadAttachment({ threadId: created.id, file: entry.file })
|
||||||
|
}
|
||||||
|
}
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setBody('')
|
setBody('')
|
||||||
|
setThreadFiles([])
|
||||||
const updated = await listThreadsByForum(id)
|
const updated = await listThreadsByForum(id)
|
||||||
setThreads(updated)
|
setThreads(updated)
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (!bytes && bytes !== 0) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const kb = bytes / 1024
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||||
|
const mb = kb / 1024
|
||||||
|
return `${mb.toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInlineInsert = (entry) => {
|
||||||
|
const marker = `[attachment]${entry.file.name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPreviewUrls = () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
setPreviewUrls([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPreviewBody = (rawBody, entries) => {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { body: rawBody, urls: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const map = new Map()
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const file = entry.file
|
||||||
|
if (!file) return
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
urls.push(url)
|
||||||
|
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
|
||||||
|
const key = String(name || '').trim().toLowerCase()
|
||||||
|
if (!map.has(key)) return match
|
||||||
|
const { url, mime } = map.get(key)
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return `[img]${url}[/img]`
|
||||||
|
}
|
||||||
|
return `[url=${url}]${name}[/url]`
|
||||||
|
})
|
||||||
|
|
||||||
|
return { body: replaced, urls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
clearPreviewUrls()
|
||||||
|
const { body: previewBody, urls } = buildPreviewBody(body || '', threadFiles)
|
||||||
|
const result = await previewBbcode(previewBody || '')
|
||||||
|
setPreviewHtml(result?.html || '')
|
||||||
|
setShowPreview(true)
|
||||||
|
setPreviewUrls(urls)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyThreadFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreadFiles(
|
||||||
|
accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendThreadFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else if (accepted.length > 0) {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length === 0) return
|
||||||
|
setThreadFiles((prev) => [
|
||||||
|
...prev,
|
||||||
|
...accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
setAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThreadPaste = (event) => {
|
||||||
|
const items = Array.from(event.clipboardData?.items || [])
|
||||||
|
if (items.length === 0) return
|
||||||
|
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
|
||||||
|
if (imageItems.length === 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
const files = imageItems
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((file) => {
|
||||||
|
const ext = file.type?.split('/')[1] || 'png'
|
||||||
|
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
|
||||||
|
return new File([file], name, { type: file.type })
|
||||||
|
})
|
||||||
|
appendThreadFiles(files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
const marker = `[attachment]${files[0].name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAttachmentFooter = () => (
|
||||||
|
<div className="bb-attachment-panel">
|
||||||
|
<div className="bb-attachment-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setAttachmentTab('options')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_options')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setAttachmentTab('attachments')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_attachments')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bb-attachment-body">
|
||||||
|
{attachmentTab === 'options' && (
|
||||||
|
<div className="bb-attachment-options">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-bbcode"
|
||||||
|
label={t('attachment.option_disable_bbcode')}
|
||||||
|
checked={attachmentOptions.disableBbcode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableBbcode: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-smilies"
|
||||||
|
label={t('attachment.option_disable_smilies')}
|
||||||
|
checked={attachmentOptions.disableSmilies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableSmilies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-auto-urls"
|
||||||
|
label={t('attachment.option_disable_auto_urls')}
|
||||||
|
checked={attachmentOptions.disableAutoUrls}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableAutoUrls: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-attach-signature"
|
||||||
|
label={t('attachment.option_attach_signature')}
|
||||||
|
checked={attachmentOptions.attachSignature}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
attachSignature: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-notify-replies"
|
||||||
|
label={t('attachment.option_notify_replies')}
|
||||||
|
checked={attachmentOptions.notifyReplies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
notifyReplies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-lock-topic"
|
||||||
|
label={t('attachment.option_lock_topic')}
|
||||||
|
checked={attachmentOptions.lockTopic}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lockTopic: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachmentTab === 'attachments' && (
|
||||||
|
<>
|
||||||
|
<p className="bb-muted mb-2">
|
||||||
|
{t('attachment.hint')}
|
||||||
|
</p>
|
||||||
|
<p className="bb-muted mb-3">
|
||||||
|
{t('attachment.max_size', { size: '25 MB' })}
|
||||||
|
</p>
|
||||||
|
<div className="bb-attachment-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||||
|
>
|
||||||
|
{t('attachment.add_files')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{attachmentValidationError && (
|
||||||
|
<p className="text-danger mb-2">{attachmentValidationError}</p>
|
||||||
|
)}
|
||||||
|
<table className="table bb-attachment-table">
|
||||||
|
<thead className="tr-header">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.filename')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.size')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.status')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{threadFiles.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="bb-attachment-empty">
|
||||||
|
{t('attachment.empty')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{threadFiles.map((entry) => (
|
||||||
|
<tr key={entry.id} className="bb-attachment-row">
|
||||||
|
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{entry.file.name}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-cell-comment">
|
||||||
|
<Form.Control
|
||||||
|
className="bb-attachment-comment"
|
||||||
|
value={entry.comment}
|
||||||
|
onChange={(event) =>
|
||||||
|
setThreadFiles((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === entry.id
|
||||||
|
? { ...item, comment: event.target.value }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t('attachment.file_comment_placeholder')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{formatBytes(entry.file.size)}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-status text-center">
|
||||||
|
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="bb-attachment-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() => handleInlineInsert(entry)}
|
||||||
|
title={t('attachment.place_inline')}
|
||||||
|
aria-label={t('attachment.place_inline')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() =>
|
||||||
|
setThreadFiles((prev) =>
|
||||||
|
prev.filter((item) => item.id !== entry.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t('attachment.delete_file')}
|
||||||
|
aria-label={t('attachment.delete_file')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid className="py-5 bb-shell-container">
|
<Container fluid className="py-5 bb-shell-container">
|
||||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||||
@@ -258,7 +648,7 @@ export default function ForumView() {
|
|||||||
</div>
|
</div>
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<div className="bb-portal-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-portal-topic-header">
|
<div className="bb-portal-topic-header tr-header">
|
||||||
<span>{t('portal.topic')}</span>
|
<span>{t('portal.topic')}</span>
|
||||||
<span>{t('thread.replies')}</span>
|
<span>{t('thread.replies')}</span>
|
||||||
<span>{t('thread.views')}</span>
|
<span>{t('thread.views')}</span>
|
||||||
@@ -284,13 +674,19 @@ export default function ForumView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{forum?.type === 'forum' && (
|
{forum?.type === 'forum' && (
|
||||||
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
|
<Modal
|
||||||
|
show={showModal}
|
||||||
|
onHide={() => setShowModal(false)}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
dialogClassName="bb-thread-modal"
|
||||||
|
>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body className="d-flex flex-column p-0">
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} className="d-flex flex-column flex-grow-1 px-3 pb-3 pt-2">
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.title')}</Form.Label>
|
<Form.Label>{t('form.title')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -302,30 +698,113 @@ export default function ForumView() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3 d-flex flex-column flex-grow-1">
|
||||||
<Form.Label>{t('form.body')}</Form.Label>
|
<Form.Label>{t('form.body')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={6}
|
rows={6}
|
||||||
|
className="flex-grow-1"
|
||||||
placeholder={t('form.thread_body_placeholder')}
|
placeholder={t('form.thread_body_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
onPaste={handleThreadPaste}
|
||||||
disabled={!token || saving}
|
disabled={!token || saving}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<div className="d-flex gap-2 justify-content-between">
|
<Form.Control
|
||||||
|
id="bb-thread-attachment-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="bb-attachment-input"
|
||||||
|
disabled={!token || saving || uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
applyThreadFiles(event.target.files)
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`bb-attachment-drop ${threadDropActive ? 'is-dragover' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
document.getElementById('bb-thread-attachment-input')?.click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setThreadDropActive(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setThreadDropActive(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setThreadDropActive(false)
|
||||||
|
applyThreadFiles(event.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t('attachment.drop_hint')}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-drop-link"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
document.getElementById('bb-thread-attachment-input')?.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('attachment.drop_browse')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{renderAttachmentFooter()}
|
||||||
|
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
|
||||||
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
<div className="d-flex gap-2">
|
||||||
{saving ? t('form.posting') : t('form.create_thread')}
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!token || saving || uploading || previewLoading}
|
||||||
|
>
|
||||||
|
{t('form.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={!token || saving || uploading}
|
||||||
|
>
|
||||||
|
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<Modal
|
||||||
|
show={showPreview}
|
||||||
|
onHide={() => {
|
||||||
|
setShowPreview(false)
|
||||||
|
clearPreviewUrls()
|
||||||
|
}}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('form.preview')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<div
|
||||||
|
className="bb-post-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
|
||||||
|
/>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
{!loadingThreads && recentThreads.length > 0 && (
|
{!loadingThreads && recentThreads.length > 0 && (
|
||||||
<div className="bb-portal-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-portal-topic-header">
|
<div className="bb-portal-topic-header tr-header">
|
||||||
<span>{t('portal.topic')}</span>
|
<span>{t('portal.topic')}</span>
|
||||||
<span>{t('thread.replies')}</span>
|
<span>{t('thread.replies')}</span>
|
||||||
<span>{t('thread.views')}</span>
|
<span>{t('thread.views')}</span>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Container, Form } from 'react-bootstrap'
|
import { Button, Container, Form, Modal } from 'react-bootstrap'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { createPost, getThread, listPostsByThread, updateThreadSolved } from '../api/client'
|
import {
|
||||||
|
createPost,
|
||||||
|
getThread,
|
||||||
|
listPostsByThread,
|
||||||
|
updateThreadSolved,
|
||||||
|
uploadAttachment,
|
||||||
|
listAttachmentExtensionsPublic,
|
||||||
|
previewBbcode,
|
||||||
|
} from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -15,6 +23,25 @@ export default function ThreadView() {
|
|||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [solving, setSolving] = useState(false)
|
const [solving, setSolving] = useState(false)
|
||||||
|
const [threadFiles, setThreadFiles] = useState([])
|
||||||
|
const [threadUploading, setThreadUploading] = useState(false)
|
||||||
|
const [replyFiles, setReplyFiles] = useState([])
|
||||||
|
const [replyUploading, setReplyUploading] = useState(false)
|
||||||
|
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
|
||||||
|
const [attachmentValidationError, setAttachmentValidationError] = useState('')
|
||||||
|
const [replyDropActive, setReplyDropActive] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('')
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewUrls, setPreviewUrls] = useState([])
|
||||||
|
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
|
||||||
|
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
|
||||||
|
disableBbcode: false,
|
||||||
|
disableSmilies: false,
|
||||||
|
disableAutoUrls: false,
|
||||||
|
attachSignature: true,
|
||||||
|
notifyReplies: false,
|
||||||
|
})
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const replyRef = useRef(null)
|
const replyRef = useRef(null)
|
||||||
|
|
||||||
@@ -29,6 +56,16 @@ export default function ThreadView() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAttachmentExtensionsPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread && posts.length === 0) return
|
if (!thread && posts.length === 0) return
|
||||||
const hash = window.location.hash
|
const hash = window.location.hash
|
||||||
@@ -46,13 +83,21 @@ export default function ThreadView() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await createPost({ body, threadId: id })
|
const created = await createPost({ body, threadId: id })
|
||||||
|
if (replyFiles.length > 0 && created?.id) {
|
||||||
|
setReplyUploading(true)
|
||||||
|
for (const entry of replyFiles) {
|
||||||
|
await uploadAttachment({ postId: created.id, file: entry.file })
|
||||||
|
}
|
||||||
|
}
|
||||||
setBody('')
|
setBody('')
|
||||||
|
setReplyFiles([])
|
||||||
const updated = await listPostsByThread(id)
|
const updated = await listPostsByThread(id)
|
||||||
setPosts(updated)
|
setPosts(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
setReplyUploading(false)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,11 +112,369 @@ export default function ThreadView() {
|
|||||||
const year = String(date.getFullYear())
|
const year = String(date.getFullYear())
|
||||||
return `${day}.${month}.${year}`
|
return `${day}.${month}.${year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (!bytes && bytes !== 0) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const kb = bytes / 1024
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||||
|
const mb = kb / 1024
|
||||||
|
return `${mb.toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInlineInsert = (entry) => {
|
||||||
|
const marker = `[attachment]${entry.file.name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPreviewUrls = () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
setPreviewUrls([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPreviewBody = (rawBody, entries) => {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { body: rawBody, urls: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const map = new Map()
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const file = entry.file
|
||||||
|
if (!file) return
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
urls.push(url)
|
||||||
|
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
|
||||||
|
const key = String(name || '').trim().toLowerCase()
|
||||||
|
if (!map.has(key)) return match
|
||||||
|
const { url, mime } = map.get(key)
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return `[img]${url}[/img]`
|
||||||
|
}
|
||||||
|
return `[url=${url}]${name}[/url]`
|
||||||
|
})
|
||||||
|
|
||||||
|
return { body: replaced, urls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
clearPreviewUrls()
|
||||||
|
const { body: previewBody, urls } = buildPreviewBody(body || '', replyFiles)
|
||||||
|
const result = await previewBbcode(previewBody || '')
|
||||||
|
setPreviewHtml(result?.html || '')
|
||||||
|
setShowPreview(true)
|
||||||
|
setPreviewUrls(urls)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyReplyFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
setReplyFiles(
|
||||||
|
accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setReplyAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendReplyFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else if (accepted.length > 0) {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length === 0) return
|
||||||
|
setReplyFiles((prev) => [
|
||||||
|
...prev,
|
||||||
|
...accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
setReplyAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReplyPaste = (event) => {
|
||||||
|
const items = Array.from(event.clipboardData?.items || [])
|
||||||
|
if (items.length === 0) return
|
||||||
|
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
|
||||||
|
if (imageItems.length === 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
const files = imageItems
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((file) => {
|
||||||
|
const ext = file.type?.split('/')[1] || 'png'
|
||||||
|
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
|
||||||
|
return new File([file], name, { type: file.type })
|
||||||
|
})
|
||||||
|
appendReplyFiles(files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
const marker = `[attachment]${files[0].name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAttachmentFooter = () => (
|
||||||
|
<div className="bb-attachment-panel">
|
||||||
|
<div className="bb-attachment-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setReplyAttachmentTab('options')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_options')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setReplyAttachmentTab('attachments')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_attachments')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bb-attachment-body">
|
||||||
|
{replyAttachmentTab === 'options' && (
|
||||||
|
<div className="bb-attachment-options">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-reply-option-disable-bbcode"
|
||||||
|
label={t('attachment.option_disable_bbcode')}
|
||||||
|
checked={replyAttachmentOptions.disableBbcode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableBbcode: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-reply-option-disable-smilies"
|
||||||
|
label={t('attachment.option_disable_smilies')}
|
||||||
|
checked={replyAttachmentOptions.disableSmilies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableSmilies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-reply-option-disable-auto-urls"
|
||||||
|
label={t('attachment.option_disable_auto_urls')}
|
||||||
|
checked={replyAttachmentOptions.disableAutoUrls}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableAutoUrls: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-reply-option-attach-signature"
|
||||||
|
label={t('attachment.option_attach_signature')}
|
||||||
|
checked={replyAttachmentOptions.attachSignature}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
attachSignature: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-reply-option-notify-replies"
|
||||||
|
label={t('attachment.option_notify_replies')}
|
||||||
|
checked={replyAttachmentOptions.notifyReplies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
notifyReplies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{replyAttachmentTab === 'attachments' && (
|
||||||
|
<>
|
||||||
|
<p className="bb-muted mb-2">
|
||||||
|
{t('attachment.hint')}
|
||||||
|
</p>
|
||||||
|
<p className="bb-muted mb-3">
|
||||||
|
{t('attachment.max_size', { size: '25 MB' })}
|
||||||
|
</p>
|
||||||
|
<div className="bb-attachment-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
|
||||||
|
>
|
||||||
|
{t('attachment.add_files')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{attachmentValidationError && (
|
||||||
|
<p className="text-danger mb-2">{attachmentValidationError}</p>
|
||||||
|
)}
|
||||||
|
<table className="table bb-attachment-table">
|
||||||
|
<thead className="tr-header">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.filename')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.size')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.status')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{replyFiles.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="bb-attachment-empty">
|
||||||
|
{t('attachment.empty')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{replyFiles.map((entry) => (
|
||||||
|
<tr key={entry.id} className="bb-attachment-row">
|
||||||
|
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{entry.file.name}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-cell-comment">
|
||||||
|
<Form.Control
|
||||||
|
className="bb-attachment-comment"
|
||||||
|
value={entry.comment}
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplyFiles((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === entry.id
|
||||||
|
? { ...item, comment: event.target.value }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t('attachment.file_comment_placeholder')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{formatBytes(entry.file.size)}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-status text-center">
|
||||||
|
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="bb-attachment-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() => handleInlineInsert(entry)}
|
||||||
|
title={t('attachment.place_inline')}
|
||||||
|
aria-label={t('attachment.place_inline')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() =>
|
||||||
|
setReplyFiles((prev) =>
|
||||||
|
prev.filter((item) => item.id !== entry.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t('attachment.delete_file')}
|
||||||
|
aria-label={t('attachment.delete_file')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderAttachments = (attachments) => {
|
||||||
|
if (!attachments || attachments.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="bb-attachment-list">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.download_url}
|
||||||
|
className="bb-attachment-item"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||||
|
<span className="bb-attachment-name">{attachment.original_name}</span>
|
||||||
|
<span className="bb-attachment-meta">
|
||||||
|
{attachment.mime_type}
|
||||||
|
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const allPosts = useMemo(() => {
|
const allPosts = useMemo(() => {
|
||||||
if (!thread) return posts
|
if (!thread) return posts
|
||||||
const rootPost = {
|
const rootPost = {
|
||||||
id: `thread-${thread.id}`,
|
id: `thread-${thread.id}`,
|
||||||
body: thread.body,
|
body: thread.body,
|
||||||
|
body_html: thread.body_html,
|
||||||
created_at: thread.created_at,
|
created_at: thread.created_at,
|
||||||
user_id: thread.user_id,
|
user_id: thread.user_id,
|
||||||
user_name: thread.user_name,
|
user_name: thread.user_name,
|
||||||
@@ -85,6 +488,7 @@ export default function ThreadView() {
|
|||||||
user_rank_badge_type: thread.user_rank_badge_type,
|
user_rank_badge_type: thread.user_rank_badge_type,
|
||||||
user_rank_badge_text: thread.user_rank_badge_text,
|
user_rank_badge_text: thread.user_rank_badge_text,
|
||||||
user_rank_badge_url: thread.user_rank_badge_url,
|
user_rank_badge_url: thread.user_rank_badge_url,
|
||||||
|
attachments: thread.attachments || [],
|
||||||
isRoot: true,
|
isRoot: true,
|
||||||
}
|
}
|
||||||
return [rootPost, ...posts]
|
return [rootPost, ...posts]
|
||||||
@@ -98,6 +502,10 @@ export default function ThreadView() {
|
|||||||
&& thread
|
&& thread
|
||||||
&& (Number(thread.user_id) === Number(userId) || isAdmin)
|
&& (Number(thread.user_id) === Number(userId) || isAdmin)
|
||||||
|
|
||||||
|
const canUploadThread = token
|
||||||
|
&& thread
|
||||||
|
&& (Number(thread.user_id) === Number(userId) || isAdmin)
|
||||||
|
|
||||||
const handleToggleSolved = async () => {
|
const handleToggleSolved = async () => {
|
||||||
if (!thread || solving) return
|
if (!thread || solving) return
|
||||||
setSolving(true)
|
setSolving(true)
|
||||||
@@ -112,6 +520,25 @@ export default function ThreadView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleThreadUpload = async () => {
|
||||||
|
if (!thread || threadFiles.length === 0 || threadUploading) return
|
||||||
|
setThreadUploading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
for (const file of threadFiles) {
|
||||||
|
await uploadAttachment({ threadId: thread.id, file })
|
||||||
|
}
|
||||||
|
setThreadFiles([])
|
||||||
|
const [threadData, postData] = await Promise.all([getThread(id), listPostsByThread(id)])
|
||||||
|
setThread(threadData)
|
||||||
|
setPosts(postData)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setThreadUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalPosts = allPosts.length
|
const totalPosts = allPosts.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,6 +602,7 @@ export default function ThreadView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="bb-posts">
|
<div className="bb-posts">
|
||||||
{allPosts.map((post, index) => {
|
{allPosts.map((post, index) => {
|
||||||
const authorName = post.author?.username
|
const authorName = post.author?.username
|
||||||
@@ -297,7 +725,11 @@ export default function ThreadView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-post-body">{post.body}</div>
|
<div
|
||||||
|
className="bb-post-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
|
||||||
|
/>
|
||||||
|
{renderAttachments(post.attachments)}
|
||||||
<div className="bb-post-footer">
|
<div className="bb-post-footer">
|
||||||
<div className="bb-post-actions">
|
<div className="bb-post-actions">
|
||||||
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
|
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
|
||||||
@@ -325,19 +757,101 @@ export default function ThreadView() {
|
|||||||
placeholder={t('form.reply_placeholder')}
|
placeholder={t('form.reply_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
onPaste={handleReplyPaste}
|
||||||
disabled={!token || saving}
|
disabled={!token || saving}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Control
|
||||||
|
id="bb-reply-attachment-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="bb-attachment-input"
|
||||||
|
disabled={!token || saving || replyUploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
applyReplyFiles(event.target.files)
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`bb-attachment-drop ${replyDropActive ? 'is-dragover' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
document.getElementById('bb-reply-attachment-input')?.click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setReplyDropActive(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setReplyDropActive(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setReplyDropActive(false)
|
||||||
|
applyReplyFiles(event.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t('attachment.drop_hint')}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-drop-link"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
document.getElementById('bb-reply-attachment-input')?.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('attachment.drop_browse')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{renderAttachmentFooter()}
|
||||||
<div className="bb-thread-reply-actions">
|
<div className="bb-thread-reply-actions">
|
||||||
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
<div className="d-flex gap-2 justify-content-end">
|
||||||
{saving ? t('form.posting') : t('form.post_reply')}
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!token || saving || replyUploading || previewLoading}
|
||||||
|
>
|
||||||
|
{t('form.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={!token || saving || replyUploading}
|
||||||
|
>
|
||||||
|
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Modal
|
||||||
|
show={showPreview}
|
||||||
|
onHide={() => {
|
||||||
|
setShowPreview(false)
|
||||||
|
clearPreviewUrls()
|
||||||
|
}}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('form.preview')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<div
|
||||||
|
className="bb-post-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
|
||||||
|
/>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"acp.add_forum": "Forum hinzufügen",
|
"acp.add_forum": "Forum hinzufügen",
|
||||||
"acp.ranks": "Ränge",
|
"acp.ranks": "Ränge",
|
||||||
"acp.groups": "Gruppen",
|
"acp.groups": "Gruppen",
|
||||||
|
"acp.attachments": "Anh\u00e4nge",
|
||||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||||
"acp.forums_tree": "Forenbaum",
|
"acp.forums_tree": "Forenbaum",
|
||||||
"acp.forums_type": "Typ",
|
"acp.forums_type": "Typ",
|
||||||
@@ -77,6 +78,9 @@
|
|||||||
"form.password": "Passwort",
|
"form.password": "Passwort",
|
||||||
"form.post_reply": "Antwort posten",
|
"form.post_reply": "Antwort posten",
|
||||||
"form.posting": "Wird gesendet...",
|
"form.posting": "Wird gesendet...",
|
||||||
|
"form.preview": "Vorschau",
|
||||||
|
"form.upload": "Hochladen",
|
||||||
|
"form.uploading": "Wird hochgeladen...",
|
||||||
"form.registering": "Registrierung läuft...",
|
"form.registering": "Registrierung läuft...",
|
||||||
"form.reply_placeholder": "Schreibe deine Antwort.",
|
"form.reply_placeholder": "Schreibe deine Antwort.",
|
||||||
"form.sign_in": "Anmelden",
|
"form.sign_in": "Anmelden",
|
||||||
@@ -212,6 +216,63 @@
|
|||||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||||
"ucp.custom_color": "Eigene Farbe",
|
"ucp.custom_color": "Eigene Farbe",
|
||||||
|
"attachment.groups_title": "Anhanggruppen",
|
||||||
|
"attachment.group_create": "Neue Anhanggruppe",
|
||||||
|
"attachment.group_create_title": "Anhanggruppe erstellen",
|
||||||
|
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
|
||||||
|
"attachment.group_empty": "Noch keine Anhanggruppen.",
|
||||||
|
"attachment.seed_defaults": "Standard-Anhangset erstellen",
|
||||||
|
"attachment.seed_in_progress": "Standardwerte werden erstellt...",
|
||||||
|
"attachment.seed_hint": "Fügt eine Media- und Files-Struktur mit üblichen Endungen hinzu.",
|
||||||
|
"attachment.group_extensions": "{{count}} Endungen",
|
||||||
|
"attachment.group_delete_confirm": "Diese Anhanggruppe l\u00f6schen?",
|
||||||
|
"attachment.group_name": "Name",
|
||||||
|
"attachment.group_parent": "\u00dcbergeordnete Gruppe",
|
||||||
|
"attachment.group_parent_none": "Keine",
|
||||||
|
"attachment.group_max_size": "Max. Gr\u00f6\u00dfe (KB)",
|
||||||
|
"attachment.group_max_size_hint": "Standard 25600 KB (25 MB).",
|
||||||
|
"attachment.group_active": "Aktiv",
|
||||||
|
"attachment.group_add_child": "Untergruppe hinzuf\u00fcgen",
|
||||||
|
"attachment.group_auto_nest": "Standardgruppen automatisch verschachteln",
|
||||||
|
"attachment.group_auto_nest_hint": "Erstellt Media- und Files-Eltern und ordnet die Standardgruppen darunter ein.",
|
||||||
|
"attachment.extensions_title": "Dateiendungen verwalten",
|
||||||
|
"attachment.extension_placeholder": "Endung hinzuf\u00fcgen (z. B. pdf)",
|
||||||
|
"attachment.extension_mimes_placeholder": "Erlaubte MIME-Typen (kommagetrennt)",
|
||||||
|
"attachment.extension_unassigned": "Nicht zugewiesen",
|
||||||
|
"attachment.extension_add": "Endung hinzuf\u00fcgen",
|
||||||
|
"attachment.extension_edit": "Endung bearbeiten",
|
||||||
|
"attachment.extension_add_button": "Endung hinzuf\u00fcgen",
|
||||||
|
"attachment.extension_empty": "Noch keine Endungen.",
|
||||||
|
"attachment.extension": "Endung",
|
||||||
|
"attachment.extension_group": "Endungsgruppe",
|
||||||
|
"attachment.extension_delete_confirm": "Diese Endung l\u00f6schen?",
|
||||||
|
"attachment.actions": "Aktionen",
|
||||||
|
"attachment.allowed_mimes": "MIME-Typen:",
|
||||||
|
"attachment.active": "Aktiv",
|
||||||
|
"attachment.inactive": "Inaktiv",
|
||||||
|
"attachment.tab_options": "Optionen",
|
||||||
|
"attachment.tab_attachments": "Anh\u00e4nge",
|
||||||
|
"attachment.hint": "Wenn du Dateien anh\u00e4ngen m\u00f6chtest, f\u00fcge sie unten hinzu.",
|
||||||
|
"attachment.max_size": "Maximale Dateigr\u00f6\u00dfe pro Anhang: {{size}}.",
|
||||||
|
"attachment.add_files": "Dateien hinzuf\u00fcgen",
|
||||||
|
"attachment.drop_hint": "Sie k\u00f6nnen Dateien zum Hochladen hier hineinziehen oder",
|
||||||
|
"attachment.drop_browse": "Durchsuchen",
|
||||||
|
"attachment.filename": "Dateiname",
|
||||||
|
"attachment.size": "Gr\u00f6\u00dfe",
|
||||||
|
"attachment.status": "Status",
|
||||||
|
"attachment.empty": "Noch keine Dateien hinzugef\u00fcgt.",
|
||||||
|
"attachment.remove": "Datei entfernen",
|
||||||
|
"attachment.file_comment": "Dateikommentar",
|
||||||
|
"attachment.file_comment_placeholder": "Kommentar (optional)",
|
||||||
|
"attachment.place_inline": "Inline platzieren",
|
||||||
|
"attachment.delete_file": "Datei l\u00f6schen",
|
||||||
|
"attachment.option_disable_bbcode": "BBCode deaktivieren",
|
||||||
|
"attachment.option_disable_smilies": "Smileys deaktivieren",
|
||||||
|
"attachment.option_disable_auto_urls": "URLs nicht automatisch verlinken",
|
||||||
|
"attachment.option_attach_signature": "Signatur anh\u00e4ngen (über die UCP \u00e4nderbar)",
|
||||||
|
"attachment.option_notify_replies": "Bei Antworten benachrichtigen",
|
||||||
|
"attachment.option_lock_topic": "Thema sperren",
|
||||||
|
"attachment.invalid_extensions": "Nicht erlaubt: {{names}}.",
|
||||||
"thread.anonymous": "Anonym",
|
"thread.anonymous": "Anonym",
|
||||||
"thread.back_to_category": "Zurück zum Forum",
|
"thread.back_to_category": "Zurück zum Forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -230,6 +291,8 @@
|
|||||||
"thread.solved": "Gel\u00f6st",
|
"thread.solved": "Gel\u00f6st",
|
||||||
"thread.mark_solved": "Als gel\u00f6st markieren",
|
"thread.mark_solved": "Als gel\u00f6st markieren",
|
||||||
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
|
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
|
||||||
|
"thread.attachments": "Anh\u00e4nge",
|
||||||
|
"thread.attach_files": "Dateien anh\u00e4ngen",
|
||||||
"thread.views": "Zugriffe",
|
"thread.views": "Zugriffe",
|
||||||
"thread.last_post": "Letzter Beitrag",
|
"thread.last_post": "Letzter Beitrag",
|
||||||
"thread.by": "von",
|
"thread.by": "von",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"acp.add_forum": "Add forum",
|
"acp.add_forum": "Add forum",
|
||||||
"acp.ranks": "Ranks",
|
"acp.ranks": "Ranks",
|
||||||
"acp.groups": "Groups",
|
"acp.groups": "Groups",
|
||||||
|
"acp.attachments": "Attachments",
|
||||||
"acp.forums_parent_root": "Root (no parent)",
|
"acp.forums_parent_root": "Root (no parent)",
|
||||||
"acp.forums_tree": "Forum tree",
|
"acp.forums_tree": "Forum tree",
|
||||||
"acp.forums_type": "Type",
|
"acp.forums_type": "Type",
|
||||||
@@ -77,6 +78,9 @@
|
|||||||
"form.password": "Password",
|
"form.password": "Password",
|
||||||
"form.post_reply": "Post reply",
|
"form.post_reply": "Post reply",
|
||||||
"form.posting": "Posting...",
|
"form.posting": "Posting...",
|
||||||
|
"form.preview": "Preview",
|
||||||
|
"form.upload": "Upload",
|
||||||
|
"form.uploading": "Uploading...",
|
||||||
"form.registering": "Registering...",
|
"form.registering": "Registering...",
|
||||||
"form.reply_placeholder": "Share your reply.",
|
"form.reply_placeholder": "Share your reply.",
|
||||||
"form.sign_in": "Sign in",
|
"form.sign_in": "Sign in",
|
||||||
@@ -212,6 +216,63 @@
|
|||||||
"ucp.accent_override": "Accent color override",
|
"ucp.accent_override": "Accent color override",
|
||||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||||
"ucp.custom_color": "Custom color",
|
"ucp.custom_color": "Custom color",
|
||||||
|
"attachment.groups_title": "Attachment groups",
|
||||||
|
"attachment.group_create": "New attachment group",
|
||||||
|
"attachment.group_create_title": "Create attachment group",
|
||||||
|
"attachment.group_edit_title": "Edit attachment group",
|
||||||
|
"attachment.group_empty": "No attachment groups yet.",
|
||||||
|
"attachment.seed_defaults": "Create default attachment set",
|
||||||
|
"attachment.seed_in_progress": "Creating defaults...",
|
||||||
|
"attachment.seed_hint": "Adds a Media and Files hierarchy with common extensions.",
|
||||||
|
"attachment.group_extensions": "{{count}} extensions",
|
||||||
|
"attachment.group_delete_confirm": "Delete this attachment group?",
|
||||||
|
"attachment.group_name": "Name",
|
||||||
|
"attachment.group_parent": "Parent group",
|
||||||
|
"attachment.group_parent_none": "No parent",
|
||||||
|
"attachment.group_max_size": "Max size (KB)",
|
||||||
|
"attachment.group_max_size_hint": "Default 25600 KB (25 MB).",
|
||||||
|
"attachment.group_active": "Active",
|
||||||
|
"attachment.group_add_child": "Add child group",
|
||||||
|
"attachment.group_auto_nest": "Auto-nest default groups",
|
||||||
|
"attachment.group_auto_nest_hint": "Creates Media and Files parents and nests the default groups underneath.",
|
||||||
|
"attachment.extensions_title": "Manage attachment extensions",
|
||||||
|
"attachment.extension_placeholder": "Add extension (e.g. pdf)",
|
||||||
|
"attachment.extension_mimes_placeholder": "Allowed MIME types (comma-separated)",
|
||||||
|
"attachment.extension_unassigned": "Not assigned",
|
||||||
|
"attachment.extension_add": "Add extension",
|
||||||
|
"attachment.extension_edit": "Edit extension",
|
||||||
|
"attachment.extension_add_button": "Add extension",
|
||||||
|
"attachment.extension_empty": "No extensions yet.",
|
||||||
|
"attachment.extension": "Extension",
|
||||||
|
"attachment.extension_group": "Extension group",
|
||||||
|
"attachment.extension_delete_confirm": "Delete this extension?",
|
||||||
|
"attachment.actions": "Actions",
|
||||||
|
"attachment.allowed_mimes": "MIME types:",
|
||||||
|
"attachment.active": "Active",
|
||||||
|
"attachment.inactive": "Inactive",
|
||||||
|
"attachment.tab_options": "Options",
|
||||||
|
"attachment.tab_attachments": "Attachments",
|
||||||
|
"attachment.hint": "If you wish to attach one or more files enter the details below.",
|
||||||
|
"attachment.max_size": "Maximum filesize per attachment: {{size}}.",
|
||||||
|
"attachment.add_files": "Add files",
|
||||||
|
"attachment.drop_hint": "Drag files here to upload or",
|
||||||
|
"attachment.drop_browse": "Browse",
|
||||||
|
"attachment.filename": "Filename",
|
||||||
|
"attachment.size": "Size",
|
||||||
|
"attachment.status": "Status",
|
||||||
|
"attachment.empty": "No files added yet.",
|
||||||
|
"attachment.remove": "Remove file",
|
||||||
|
"attachment.file_comment": "File comment",
|
||||||
|
"attachment.file_comment_placeholder": "Comment (optional)",
|
||||||
|
"attachment.place_inline": "Place inline",
|
||||||
|
"attachment.delete_file": "Delete file",
|
||||||
|
"attachment.option_disable_bbcode": "Disable BBCode",
|
||||||
|
"attachment.option_disable_smilies": "Disable smilies",
|
||||||
|
"attachment.option_disable_auto_urls": "Do not automatically parse URLs",
|
||||||
|
"attachment.option_attach_signature": "Attach a signature (signatures can be altered via the UCP)",
|
||||||
|
"attachment.option_notify_replies": "Notify me when a reply is posted",
|
||||||
|
"attachment.option_lock_topic": "Lock topic",
|
||||||
|
"attachment.invalid_extensions": "Not allowed: {{names}}.",
|
||||||
"thread.anonymous": "Anonymous",
|
"thread.anonymous": "Anonymous",
|
||||||
"thread.back_to_category": "Back to forum",
|
"thread.back_to_category": "Back to forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -230,6 +291,8 @@
|
|||||||
"thread.solved": "Solved",
|
"thread.solved": "Solved",
|
||||||
"thread.mark_solved": "Mark solved",
|
"thread.mark_solved": "Mark solved",
|
||||||
"thread.mark_unsolved": "Mark unsolved",
|
"thread.mark_unsolved": "Mark unsolved",
|
||||||
|
"thread.attachments": "Attachments",
|
||||||
|
"thread.attach_files": "Attach files",
|
||||||
"thread.views": "Views",
|
"thread.views": "Views",
|
||||||
"thread.last_post": "Last post",
|
"thread.last_post": "Last post",
|
||||||
"thread.by": "by",
|
"thread.by": "by",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AttachmentController;
|
||||||
|
use App\Http\Controllers\AttachmentExtensionController;
|
||||||
|
use App\Http\Controllers\AttachmentGroupController;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\ForumController;
|
use App\Http\Controllers\ForumController;
|
||||||
use App\Http\Controllers\I18nController;
|
use App\Http\Controllers\I18nController;
|
||||||
@@ -57,6 +60,24 @@ Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('au
|
|||||||
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachment-groups', [AttachmentGroupController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/attachment-groups', [AttachmentGroupController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/attachment-groups/reorder', [AttachmentGroupController::class, 'reorder'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachment-extensions', [AttachmentExtensionController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/attachment-extensions/public', [AttachmentExtensionController::class, 'publicIndex']);
|
||||||
|
Route::post('/attachment-extensions', [AttachmentExtensionController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachments', [AttachmentController::class, 'index']);
|
||||||
|
Route::post('/attachments', [AttachmentController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
|
||||||
|
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download']);
|
||||||
|
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/forums', [ForumController::class, 'index']);
|
Route::get('/forums', [ForumController::class, 'index']);
|
||||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||||
Route::post('/forums', [ForumController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/forums', [ForumController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
|||||||
Reference in New Issue
Block a user