21 Commits

Author SHA1 Message Date
64244567c0 Add attachment thumbnails and ACP refinements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-31 12:02:54 +01:00
7fbc566129 re-enabled autoseeding of attachment groups
All checks were successful
CI/CD Pipeline / test (push) Successful in 7s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-01-28 20:01:02 +01:00
c33cde6f04 added attchments
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-28 19:34:25 +01:00
2409feb06f feat: add solved threads
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 26s
2026-01-24 14:11:55 +01:00
e3dcf99362 feat: derive build from git commit count 2026-01-24 13:23:44 +01:00
357f6fb755 test pipeline 2026-01-23 19:26:53 +01:00
2281b80980 test pipeline 2026-01-23 19:26:53 +01:00
f23363fdcc test pipeline 2026-01-23 19:26:52 +01:00
c1814c0d47 test pipeline 2026-01-23 19:26:51 +01:00
7489a3903d test pipeline 2026-01-23 19:26:50 +01:00
b967aa912b test pipeline 2026-01-23 19:26:49 +01:00
67ae9517f4 added ansible playbook 2026-01-23 19:26:48 +01:00
653905d5e2 fixed post count 2026-01-23 19:26:43 +01:00
bc893b644d fixed post count 2026-01-23 19:26:42 +01:00
662e00bec1 fixed post count 2026-01-23 19:26:41 +01:00
a96913bffa fixed post count 2026-01-23 19:26:40 +01:00
79ac0cdca5 fix rank creation error 2026-01-23 19:26:39 +01:00
fe4b7ccd7c fix rank creation error 2026-01-23 19:26:38 +01:00
fc9de4c9fd added gitea worker 2026-01-23 19:26:37 +01:00
6b6f787351 chore: remove tracked storage artifacts 2026-01-23 19:26:36 +01:00
d4fb86633b feat: add installer, ranks/groups enhancements, and founder protections 2026-01-23 19:26:35 +01:00
89 changed files with 12051 additions and 4596 deletions

View File

@@ -0,0 +1,39 @@
name: CI/CD Pipeline
run-name: ${{ gitea.event.head_commit.message }}
on:
push:
branches:
- master
jobs:
test:
runs-on: debian-latest
steps:
- name: Show Debian version
run: cat /etc/os-release
- name: Test Deployment
run: echo "Deployment test"
deploy:
runs-on: self-hosted
needs: test
steps:
- name: Custom Checkout
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
SPEEDBB_REPO: ${{ vars.SPEEDBB_REPO }}
PROD_BASE_DIR: ${{ vars.PROD_BASE_DIR }}
ANSIBLE_POSIX_ACL: false
run: |
git clone --quiet --no-checkout --depth=1 --branch=${{ gitea.ref_name }} ${{ vars.SPEEDBB_REPO }} ./repo
cd repo
git config core.sparseCheckout true
echo "ansible/" > .git/info/sparse-checkout
git checkout HEAD
ls -la
cd ansible
pwd
ls -la
cat hosts.ini
echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass.txt
ansible-playbook --vault-password-file .vault_pass.txt deploy-to-prod.yaml
rm .vault_pass.txt

2
.gitignore vendored
View File

@@ -21,6 +21,8 @@
/public/build
/public/hot
/public/storage
/storage/app
/storage/framework
/storage/*.key
/storage/pail
/storage/framework/views/*.php

4
ansible/ansible.cfg Normal file
View File

@@ -0,0 +1,4 @@
[defaults]
inventory = ./hosts.ini
set_remote_user = yes
allow_world_readable_tmpfiles=true

View File

@@ -0,0 +1,15 @@
---
- name: Ping the hosts defined in hosts.ini
hosts: prod
vars_files:
- ./vars/vault.yaml
- ./vars/vars.yaml
gather_facts: yes
tasks:
- name: Ping the hosts
ping:
roles:
- speedBB

8
ansible/hosts.ini Normal file
View File

@@ -0,0 +1,8 @@
[dev]
fd20:2184:8045:4973:5054:ff:fe6c:13d1 ansible_connection=local
[prod]
support.24unix.net ansible_user=tracer ansible_become_password=

View File

@@ -0,0 +1,104 @@
---
- name: Check if base_dir exists
stat:
path: "{{ prod_base_dir }}"
register: base_dir_status
- name: Fetch latest code
git:
repo: "{{ git_repo }}"
dest: "{{ prod_base_dir }}"
version: "master"
update: yes
force: true
register: git_result
- debug:
var: git_result
- name: Check if .env exists
stat:
path: "{{ prod_base_dir }}/.env"
register: env_file
- name: Download and installs all libs and dependencies
community.general.composer:
command: install
arguments: --no-dev --optimize-autoloader
working_dir: "{{ prod_base_dir }}"
php_path: /usr/bin/keyhelp-php84
- name: Install node_modules
npm:
path: "{{ prod_base_dir }}"
state: present
when: git_result.changed
- name: Build frontend
command: "npm run build"
args:
chdir: "{{ prod_base_dir }}"
- name: Clear config cache
command: "keyhelp-php84 artisan config:clear"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Clear application cache
command: "keyhelp-php84 artisan cache:clear"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Create database backup directory
file:
path: "{{ prod_base_dir }}/backups"
state: directory
mode: '0755'
- name: Backup database before migrations
shell: |
cd {{ prod_base_dir }}
DB_USERNAME=$(grep DB_USERNAME .env | cut -d '=' -f2)
DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d '=' -f2)
DB_DATABASE=$(grep DB_DATABASE .env | cut -d '=' -f2)
BACKUP_FILE="{{ prod_base_dir }}/backups/db_backup_$(date +%Y%m%d_%H%M%S).sql"
mysqldump -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" > "$BACKUP_FILE"
echo "$BACKUP_FILE"
register: backup_result
when: env_file.stat.exists
- name: Display backup location
debug:
msg: "Database backed up to: {{ backup_result.stdout }}"
when: env_file.stat.exists
- name: Run database migrations safely
command: "keyhelp-php84 artisan migrate:safe --force"
args:
chdir: "{{ prod_base_dir }}"
register: migrate_result
failed_when: migrate_result.rc != 0
when: env_file.stat.exists
- name: Display migration result
debug:
var: migrate_result
when: env_file.stat.exists
- name: Remove old database backups (keep last 10)
shell: |
cd {{ prod_base_dir }}/backups
ls -t db_backup_*.sql | tail -n +11 | xargs -r rm
ignore_errors: yes
when: env_file.stat.exists
- name: Run version fetch command
command: "keyhelp-php84 artisan version:fetch"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Reload PHP-FPM to clear OPcache
command: sudo /usr/bin/systemctl reload keyhelp-php84-fpm.service

5
ansible/vars/vars.yaml Normal file
View File

@@ -0,0 +1,5 @@
---
git_repo: "{{ lookup('env', 'SPEEDBB_REPO') }}"
prod_base_dir: "{{ lookup('env', 'PROD_BASE_DIR') }}"
prod_become_user: "{{ vault_prod_become_user }}"

9
ansible/vars/vault.yaml Normal file
View File

@@ -0,0 +1,9 @@
$ANSIBLE_VAULT;1.1;AES256
31623264303535663263613235356231623137333734626164376138656532623937316534333835
3661666237386534373466356136393566333162326562330a383833363737323637363738616666
62393164326465376634356666303861613362313430656161653531373733353530636265353738
3863633131313834390a356663373338346137373662356161643336636534626130313466343566
36653636333838633938323363646335663935646135613632356434396436326131323361366561
32633939346163356131663266346539323330613536333838616332646139313731326133646165
31343763636337306263646631353562646462323631383439353738333035623664623163303839
34343261383738396534

View 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];
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class VersionFetch extends Command
{
protected $signature = 'version:fetch';
protected $description = 'Update the build number based on the git commit count of master.';
public function handle(): int
{
$version = Setting::where('key', 'version')->value('value');
$build = $this->resolveBuildCount();
if ($version === null) {
$this->error('Unable to determine version from settings.');
return self::FAILURE;
}
if ($build === null) {
$this->error('Unable to determine build number from git.');
return self::FAILURE;
}
Setting::updateOrCreate(
['key' => 'build'],
['value' => (string) $build],
);
if (!$this->syncComposerMetadata($version, $build)) {
$this->error('Failed to sync version/build to composer.json.');
return self::FAILURE;
}
$this->info("Build number updated to {$build}.");
return self::SUCCESS;
}
private function resolveBuildCount(): ?int
{
$commands = [
['git', 'rev-list', '--count', 'master'],
['git', 'rev-list', '--count', 'HEAD'],
];
foreach ($commands as $command) {
$process = new Process($command, base_path());
$process->run();
if ($process->isSuccessful()) {
$output = trim($process->getOutput());
if (is_numeric($output)) {
return (int) $output;
}
}
}
return null;
}
private function syncComposerMetadata(string $version, int $build): bool
{
$composerPath = base_path('composer.json');
if (!is_file($composerPath) || !is_readable($composerPath)) {
return false;
}
$raw = file_get_contents($composerPath);
if ($raw === false) {
return false;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return false;
}
$data['version'] = $version;
$data['build'] = (string) $build;
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return false;
}
$encoded .= "\n";
return file_put_contents($composerPath, $encoded) !== false;
}
}

View File

@@ -0,0 +1,482 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\Post;
use App\Models\Setting;
use App\Models\Thread;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
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);
$thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder);
$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,
'thumbnail_path' => $thumbnailPayload['path'] ?? null,
'thumbnail_mime_type' => $thumbnailPayload['mime'] ?? null,
'thumbnail_size_bytes' => $thumbnailPayload['size'] ?? null,
'original_name' => $file->getClientOriginalName(),
'extension' => $extension !== '' ? $extension : null,
'mime_type' => $mime,
'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 thumbnail(Attachment $attachment): Response
{
if (!$this->canViewAttachment($attachment)) {
abort(404);
}
if (!$attachment->thumbnail_path) {
abort(404);
}
$disk = Storage::disk($attachment->disk);
if (!$disk->exists($attachment->thumbnail_path)) {
abort(404);
}
$mime = $attachment->thumbnail_mime_type ?: 'image/jpeg';
return $disk->response($attachment->thumbnail_path, null, [
'Content-Type' => $mime,
]);
}
public function destroy(Request $request, Attachment $attachment): JsonResponse
{
$user = $request->user();
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
{
$isImage = str_starts_with((string) $attachment->mime_type, 'image/');
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",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => $isImage,
'created_at' => $attachment->created_at?->toIso8601String(),
];
}
private function maybeCreateThumbnail($file, string $scopeFolder): ?array
{
$enabled = $this->settingBool('attachments.create_thumbnails', true);
if (!$enabled) {
return null;
}
$mime = $file->getMimeType() ?? '';
if (!str_starts_with($mime, 'image/')) {
return null;
}
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
if ($maxWidth <= 0 || $maxHeight <= 0) {
return null;
}
$sourcePath = $file->getPathname();
$info = @getimagesize($sourcePath);
if (!$info) {
return null;
}
[$width, $height] = $info;
if ($width <= 0 || $height <= 0) {
return null;
}
if ($width <= $maxWidth && $height <= $maxHeight) {
return null;
}
$ratio = min($maxWidth / $width, $maxHeight / $height);
$targetWidth = max(1, (int) round($width * $ratio));
$targetHeight = max(1, (int) round($height * $ratio));
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
if (!$sourceImage) {
return null;
}
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
if (!$thumbImage) {
imagedestroy($sourceImage);
return null;
}
if (in_array($mime, ['image/png', 'image/gif'], true)) {
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
imagealphablending($thumbImage, false);
imagesavealpha($thumbImage, true);
}
imagecopyresampled(
$thumbImage,
$sourceImage,
0,
0,
0,
0,
$targetWidth,
$targetHeight,
$width,
$height
);
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
imagedestroy($sourceImage);
imagedestroy($thumbImage);
if ($thumbBinary === null) {
return null;
}
$filename = Str::uuid()->toString();
$extension = strtolower((string) $file->getClientOriginalExtension());
if ($extension !== '') {
$filename .= ".{$extension}";
}
$disk = 'local';
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
Storage::disk($disk)->put($thumbPath, $thumbBinary);
return [
'path' => $thumbPath,
'mime' => $mime,
'size' => strlen($thumbBinary),
];
}
private function createImageFromFile(string $path, string $mime)
{
return match ($mime) {
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
'image/png' => @imagecreatefrompng($path),
'image/gif' => @imagecreatefromgif($path),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
default => null,
};
}
private function renderImageBinary($image, string $mime, int $quality): ?string
{
ob_start();
$success = false;
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
$success = imagejpeg($image, null, max(10, min(95, $quality)));
} elseif ($mime === 'image/png') {
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
$success = imagepng($image, null, $compression);
} elseif ($mime === 'image/gif') {
$success = imagegif($image);
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
$success = imagewebp($image, null, max(10, min(95, $quality)));
}
$data = ob_get_clean();
if (!$success) {
return null;
}
return $data !== false ? $data : null;
}
private function settingBool(string $key, bool $default): bool
{
$value = Setting::query()->where('key', $key)->value('value');
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function settingInt(string $key, int $default): int
{
$value = Setting::query()->where('key', $key)->value('value');
if ($value === null) {
return $default;
}
return (int) $value;
}
}

View File

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

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

View File

@@ -22,17 +22,18 @@ class AuthController extends Controller
public function register(Request $request, CreateNewUser $creator): JsonResponse
{
$input = [
'name' => $request->input('name') ?? $request->input('username'),
'email' => $request->input('email'),
'password' => $request->input('password') ?? $request->input('plainPassword'),
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
'email' => $request->input(key: 'email'),
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
'password_confirmation' => $request->input(key: 'password_confirmation')
?? $request->input(key: 'plainPassword'),
];
$user = $creator->create($input);
$user = $creator->create(input: $input);
$user->sendEmailVerificationNotification();
return response()->json([
return response()->json(data: [
'user_id' => $user->id,
'email' => $user->email,
'message' => 'Verification email sent.',
@@ -41,87 +42,87 @@ class AuthController extends Controller
public function login(Request $request): JsonResponse
{
$request->merge([
'login' => $request->input('login', $request->input('email')),
$request->merge(input: [
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
]);
$request->validate([
$request->validate(rules: [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
]);
$login = trim((string) $request->input('login'));
$loginNormalized = Str::lower($login);
$login = trim(string: (string) $request->input(key: 'login'));
$loginNormalized = Str::lower(value: $login);
$userQuery = User::query();
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
$userQuery->whereRaw('lower(email) = ?', [$loginNormalized]);
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
} else {
$userQuery->where('name_canonical', $loginNormalized);
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
}
$user = $userQuery->first();
if (!$user || !Hash::check($request->input('password'), $user->password)) {
throw ValidationException::withMessages([
if (!$user || !Hash::check(value: $request->input(key: 'password'), hashedValue: $user->password)) {
throw ValidationException::withMessages(messages: [
'login' => ['Invalid credentials.'],
]);
}
if (!$user->hasVerifiedEmail()) {
return response()->json([
return response()->json(data : [
'message' => 'Email not verified.',
], 403);
], status: 403);
}
$token = $user->createToken('api')->plainTextToken;
$token = $user->createToken(name: 'api')->plainTextToken;
return response()->json([
return response()->json(data: [
'token' => $token,
'user_id' => $user->id,
'email' => $user->email,
'roles' => $user->roles()->pluck('name')->values(),
'roles' => $user->roles()->pluck(column: 'name')->values(),
]);
}
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
{
$user = User::findOrFail($id);
$user = User::findOrFail(id: $id);
if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
abort(403);
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
abort(code: 403);
}
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
event(new Verified(user: $user));
}
return redirect('/login');
return redirect(to: '/login');
}
public function forgotPassword(Request $request): JsonResponse
{
$request->validate([
$request->validate(rules: [
'email' => ['required', 'email'],
]);
$status = Password::sendResetLink(
$request->only('email')
$request->only(keys: 'email')
);
if ($status !== Password::RESET_LINK_SENT) {
throw ValidationException::withMessages([
'email' => [__($status)],
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(['message' => __($status)]);
return response()->json(data: ['message' => __(key: $status)]);
}
public function resetPassword(Request $request): JsonResponse
{
$request->validate([
$request->validate(rules: [
'token' => ['required'],
'email' => ['required', 'email'],
'password' => $this->passwordRules(),
@@ -130,51 +131,51 @@ class AuthController extends Controller
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
$user->forceFill(attributes: [
'password' => Hash::make(value: $password),
'remember_token' => Str::random(length: 60),
])->save();
event(new PasswordReset($user));
event(new PasswordReset(user: $user));
}
);
if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages([
'email' => [__($status)],
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(['message' => __($status)]);
return response()->json(data: ['message' => __(key: $status)]);
}
public function updatePassword(Request $request): JsonResponse
{
$request->validate([
$request->validate(rules: [
'current_password' => ['required'],
'password' => $this->passwordRules(),
]);
$user = $request->user();
if (!$user || !Hash::check($request->input('current_password'), $user->password)) {
throw ValidationException::withMessages([
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
throw ValidationException::withMessages(messages: [
'current_password' => ['Invalid current password.'],
]);
}
$user->forceFill([
'password' => Hash::make($request->input('password')),
'remember_token' => Str::random(60),
$user->forceFill(attributes: [
'password' => Hash::make(value: $request->input(key: 'password')),
'remember_token' => Str::random(length: 60),
])->save();
return response()->json(['message' => 'Password updated.']);
return response()->json(data: ['message' => 'Password updated.']);
}
public function logout(Request $request): JsonResponse
{
$request->user()?->currentAccessToken()?->delete();
return response()->json(null, 204);
return response()->json(data: null, status: 204);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Post;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -14,31 +15,31 @@ class ForumController extends Controller
{
$query = Forum::query()
->withoutTrashed()
->withCount(['threads', 'posts'])
->withSum('threads', 'views_count');
->withCount(relations: ['threads', 'posts'])
->withSum(relation: 'threads', column: 'views_count');
$parentParam = $request->query('parent');
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$parentParam = $request->query(key: 'parent');
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
if ($exists === false) {
$query->whereNull('parent_id');
$query->whereNull(columns: 'parent_id');
} elseif ($exists === true) {
$query->whereNotNull('parent_id');
$query->whereNotNull(columns: 'parent_id');
}
} elseif (is_string($parentParam)) {
$parentId = $this->parseIriId($parentParam);
} elseif (is_string(value: $parentParam)) {
$parentId = $this->parseIriId(value: $parentParam);
if ($parentId !== null) {
$query->where('parent_id', $parentId);
$query->where(column: 'parent_id', operator: $parentId);
}
}
if ($request->filled('type')) {
$query->where('type', $request->query('type'));
if ($request->filled(key: 'type')) {
$query->where(column: 'type', operator: $request->query(key: 'type'));
}
$forums = $query
->orderBy('position')
->orderBy('name')
->orderBy(column: 'position')
->orderBy(column: 'name')
->get();
$forumIds = $forums->pluck('id')->all();
@@ -211,11 +212,13 @@ class ForumController extends Controller
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position,
'threads_count' => $forum->threads_count ?? 0,
'posts_count' => $forum->posts_count ?? 0,
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name,
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(),
];
@@ -234,7 +237,7 @@ class ForumController extends Controller
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with('user')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
@@ -256,8 +259,28 @@ class ForumController extends Controller
->where('threads.forum_id', $forumId)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with('user')
->orderByDesc(column: 'posts.created_at')
->with(relations: ['user.rank', 'user.roles'])
->first();
}
private function resolveGroupColor(?User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy(callback: 'name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\View;
class InstallerController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
if ($this->envExists()) {
return redirect('/');
}
return view('installer', [
'appUrl' => $request->getSchemeAndHttpHost(),
]);
}
public function store(Request $request): View|RedirectResponse
{
if ($this->envExists()) {
return redirect('/');
}
$data = $request->validate([
'app_url' => ['required', 'url'],
'db_host' => ['required', 'string', 'max:255'],
'db_port' => ['nullable', 'integer'],
'db_database' => ['required', 'string', 'max:255'],
'db_username' => ['required', 'string', 'max:255'],
'db_password' => ['nullable', 'string'],
'admin_name' => ['required', 'string', 'max:255'],
'admin_email' => ['required', 'email', 'max:255'],
'admin_password' => ['required', 'string', 'min:8'],
]);
$appKey = 'base64:' . base64_encode(random_bytes(32));
$envLines = [
'APP_NAME="speedBB"',
'APP_ENV=production',
'APP_DEBUG=false',
'APP_URL=' . $data['app_url'],
'APP_KEY=' . $appKey,
'',
'DB_CONNECTION=mysql',
'DB_HOST=' . $data['db_host'],
'DB_PORT=' . ($data['db_port'] ?: 3306),
'DB_DATABASE=' . $data['db_database'],
'DB_USERNAME=' . $data['db_username'],
'DB_PASSWORD=' . ($data['db_password'] ?? ''),
'',
'MAIL_MAILER=sendmail',
'MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"',
'MAIL_FROM_ADDRESS="hello@example.com"',
'MAIL_FROM_NAME="speedBB"',
];
$this->writeEnv(implode("\n", $envLines) . "\n");
config([
'app.key' => $appKey,
'app.url' => $data['app_url'],
'database.default' => 'mysql',
'database.connections.mysql.host' => $data['db_host'],
'database.connections.mysql.port' => (int) ($data['db_port'] ?: 3306),
'database.connections.mysql.database' => $data['db_database'],
'database.connections.mysql.username' => $data['db_username'],
'database.connections.mysql.password' => $data['db_password'] ?? '',
'mail.default' => 'sendmail',
'mail.mailers.sendmail.path' => '/usr/sbin/sendmail -bs -i',
]);
DB::purge('mysql');
try {
DB::connection('mysql')->getPdo();
} catch (\Throwable $e) {
$this->removeEnv();
return view('installer', [
'appUrl' => $data['app_url'],
'error' => 'Database connection failed: ' . $e->getMessage(),
'old' => $data,
]);
}
$migrateExit = Artisan::call('migrate', ['--force' => true]);
if ($migrateExit !== 0) {
$this->removeEnv();
return view('installer', [
'appUrl' => $data['app_url'],
'error' => 'Migration failed. Please check your database credentials.',
'old' => $data,
]);
}
$adminRole = Role::firstOrCreate(['name' => 'ROLE_ADMIN']);
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER']);
$user = User::create([
'name' => $data['admin_name'],
'name_canonical' => Str::lower(trim($data['admin_name'])),
'email' => $data['admin_email'],
'password' => Hash::make($data['admin_password']),
'email_verified_at' => now(),
]);
$user->roles()->sync([$adminRole->id, $founderRole->id]);
return view('installer-success');
}
private function envExists(): bool
{
return file_exists(base_path('.env'));
}
private function writeEnv(string $contents): void
{
$path = base_path('.env');
file_put_contents($path, $contents);
}
private function removeEnv(): void
{
$path = base_path('.env');
if (file_exists($path)) {
unlink($path);
}
}
}

View File

@@ -33,8 +33,9 @@ class PortalController extends Controller
->withoutTrashed()
->withCount('posts')
->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
'user' => fn ($query) => $query->withCount(['posts', 'threads'])->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])
->latest('created_at')
->limit(12)
@@ -43,7 +44,8 @@ class PortalController extends Controller
$stats = [
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count()
+ Thread::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
];
@@ -62,7 +64,9 @@ class PortalController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
] : null,
]);
}
@@ -77,11 +81,13 @@ class PortalController extends Controller
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position,
'threads_count' => $forum->threads_count ?? 0,
'posts_count' => $forum->posts_count ?? 0,
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name,
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(),
];
@@ -93,15 +99,16 @@ class PortalController extends Controller
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => $thread->posts_count ?? 0,
'posts_count' => ($thread->posts_count ?? 0) + 1,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => $thread->user?->posts_count,
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
@@ -109,12 +116,18 @@ class PortalController extends Controller
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
?? $thread->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
?? $this->resolveGroupColor($thread->user),
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
];
@@ -133,7 +146,7 @@ class PortalController extends Controller
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with('user')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
@@ -146,4 +159,24 @@ class PortalController extends Controller
return $byForum;
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Http\Controllers;
use App\Actions\BbcodeFormatter;
use App\Models\Post;
use App\Models\Thread;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -13,7 +15,11 @@ class PostController extends Controller
public function index(Request $request): JsonResponse
{
$query = Post::query()->withoutTrashed()->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
$threadParam = $request->query('thread');
@@ -49,7 +55,11 @@ class PostController extends Controller
]);
$post->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
return response()->json($this->serializePost($post), 201);
@@ -83,26 +93,126 @@ class PostController extends Controller
private function serializePost(Post $post): array
{
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
$bodyHtml = $this->renderBody($post->body, $attachments);
return [
'id' => $post->id,
'body' => $post->body,
'body_html' => $bodyHtml,
'thread' => "/api/threads/{$post->thread_id}",
'user_id' => $post->user_id,
'user_name' => $post->user?->name,
'user_avatar_url' => $post->user?->avatar_path
? Storage::url($post->user->avatar_path)
: null,
'user_posts_count' => $post->user?->posts_count,
'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0),
'user_created_at' => $post->user?->created_at?->toIso8601String(),
'user_location' => $post->user?->location,
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
'user_rank_name' => $post->user?->rank?->name,
'user_rank_badge_type' => $post->user?->rank?->badge_type,
'user_rank_badge_text' => $post->user?->rank?->badge_text,
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
? Storage::url($post->user->rank->badge_image_path)
: null,
'user_rank_color' => $post->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($post->user),
'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_at?->toIso8601String(),
'attachments' => $post->relationLoaded('attachments')
? $attachments
->map(fn ($attachment) => [
'id' => $attachment->id,
'group' => $attachment->group ? [
'id' => $attachment->group->id,
'name' => $attachment->group->name,
] : null,
'original_name' => $attachment->original_name,
'extension' => $attachment->extension,
'mime_type' => $attachment->mime_type,
'size_bytes' => $attachment->size_bytes,
'download_url' => "/api/attachments/{$attachment->id}/download",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
'created_at' => $attachment->created_at?->toIso8601String(),
])
->values()
: [],
];
}
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/') && $this->displayImagesInline()) {
return "[img]{$url}[/img]";
}
return "[url={$url}]{$rawName}[/url]";
}, $body) ?? $body;
}
private function displayImagesInline(): bool
{
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
if ($value === null) {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PostThankController extends Controller
{
public function store(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$thank = PostThank::firstOrCreate([
'post_id' => $post->id,
'user_id' => $user->id,
]);
return response()->json([
'id' => $thank->id,
'post_id' => $post->id,
'user_id' => $user->id,
], 201);
}
public function destroy(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
PostThank::where('post_id', $post->id)
->where('user_id', $user->id)
->delete();
return response()->json(null, 204);
}
public function given(User $user): JsonResponse
{
$thanks = PostThank::query()
->where('user_id', $user->id)
->with(['post.thread', 'post.user.rank', 'post.user.roles'])
->latest('created_at')
->get()
->map(fn (PostThank $thank) => $this->serializeGiven($thank));
return response()->json($thanks);
}
public function received(User $user): JsonResponse
{
$thanks = PostThank::query()
->whereHas('post', fn ($query) => $query->where('user_id', $user->id))
->with(['post.thread', 'user.rank', 'user.roles'])
->latest('created_at')
->get()
->map(fn (PostThank $thank) => $this->serializeReceived($thank));
return response()->json($thanks);
}
private function serializeGiven(PostThank $thank): array
{
return [
'id' => $thank->id,
'post_id' => $thank->post_id,
'thread_id' => $thank->post?->thread_id,
'thread_title' => $thank->post?->thread?->title,
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
'post_author_id' => $thank->post?->user_id,
'post_author_name' => $thank->post?->user?->name,
'post_author_rank_color' => $thank->post?->user?->rank?->color,
'post_author_group_color' => $this->resolveGroupColor($thank->post?->user),
'thanked_at' => $thank->created_at?->toIso8601String(),
];
}
private function serializeReceived(PostThank $thank): array
{
return [
'id' => $thank->id,
'post_id' => $thank->post_id,
'thread_id' => $thank->post?->thread_id,
'thread_title' => $thank->post?->thread?->title,
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
'thanker_id' => $thank->user_id,
'thanker_name' => $thank->user?->name,
'thanker_rank_color' => $thank->user?->rank?->color,
'thanker_group_color' => $this->resolveGroupColor($thank->user),
'thanked_at' => $thank->created_at?->toIso8601String(),
];
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View 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']),
]);
}
}

View File

@@ -12,8 +12,8 @@ class RankController 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);
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
return response()->json(data: ['message' => 'Forbidden'], status: 403);
}
return null;
@@ -29,6 +29,7 @@ class RankController extends Controller
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,
@@ -45,19 +46,24 @@ class RankController extends Controller
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
'badge_type' => ['nullable', 'in:text,image'],
'badge_type' => ['nullable', 'in:text,image,none'],
'badge_text' => ['nullable', 'string', 'max:40'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$badgeType = $data['badge_type'] ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $data['name'])
: null;
if ($badgeType === 'none') {
$badgeText = null;
}
$rank = Rank::create([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
'color' => $data['color'] ?? null,
]);
return response()->json([
@@ -65,6 +71,7 @@ class RankController extends Controller
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => null,
], 201);
}
@@ -77,16 +84,21 @@ class RankController extends Controller
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
'badge_type' => ['nullable', 'in:text,image'],
'badge_type' => ['nullable', 'in:text,image,none'],
'badge_text' => ['nullable', 'string', 'max:40'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
: null;
if ($badgeType === 'none') {
$badgeText = null;
}
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
if ($badgeType === 'text' && $rank->badge_image_path) {
if ($badgeType !== 'image' && $rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
$rank->badge_image_path = null;
}
@@ -95,6 +107,7 @@ class RankController extends Controller
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
'color' => $color,
]);
return response()->json([
@@ -102,6 +115,7 @@ class RankController extends Controller
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RoleController extends Controller
{
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
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;
}
$roles = Role::query()
->orderBy('name')
->get()
->map(fn (Role $role) => [
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
]);
return response()->json($roles);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$normalizedName = $this->normalizeRoleName($data['name']);
if (Role::query()->where('name', $normalizedName)->exists()) {
return response()->json(['message' => 'Role already exists.'], 422);
}
$role = Role::create([
'name' => $normalizedName,
'color' => $data['color'] ?? null,
]);
return response()->json([
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
], 201);
}
public function update(Request $request, Role $role): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$normalizedName = $this->normalizeRoleName($data['name']);
if (Role::query()
->where('id', '!=', $role->id)
->where('name', $normalizedName)
->exists()
) {
return response()->json(['message' => 'Role already exists.'], 422);
}
if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) {
return response()->json(['message' => 'Core roles cannot be renamed.'], 422);
}
$color = array_key_exists('color', $data) ? $data['color'] : $role->color;
$role->update([
'name' => $normalizedName,
'color' => $color,
]);
return response()->json([
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
]);
}
public function destroy(Request $request, Role $role): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if (in_array($role->name, self::CORE_ROLES, true)) {
return response()->json(['message' => 'Core roles cannot be deleted.'], 422);
}
if ($role->users()->exists()) {
return response()->json(['message' => 'Role is assigned to users.'], 422);
}
$role->delete();
return response()->json(null, 204);
}
private function normalizeRoleName(string $value): string
{
$raw = strtoupper(trim($value));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
return 'ROLE_';
}
if (str_starts_with($raw, 'ROLE_')) {
return $raw;
}
return "ROLE_{$raw}";
}
}

View File

@@ -13,7 +13,8 @@ class StatsController extends Controller
{
return response()->json([
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count()
+ Thread::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
]);
}

View File

@@ -4,8 +4,11 @@ namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Thread;
use App\Actions\BbcodeFormatter;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller
@@ -15,9 +18,13 @@ class ThreadController extends Controller
$query = Thread::query()
->withoutTrashed()
->withCount('posts')
->withMax('posts', 'created_at')
->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
]);
$forumParam = $request->query('forum');
@@ -29,7 +36,7 @@ class ThreadController extends Controller
}
$threads = $query
->latest('created_at')
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
->get()
->map(fn (Thread $thread) => $this->serializeThread($thread));
@@ -41,8 +48,13 @@ class ThreadController extends Controller
$thread->increment('views_count');
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
@@ -70,8 +82,13 @@ class ThreadController extends Controller
]);
$thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
'latestPost.user',
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread), 201);
@@ -86,6 +103,38 @@ class ThreadController extends Controller
return response()->json(null, 204);
}
public function updateSolved(Request $request, Thread $thread): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $thread->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
}
$data = $request->validate([
'solved' => ['required', 'boolean'],
]);
$thread->solved = $data['solved'];
$thread->save();
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
private function parseIriId(?string $value): ?int
{
if (!$value) {
@@ -105,35 +154,140 @@ class ThreadController extends Controller
private function serializeThread(Thread $thread): array
{
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
$bodyHtml = $this->renderBody($thread->body, $attachments);
return [
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
'body_html' => $bodyHtml,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => $thread->posts_count ?? 0,
'posts_count' => ($thread->posts_count ?? 0) + 1,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => $thread->user?->posts_count,
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_location' => $thread->user?->location,
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
?? $thread->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
?? $this->resolveGroupColor($thread->user),
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
'attachments' => $thread->relationLoaded('attachments')
? $attachments
->map(fn ($attachment) => [
'id' => $attachment->id,
'group' => $attachment->group ? [
'id' => $attachment->group->id,
'name' => $attachment->group->name,
] : null,
'original_name' => $attachment->original_name,
'extension' => $attachment->extension,
'mime_type' => $attachment->mime_type,
'size_bytes' => $attachment->size_bytes,
'download_url' => "/api/attachments/{$attachment->id}/download",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
'created_at' => $attachment->created_at?->toIso8601String(),
])
->values()
: [],
];
}
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/') && $this->displayImagesInline()) {
return "[img]{$url}[/img]";
}
return "[url={$url}]{$rawName}[/url]";
}, $body) ?? $body;
}
private function displayImagesInline(): bool
{
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
if ($value === null) {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -26,7 +27,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles->pluck('name')->values(),
]);
@@ -50,7 +53,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
@@ -65,7 +70,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'created_at' => $user->created_at?->toIso8601String(),
]);
}
@@ -101,7 +108,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
@@ -112,6 +121,9 @@ class UserController extends Controller
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'rank_id' => ['nullable', 'exists:ranks,id'],
@@ -127,7 +139,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
]);
}
@@ -137,6 +151,9 @@ class UserController extends Controller
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
@@ -148,8 +165,18 @@ class UserController extends Controller
Rule::unique('users', 'email')->ignore($user->id),
],
'rank_id' => ['nullable', 'exists:ranks,id'],
'roles' => ['nullable', 'array'],
'roles.*' => ['string', 'exists:roles,name'],
]);
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
$requested = collect($data['roles'] ?? [])
->map(fn ($name) => $this->normalizeRoleName($name));
if ($requested->contains('ROLE_FOUNDER')) {
return response()->json(['message' => 'Forbidden'], 403);
}
}
$nameCanonical = Str::lower(trim($data['name']));
$nameConflict = User::query()
->where('id', '!=', $user->id)
@@ -171,6 +198,19 @@ class UserController extends Controller
'rank_id' => $data['rank_id'] ?? null,
])->save();
if (array_key_exists('roles', $data)) {
$roleNames = collect($data['roles'] ?? [])
->map(fn ($name) => $this->normalizeRoleName($name))
->unique()
->values()
->all();
$roleIds = Role::query()
->whereIn('name', $roleNames)
->pluck('id')
->all();
$user->roles()->sync($roleIds);
}
$user->loadMissing('rank');
return response()->json([
@@ -181,7 +221,9 @@ class UserController extends Controller
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
@@ -194,4 +236,42 @@ class UserController extends Controller
return Storage::url($user->avatar_path);
}
private function resolveGroupColor(User $user): ?string
{
$user->loadMissing('roles');
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
private function normalizeRoleName(string $value): string
{
$raw = strtoupper(trim($value));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
return 'ROLE_';
}
if (str_starts_with($raw, 'ROLE_')) {
return $raw;
}
return "ROLE_{$raw}";
}
private function isFounder(User $user): bool
{
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
}
}

74
app/Models/Attachment.php Normal file
View File

@@ -0,0 +1,74 @@
<?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',
'thumbnail_path',
'thumbnail_mime_type',
'thumbnail_size_bytes',
'original_name',
'extension',
'mime_type',
'size_bytes',
];
protected $casts = [
'size_bytes' => 'int',
'thumbnail_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);
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string $body
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
* @property-read \App\Models\Thread $thread
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
@@ -45,4 +47,14 @@ class Post extends Model
{
return $this->belongsTo(User::class);
}
public function thanks(): HasMany
{
return $this->hasMany(PostThank::class);
}
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class);
}
}

24
app/Models/PostThank.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PostThank extends Model
{
protected $fillable = [
'post_id',
'user_id',
];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -19,6 +19,7 @@ class Rank extends Model
'badge_type',
'badge_text',
'badge_image_path',
'color',
];
public function users(): HasMany

View File

@@ -25,6 +25,7 @@ class Role extends Model
{
protected $fillable = [
'name',
'color',
];
public function users(): BelongsToMany

View File

@@ -14,9 +14,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $user_id
* @property string $title
* @property string $body
* @property bool $solved
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forum $forum
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
* @property-read int|null $posts_count
* @property-read \App\Models\User|null $user
@@ -41,6 +43,11 @@ class Thread extends Model
'user_id',
'title',
'body',
'solved',
];
protected $casts = [
'solved' => 'bool',
];
public function forum(): BelongsTo
@@ -58,6 +65,11 @@ class Thread extends Model
return $this->hasMany(Post::class);
}
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
@@ -106,6 +107,21 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Post::class);
}
public function threads(): HasMany
{
return $this->hasMany(Thread::class);
}
public function thanksGiven(): HasMany
{
return $this->hasMany(PostThank::class);
}
public function thanksReceived(): HasManyThrough
{
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
}
public function rank()
{
return $this->belongsTo(Rank::class);

View File

@@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withCommands([
__DIR__.'/../app/Console/Commands',
])
->withMiddleware(function (Middleware $middleware): void {
//
})

View File

@@ -11,6 +11,7 @@
"laravel/framework": "^12.0",
"laravel/sanctum": "*",
"laravel/tinker": "^2.10.1",
"s9e/text-formatter": "^2.5",
"ext-pdo": "*"
},
"require-dev": {

524
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_thanks', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['post_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('post_thanks');
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('ranks', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('badge_image_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$roles = DB::table('roles')
->select(['id', 'name'])
->get();
foreach ($roles as $role) {
$name = (string) $role->name;
if (str_starts_with($name, 'ROLE_')) {
continue;
}
$raw = strtoupper(trim($name));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
continue;
}
$normalized = str_starts_with($raw, 'ROLE_') ? $raw : "ROLE_{$raw}";
$exists = DB::table('roles')
->where('id', '!=', $role->id)
->where('name', $normalized)
->exists();
if ($exists) {
continue;
}
DB::table('roles')
->where('id', $role->id)
->update(['name' => $normalized]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// No safe reversal.
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('roles', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('threads', function (Blueprint $table) {
$table->boolean('solved')->default(false)->after('body');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('solved');
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('path');
$table->string('thumbnail_mime_type', 150)->nullable()->after('thumbnail_path');
$table->unsignedBigInteger('thumbnail_size_bytes')->nullable()->after('thumbnail_mime_type');
});
}
public function down(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->dropColumn(['thumbnail_path', 'thumbnail_mime_type', 'thumbnail_size_bytes']);
});
}
};

298
package-lock.json generated
View File

@@ -2072,18 +2072,6 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2888,18 +2876,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3010,280 +2986,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@@ -4,8 +4,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"dev": "vite",
"watch": "vite build --watch",
"lint": "eslint ."
},
"dependencies": {

View File

@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Allow the installer to run without a .env file.
if (!file_exists(__DIR__.'/../.env')) {
$tempKey = 'base64:'.base64_encode(random_bytes(32));
$_ENV['APP_KEY'] = $tempKey;
$_SERVER['APP_KEY'] = $tempKey;
$_ENV['DB_CONNECTION'] = 'sqlite';
$_SERVER['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
$_SERVER['DB_DATABASE'] = ':memory:';
$_ENV['SESSION_DRIVER'] = 'array';
$_SERVER['SESSION_DRIVER'] = 'array';
$_ENV['SESSION_DOMAIN'] = null;
$_SERVER['SESSION_DOMAIN'] = null;
$_ENV['SESSION_SECURE_COOKIE'] = false;
$_SERVER['SESSION_SECURE_COOKIE'] = false;
}
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';

View File

@@ -218,11 +218,11 @@ function PortalHeader({
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>}
{crumb.current ? (
<span className="bb-portal-current">
<Link to={crumb.to} className="bb-portal-current bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label}
</span>
</Link>
) : (
<Link to={crumb.to} className="bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
@@ -426,7 +426,7 @@ function AppShell() {
])
return (
<div className="bb-shell">
<div className="bb-shell" id="top">
<PortalHeader
isAuthenticated={!!token}
forumName={settings.forumName}

View File

@@ -97,6 +97,14 @@ export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`)
}
export async function listUserThanksGiven(id) {
return apiFetch(`/user/${id}/thanks/given`)
}
export async function listUserThanksReceived(id) {
return apiFetch(`/user/${id}/thanks/received`)
}
export async function fetchVersion() {
return apiFetch('/version')
}
@@ -109,6 +117,13 @@ export async function fetchPortalSummary() {
return apiFetch('/portal/summary')
}
export async function previewBbcode(body) {
return apiFetch('/preview', {
method: 'POST',
body: JSON.stringify({ body }),
})
}
export async function fetchSetting(key) {
// TODO: Prefer fetchSettings() when multiple settings are needed.
const cacheBust = Date.now()
@@ -238,6 +253,100 @@ export async function getThread(id) {
return apiFetch(`/threads/${id}`)
}
export async function updateThreadSolved(threadId, solved) {
return apiFetch(`/threads/${threadId}/solved`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({ solved }),
})
}
export async function listAttachmentsByThread(threadId) {
return getCollection(`/attachments?thread=/api/threads/${threadId}`)
}
export async function listAttachmentsByPost(postId) {
return getCollection(`/attachments?post=/api/posts/${postId}`)
}
export async function uploadAttachment({ threadId, postId, file }) {
const body = new FormData()
if (threadId) body.append('thread', `/api/threads/${threadId}`)
if (postId) body.append('post', `/api/posts/${postId}`)
body.append('file', file)
return apiFetch('/attachments', {
method: 'POST',
body,
})
}
export async function deleteAttachment(id) {
return apiFetch(`/attachments/${id}`, {
method: 'DELETE',
})
}
export async function listAttachmentGroups() {
return getCollection('/attachment-groups')
}
export async function createAttachmentGroup(payload) {
return apiFetch('/attachment-groups', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentGroup(id, payload) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentGroup(id) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'DELETE',
})
}
export async function reorderAttachmentGroups(parentId, orderedIds) {
return apiFetch('/attachment-groups/reorder', {
method: 'POST',
body: JSON.stringify({ parentId, orderedIds }),
})
}
export async function listAttachmentExtensions() {
return getCollection('/attachment-extensions')
}
export async function listAttachmentExtensionsPublic() {
return getCollection('/attachment-extensions/public')
}
export async function createAttachmentExtension(payload) {
return apiFetch('/attachment-extensions', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentExtension(id, payload) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentExtension(id) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'DELETE',
})
}
export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`)
}
@@ -250,6 +359,30 @@ export async function listRanks() {
return getCollection('/ranks')
}
export async function listRoles() {
return getCollection('/roles')
}
export async function createRole(payload) {
return apiFetch('/roles', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRole(roleId, payload) {
return apiFetch(`/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRole(roleId) {
return apiFetch(`/roles/${roleId}`, {
method: 'DELETE',
})
}
export async function updateUserRank(userId, rankId) {
return apiFetch(`/users/${userId}/rank`, {
method: 'PATCH',

View File

@@ -6,6 +6,14 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
const authorName = thread.user_name || t('thread.anonymous')
const lastAuthorName = thread.last_post_user_name || authorName
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
const authorLinkColor = thread.user_rank_color || thread.user_group_color
const authorLinkStyle = authorLinkColor
? { '--bb-user-link-color': authorLinkColor }
: undefined
const lastAuthorLinkColor = thread.last_post_user_rank_color || thread.last_post_user_group_color
const lastAuthorLinkStyle = lastAuthorLinkColor
? { '--bb-user-link-color': lastAuthorLinkColor }
: undefined
const formatDateTime = (value) => {
if (!value) return '—'
@@ -20,6 +28,8 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
}
const repliesCount = Math.max((thread.posts_count ?? 0) - 1, 0)
return (
<div className="bb-portal-topic-row">
<div className="bb-portal-topic-main">
@@ -29,12 +39,19 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
<div>
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
{thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</Link>
<div className="bb-portal-topic-meta">
<div className="bb-portal-topic-meta-line">
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
{thread.user_id ? (
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
<Link
to={`/profile/${thread.user_id}`}
className="bb-portal-topic-author"
style={authorLinkStyle}
>
{authorName}
</Link>
) : (
@@ -60,14 +77,18 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
</div>
</div>
</div>
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
<div className="bb-portal-topic-cell">{repliesCount}</div>
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
<div className="bb-portal-last">
<span className="bb-portal-last-by">
{t('thread.by')}{' '}
{thread.last_post_user_id ? (
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
<Link
to={`/profile/${thread.last_post_user_id}`}
className="bb-portal-last-user"
style={lastAuthorLinkStyle}
>
{lastAuthorName}
</Link>
) : (

View File

@@ -105,6 +105,40 @@ a {
margin: 0;
font-size: 1.6rem;
color: var(--bb-accent, #f29b3f);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.bb-thread-solved-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: var(--bb-accent, #f29b3f);
color: #0b0f17;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-left: 0.45rem;
}
.bb-thread-solved-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.bb-thread-solved-toggle:hover,
.bb-thread-solved-toggle:focus {
background: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
color: #0b0f17;
}
.bb-thread-meta {
@@ -136,6 +170,322 @@ a {
flex-wrap: wrap;
}
.bb-thread-attachments {
border: 1px solid var(--bb-border);
border-radius: 12px;
padding: 0.8rem 1rem;
background: #141822;
}
.bb-thread-attachments-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
font-weight: 600;
color: var(--bb-ink);
margin-bottom: 0.6rem;
}
.bb-thread-attachments-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.bb-thread-attachments-actions input[type='file'] {
max-width: 280px;
color: var(--bb-ink-muted);
}
.bb-thread-modal.modal-dialog {
max-width: 95vw !important;
width: 95vw !important;
height: 95vh;
margin: 2.5vh auto;
}
.bb-thread-modal.modal-dialog .modal-content {
height: 95vh;
width: 100%;
}
.bb-thread-modal.modal-dialog .modal-body {
overflow-y: auto;
}
.bb-attachment-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.bb-post-content .bb-attachment-list {
margin-top: 0.4rem;
}
.bb-attachment-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--bb-ink);
text-decoration: none;
padding: 0.35rem 0.5rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-attachment-item .bi-paperclip {
color: var(--bb-accent, #f29b3f);
}
.bb-attachment-item:hover {
border-color: var(--bb-accent, #f29b3f);
color: var(--bb-accent, #f29b3f);
}
.bb-attachment-name {
font-weight: 600;
}
.bb-attachment-meta {
color: var(--bb-ink-muted);
font-size: 0.8rem;
}
.bb-attachment-panel {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(18, 23, 33, 0.9);
overflow: hidden;
}
.bb-attachment-tabs {
display: flex;
gap: 0.4rem;
padding: 0.6rem 0.8rem 0;
}
.bb-attachment-tab {
border: none;
background: #1a202b;
color: var(--bb-ink-muted);
padding: 0.35rem 0.75rem;
border-radius: 8px 8px 0 0;
font-size: 0.85rem;
font-weight: 600;
}
.bb-attachment-tab.is-active {
color: var(--bb-accent, #f29b3f);
background: #202735;
}
.bb-attachment-body {
padding: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-attachment-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.8rem;
}
.bb-attachment-drop {
border: 2px dashed color-mix(in srgb, var(--bb-accent, #f29b3f) 65%, transparent);
border-radius: 12px;
padding: 0.85rem 1rem;
background: rgba(18, 23, 33, 0.6);
color: var(--bb-ink-muted);
text-align: center;
margin-bottom: 0.8rem;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.bb-attachment-drop.is-dragover {
border-color: var(--bb-accent, #f29b3f);
background: rgba(242, 155, 63, 0.12);
color: var(--bb-ink);
}
.bb-attachment-drop-link {
border: 0;
padding: 0;
background: transparent;
color: var(--bb-accent, #f29b3f);
font-weight: 600;
text-decoration: underline;
}
.bb-attachment-drop-link:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-attachment-input {
display: none;
}
.bb-attachment-table {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
margin-bottom: 0;
color: var(--bb-ink);
}
.bb-attachment-table thead th {
font-size: 0.7rem;
text-transform: none;
letter-spacing: 0.02em;
color: var(--bb-ink-muted);
background: rgba(255, 255, 255, 0.03);
text-align: left;
border-bottom: 0;
padding: 0.6rem 0.8rem;
}
.bb-attachment-table tbody td {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 0.6rem 0.8rem;
vertical-align: middle;
}
.bb-attachment-table thead th:nth-child(3),
.bb-attachment-table tbody td:nth-child(3) {
width: 90px;
}
.bb-attachment-table thead th:nth-child(4),
.bb-attachment-table tbody td:nth-child(4) {
width: 80px;
}
.bb-attachment-table thead th:nth-child(5),
.bb-attachment-table tbody td:nth-child(5) {
width: 1%;
white-space: nowrap;
}
.bb-attachment-name {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-attachment-size {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-status {
color: #8bd98b;
font-size: 0.85rem;
}
.bb-attachment-comment {
background: #202734;
border-color: rgba(255, 255, 255, 0.08);
color: var(--bb-ink);
font-size: 0.85rem;
}
.bb-attachment-row-actions {
display: inline-flex;
align-items: center;
gap: 0;
justify-content: flex-end;
background: var(--bb-accent, #f29b3f);
border-radius: 10px;
padding: 0.2rem;
width: fit-content;
}
.bb-attachment-action {
border: 0;
background: transparent;
color: #0e121b;
width: 36px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.bb-attachment-action:hover {
transform: translateY(-1px);
background: color-mix(in srgb, #fff 18%, transparent);
color: #0e121b;
}
.bb-attachment-remove {
border: none;
background: rgba(255, 255, 255, 0.06);
color: var(--bb-ink-muted);
width: 32px;
height: 28px;
border-radius: 6px;
}
.bb-attachment-remove:hover {
color: #f07f7f;
background: rgba(240, 127, 127, 0.12);
}
.bb-attachment-empty {
padding: 0.8rem;
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-options {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.2rem 0;
}
.bb-attachment-options .form-check-label {
color: var(--bb-ink);
}
.bb-attachment-options .form-check-input {
background-color: #1a202b;
border-color: rgba(255, 255, 255, 0.2);
}
.bb-attachment-options .form-check-input:checked {
background-color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus {
border-color: var(--bb-accent, #f29b3f);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 30%, transparent);
}
.tr-header {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.tr-header th {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.rdt_TableHeadRow {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.bb-thread-actions {
display: flex;
align-items: center;
@@ -317,6 +667,9 @@ a {
.bb-post-content {
padding: 1rem 1.35rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.bb-post-header {
@@ -360,16 +713,37 @@ a {
transition: border-color 0.15s ease, color 0.15s ease;
}
.bb-post-footer .bb-post-action {
width: 28px;
height: 28px;
font-size: 0.9rem;
}
.bb-post-action:hover {
color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.bb-post-action--round {
border-radius: 50%;
}
.bb-post-content {
position: relative;
}
.bb-post-body {
white-space: pre-wrap;
color: var(--bb-ink);
line-height: 1.6;
flex: 1 1 auto;
}
.bb-post-footer {
display: flex;
justify-content: flex-end;
margin-top: auto;
}
.bb-thread-reply {
@@ -1194,12 +1568,12 @@ a {
}
.bb-board-last-link {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600;
}
.bb-board-last-link:hover {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
@@ -1361,6 +1735,45 @@ a {
color: var(--bb-ink);
}
.bb-profile-thanks {
display: grid;
gap: 1.5rem;
}
.bb-profile-thanks-list {
list-style: none;
padding: 0;
margin: 0.6rem 0 0;
display: grid;
gap: 0.6rem;
}
.bb-profile-thanks-item {
display: grid;
gap: 0.2rem;
color: var(--bb-ink-muted);
}
.bb-profile-thanks-item a {
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600;
}
.bb-profile-thanks-item a:hover {
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
.bb-profile-thanks-meta {
color: var(--bb-ink-muted);
font-weight: 500;
}
.bb-profile-thanks-date {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-portal-list {
list-style: none;
padding: 0;
@@ -1474,12 +1887,12 @@ a {
}
.bb-portal-topic-author {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600;
}
.bb-portal-topic-author:hover {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
@@ -1516,12 +1929,12 @@ a {
}
.bb-portal-last-user {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
font-weight: 600;
}
.bb-portal-last-user:hover {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
@@ -1572,11 +1985,11 @@ a {
}
.bb-portal-user-name-link {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
}
.bb-portal-user-name-link:hover {
color: var(--bb-accent, #f29b3f);
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
text-decoration: none;
}
@@ -1709,6 +2122,14 @@ a {
color: #0e121b;
}
.bb-accent-button:disabled,
.bb-accent-button.disabled {
background: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
color: #0e121b;
opacity: 0.6;
}
.modal-content .modal-header {
background: #0f1218;
color: #e6e8eb;
@@ -1755,7 +2176,57 @@ a {
}
.bb-acp {
max-width: 1880px;
max-width: 100%;
}
.bb-acp-sidebar {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bb-acp-sidebar-section {
background: rgba(16, 20, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.75rem;
}
.bb-acp-sidebar-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bb-ink-muted);
margin-bottom: 0.5rem;
}
.bb-acp-sidebar .list-group-item {
background: transparent;
color: var(--bb-ink);
border: 0;
padding: 0.35rem 0.25rem;
}
.bb-acp-sidebar .list-group-item.is-active,
.bb-acp-sidebar .list-group-item:hover {
color: var(--bb-accent, #f29b3f);
}
.bb-acp-panel {
background: rgba(18, 23, 33, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.bb-acp-panel-header {
padding: 0.9rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-acp-panel-body {
padding: 1rem;
}
.bb-icon {
@@ -1825,11 +2296,118 @@ a {
gap: 0.6rem;
}
.bb-attachment-type-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bb-attachment-type-title {
font-weight: 600;
color: var(--bb-ink);
}
.bb-attachment-type-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-type-rules {
margin-top: 0.2rem;
color: var(--bb-ink-muted);
font-size: 0.8rem;
}
.bb-attachment-admin {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bb-attachment-extension-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.2fr) minmax(160px, 1fr) auto;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
}
.bb-attachment-extension-table {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
background: rgba(18, 23, 33, 0.8);
}
.bb-attachment-extension-table table {
margin-bottom: 0;
color: var(--bb-ink);
}
.bb-attachment-extension-table thead th {
font-size: 0.8rem;
color: var(--bb-ink-muted);
background: rgba(15, 19, 27, 0.7);
border-bottom: 0;
padding: 0.55rem 0.8rem;
}
.bb-attachment-extension-table tbody td {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 0.55rem 0.8rem;
vertical-align: middle;
}
.bb-attachment-extension-name {
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-rank-main img {
height: 22px;
width: auto;
}
.bb-rank-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
flex: 0 0 auto;
}
.bb-rank-badge {
display: inline-flex;
align-items: center;
@@ -1863,6 +2441,146 @@ a {
.bb-rank-actions {
display: inline-flex;
gap: 0.5rem;
align-items: center;
}
.bb-multiselect {
position: relative;
}
.bb-multiselect__control {
width: 100%;
min-height: 42px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(14, 18, 27, 0.6);
color: var(--bb-ink);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem 0.6rem;
text-align: left;
}
.bb-multiselect__control:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bb-multiselect__value {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.bb-multiselect__placeholder {
color: var(--bb-ink-muted);
}
.bb-multiselect__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 0.85rem;
}
.bb-multiselect__chip-color {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}
.bb-multiselect__chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
font-size: 0.8rem;
line-height: 1;
}
.bb-multiselect__caret {
color: var(--bb-ink-muted);
}
.bb-multiselect__menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 20;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 16, 24, 0.95);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.bb-multiselect__search {
padding: 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-multiselect__search input {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(16, 20, 28, 0.8);
color: var(--bb-ink);
padding: 0.35rem 0.5rem;
}
.bb-multiselect__options {
max-height: 220px;
overflow-y: auto;
}
.bb-multiselect__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
background: transparent;
border: none;
color: var(--bb-ink);
text-align: left;
}
.bb-multiselect__option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bb-btn-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bb-multiselect__option:hover,
.bb-multiselect__option.is-selected {
background: rgba(255, 255, 255, 0.08);
}
.bb-multiselect__option-main {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.bb-multiselect__empty {
padding: 0.75rem;
color: var(--bb-ink-muted);
}
.bb-user-search {
@@ -1939,6 +2657,7 @@ a {
}
}
.bb-collapse-toggle {
width: 20px;
height: 20px;

File diff suppressed because it is too large Load Diff

View File

@@ -96,7 +96,48 @@ export default function BoardIndex() {
nodes.forEach((node) => sortNodes(node.children))
}
const aggregateNodes = (node) => {
if (!node.children?.length) {
return {
threads: node.threads_count ?? 0,
views: node.views_count ?? 0,
posts: node.posts_count ?? 0,
last: node.last_post_at ? { at: node.last_post_at, node } : null,
}
}
let threads = node.threads_count ?? 0
let views = node.views_count ?? 0
let posts = node.posts_count ?? 0
let last = node.last_post_at ? { at: node.last_post_at, node } : null
node.children.forEach((child) => {
const agg = aggregateNodes(child)
threads += agg.threads
views += agg.views
posts += agg.posts
if (agg.last && (!last || agg.last.at > last.at)) {
last = agg.last
}
})
node.threads_count = threads
node.views_count = views
node.posts_count = posts
if (last) {
const source = last.node
node.last_post_at = source.last_post_at
node.last_post_user_id = source.last_post_user_id
node.last_post_user_name = source.last_post_user_name
node.last_post_user_rank_color = source.last_post_user_rank_color
node.last_post_user_group_color = source.last_post_user_group_color
}
return { threads, views, posts, last }
}
sortNodes(roots)
roots.forEach((root) => aggregateNodes(root))
return roots
}, [forums])
@@ -138,7 +179,18 @@ export default function BoardIndex() {
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
<Link
to={`/profile/${node.last_post_user_id}`}
className="bb-board-last-link"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (

View File

@@ -1,7 +1,15 @@
import { useEffect, useState } from 'react'
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
import {
createThread,
getForum,
listAllForums,
listThreadsByForum,
uploadAttachment,
listAttachmentExtensionsPublic,
previewBbcode,
} from '../api/client'
import PortalTopicRow from '../components/PortalTopicRow'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
@@ -18,6 +26,24 @@ export default function ForumView() {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
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 renderChildRows = (nodes) =>
@@ -44,7 +70,18 @@ export default function ForumView() {
<span className="bb-board-last-by">
{t('thread.by')}{' '}
{node.last_post_user_id ? (
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
<Link
to={`/profile/${node.last_post_user_id}`}
className="bb-board-last-link"
style={
node.last_post_user_rank_color || node.last_post_user_group_color
? {
'--bb-user-link-color':
node.last_post_user_rank_color || node.last_post_user_group_color,
}
: undefined
}
>
{node.last_post_user_name || t('thread.anonymous')}
</Link>
) : (
@@ -60,6 +97,77 @@ export default function ForumView() {
</div>
))
const getParentId = (node) => {
if (!node.parent) return null
if (typeof node.parent === 'string') {
return node.parent.split('/').pop()
}
return node.parent.id ?? null
}
const buildForumTree = (allForums) => {
const map = new Map()
const roots = []
allForums.forEach((item) => {
map.set(String(item.id), { ...item, children: [] })
})
allForums.forEach((item) => {
const parentId = getParentId(item)
const node = map.get(String(item.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const aggregateNodes = (node) => {
if (!node.children?.length) {
return {
threads: node.threads_count ?? 0,
views: node.views_count ?? 0,
posts: node.posts_count ?? 0,
last: node.last_post_at ? { at: node.last_post_at, node } : null,
}
}
let threads = node.threads_count ?? 0
let views = node.views_count ?? 0
let posts = node.posts_count ?? 0
let last = node.last_post_at ? { at: node.last_post_at, node } : null
node.children.forEach((child) => {
const agg = aggregateNodes(child)
threads += agg.threads
views += agg.views
posts += agg.posts
if (agg.last && (!last || agg.last.at > last.at)) {
last = agg.last
}
})
node.threads_count = threads
node.views_count = views
node.posts_count = posts
if (last) {
const source = last.node
node.last_post_at = source.last_post_at
node.last_post_user_id = source.last_post_user_id
node.last_post_user_name = source.last_post_user_name
node.last_post_user_rank_color = source.last_post_user_rank_color
node.last_post_user_group_color = source.last_post_user_group_color
}
return { threads, views, posts, last }
}
roots.forEach((root) => aggregateNodes(root))
return map
}
useEffect(() => {
let active = true
@@ -70,9 +178,11 @@ export default function ForumView() {
const forumData = await getForum(id)
if (!active) return
setForum(forumData)
const childData = await listForumsByParent(id)
const allForums = await listAllForums()
if (!active) return
setChildren(childData)
const treeMap = buildForumTree(allForums)
const currentNode = treeMap.get(String(forumData.id))
setChildren(currentNode?.children ?? [])
if (forumData.type === 'forum') {
const threadData = await listThreadsByForum(id)
if (!active) return
@@ -94,24 +204,388 @@ export default function ForumView() {
}
}, [id])
useEffect(() => {
listAttachmentExtensionsPublic()
.then((data) => {
if (Array.isArray(data)) {
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
}
})
.catch(() => {})
}, [])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
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('')
setBody('')
setThreadFiles([])
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setUploading(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 (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
@@ -174,7 +648,7 @@ export default function ForumView() {
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<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('thread.replies')}</span>
<span>{t('thread.views')}</span>
@@ -200,13 +674,19 @@ export default function ForumView() {
</>
)}
{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.Title>{t('forum.start_thread')}</Modal.Title>
</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>}
<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.Label>{t('form.title')}</Form.Label>
<Form.Control
@@ -218,30 +698,113 @@ export default function ForumView() {
required
/>
</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.Control
as="textarea"
rows={6}
className="flex-grow-1"
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleThreadPaste}
disabled={!token || saving}
required
/>
</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)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
<div className="d-flex gap-2">
<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>
</div>
</Modal.Footer>
</Form>
</Modal.Body>
</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>
)
}

View File

@@ -91,7 +91,48 @@ export default function Home() {
nodes.forEach((node) => sortNodes(node.children))
}
const aggregateNodes = (node) => {
if (!node.children?.length) {
return {
threads: node.threads_count ?? 0,
views: node.views_count ?? 0,
posts: node.posts_count ?? 0,
last: node.last_post_at ? { at: node.last_post_at, node } : null,
}
}
let threads = node.threads_count ?? 0
let views = node.views_count ?? 0
let posts = node.posts_count ?? 0
let last = node.last_post_at ? { at: node.last_post_at, node } : null
node.children.forEach((child) => {
const agg = aggregateNodes(child)
threads += agg.threads
views += agg.views
posts += agg.posts
if (agg.last && (!last || agg.last.at > last.at)) {
last = agg.last
}
})
node.threads_count = threads
node.views_count = views
node.posts_count = posts
if (last) {
const source = last.node
node.last_post_at = source.last_post_at
node.last_post_user_id = source.last_post_user_id
node.last_post_user_name = source.last_post_user_name
node.last_post_user_rank_color = source.last_post_user_rank_color
node.last_post_user_group_color = source.last_post_user_group_color
}
return { threads, views, posts, last }
}
sortNodes(roots)
roots.forEach((root) => aggregateNodes(root))
return roots
}, [forums])
@@ -194,7 +235,7 @@ export default function Home() {
)}
{!loadingThreads && recentThreads.length > 0 && (
<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('thread.replies')}</span>
<span>{t('thread.views')}</span>
@@ -226,7 +267,15 @@ export default function Home() {
</Link>
<div className="bb-portal-user-name">
{profile?.id ? (
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
<Link
to={`/profile/${profile.id}`}
className="bb-portal-user-name-link"
style={
profile?.rank?.color || profile?.group_color
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
: undefined
}
>
{profile?.name || email || 'User'}
</Link>
) : (

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client'
import { Link } from 'react-router-dom'
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
export default function Profile() {
const { id } = useParams()
@@ -10,23 +11,32 @@ export default function Profile() {
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [thanksGiven, setThanksGiven] = useState([])
const [thanksReceived, setThanksReceived] = useState([])
const [loadingThanks, setLoadingThanks] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
setError('')
getUserProfile(id)
.then((data) => {
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
.then(([profileData, givenData, receivedData]) => {
if (!active) return
setProfile(data)
setProfile(profileData)
setThanksGiven(Array.isArray(givenData) ? givenData : [])
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
})
.catch((err) => {
if (!active) return
setError(err.message)
setThanksGiven([])
setThanksReceived([])
})
.finally(() => {
if (active) setLoading(false)
if (!active) return
setLoading(false)
setLoadingThanks(false)
})
return () => {
@@ -34,6 +44,19 @@ export default function Profile() {
}
}, [id])
const formatDateTime = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear())
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
}
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
@@ -59,6 +82,96 @@ export default function Profile() {
</div>
</div>
)}
{profile && (
<div className="bb-profile-thanks mt-4">
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_given')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksGiven.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksGiven.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksGiven.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.post_author_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')}{' '}
<Link
to={`/profile/${item.post_author_id}`}
style={
item.post_author_rank_color || item.post_author_group_color
? {
'--bb-user-link-color':
item.post_author_rank_color || item.post_author_group_color,
}
: undefined
}
>
{item.post_author_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_for')} {item.post_author_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="bb-profile-section">
<div className="bb-portal-card-title">{t('profile.thanks_received')}</div>
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
{!loadingThanks && thanksReceived.length === 0 && (
<p className="bb-muted">{t('profile.thanks_empty')}</p>
)}
{!loadingThanks && thanksReceived.length > 0 && (
<ul className="bb-profile-thanks-list">
{thanksReceived.map((item) => (
<li key={item.id} className="bb-profile-thanks-item">
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
{item.thread_title || t('thread.label')}
</Link>
{item.thanker_id ? (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')}{' '}
<Link
to={`/profile/${item.thanker_id}`}
style={
item.thanker_rank_color || item.thanker_group_color
? {
'--bb-user-link-color':
item.thanker_rank_color || item.thanker_group_color,
}
: undefined
}
>
{item.thanker_name || t('thread.anonymous')}
</Link>
</span>
) : (
<span className="bb-profile-thanks-meta">
{t('profile.thanks_by')} {item.thanker_name || t('thread.anonymous')}
</span>
)}
<span className="bb-profile-thanks-date">
{formatDateTime(item.thanked_at)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</Container>
)

View File

@@ -1,19 +1,48 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread } from '../api/client'
import { Button, Container, Form, Modal } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import {
createPost,
getThread,
listPostsByThread,
updateThreadSolved,
uploadAttachment,
listAttachmentExtensionsPublic,
previewBbcode,
} from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ThreadView() {
const { id } = useParams()
const { token } = useAuth()
const { token, userId, isAdmin } = useAuth()
const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [body, setBody] = useState('')
const [saving, setSaving] = 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 [lightboxImage, setLightboxImage] = useState('')
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
disableBbcode: false,
disableSmilies: false,
disableAutoUrls: false,
attachSignature: true,
notifyReplies: false,
})
const { t } = useTranslation()
const replyRef = useRef(null)
@@ -28,6 +57,16 @@ export default function ThreadView() {
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
listAttachmentExtensionsPublic()
.then((data) => {
if (Array.isArray(data)) {
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
}
})
.catch(() => {})
}, [])
useEffect(() => {
if (!thread && posts.length === 0) return
const hash = window.location.hash
@@ -45,18 +84,26 @@ export default function ThreadView() {
setSaving(true)
setError('')
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('')
setReplyFiles([])
const updated = await listPostsByThread(id)
setPosts(updated)
} catch (err) {
setError(err.message)
} finally {
setReplyUploading(false)
setSaving(false)
}
}
const replyCount = posts.length
// const replyCount = posts.length
const formatDate = (value) => {
if (!value) return '—'
const date = new Date(value)
@@ -66,21 +113,404 @@ export default function ThreadView() {
const year = String(date.getFullYear())
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) => (
attachment.is_image ? (
<button
key={attachment.id}
type="button"
className="bb-attachment-item border-0 text-start"
onClick={() => setLightboxImage(attachment.download_url)}
>
<img
src={attachment.thumbnail_url || attachment.download_url}
alt={attachment.original_name}
className="img-fluid rounded"
style={{ width: 72, height: 72, objectFit: 'cover' }}
/>
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</button>
) : (
<a
key={attachment.id}
href={attachment.download_url}
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(() => {
if (!thread) return posts
const rootPost = {
id: `thread-${thread.id}`,
body: thread.body,
body_html: thread.body_html,
created_at: thread.created_at,
user_id: thread.user_id,
user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url,
user_posts_count: thread.user_posts_count,
user_created_at: thread.user_created_at,
user_location: thread.user_location,
user_thanks_given_count: thread.user_thanks_given_count,
user_thanks_received_count: thread.user_thanks_received_count,
user_rank_name: thread.user_rank_name,
user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text,
user_rank_badge_url: thread.user_rank_badge_url,
attachments: thread.attachments || [],
isRoot: true,
}
return [rootPost, ...posts]
@@ -90,6 +520,47 @@ export default function ThreadView() {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const canToggleSolved = token
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const canUploadThread = token
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const handleToggleSolved = async () => {
if (!thread || solving) return
setSolving(true)
setError('')
try {
const updated = await updateThreadSolved(thread.id, !thread.solved)
setThread(updated)
} catch (err) {
setError(err.message)
} finally {
setSolving(false)
}
}
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
return (
@@ -99,7 +570,12 @@ export default function ThreadView() {
{thread && (
<div className="bb-thread">
<div className="bb-thread-titlebar">
<h1 className="bb-thread-title">{thread.title}</h1>
<h1 className="bb-thread-title">
{thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</h1>
<div className="bb-thread-meta">
<span>{t('thread.by')}</span>
<span className="bb-thread-author">
@@ -117,6 +593,20 @@ export default function ThreadView() {
<i className="bi bi-reply-fill" aria-hidden="true" />
<span>{t('form.post_reply')}</span>
</Button>
{canToggleSolved && (
<Button
variant="outline-secondary"
className="bb-thread-solved-toggle"
onClick={handleToggleSolved}
disabled={solving}
>
<i
className={`bi ${thread.solved ? 'bi-check-circle-fill' : 'bi-check-circle'}`}
aria-hidden="true"
/>
<span>{thread.solved ? t('thread.mark_unsolved') : t('thread.mark_solved')}</span>
</Button>
)}
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
@@ -134,12 +624,24 @@ export default function ThreadView() {
</div>
</div>
<div className="bb-posts">
{allPosts.map((post, index) => {
const authorName = post.author?.username
|| post.user_name
|| post.author_name
|| t('thread.anonymous')
const currentUserId = Number(userId)
const postUserId = Number(post.user_id)
const canThank = Number.isFinite(currentUserId)
&& Number.isFinite(postUserId)
&& currentUserId !== postUserId
console.log('canThank check', {
postId: post.id,
postUserId,
currentUserId,
canThank,
})
const topicLabel = thread?.title
? post.isRoot
? thread.title
@@ -190,12 +692,16 @@ export default function ThreadView() {
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span>
<span className="bb-post-author-value">7</span>
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_given_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks received:</span>
<span className="bb-post-author-value">5</span>
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
<span className="bb-post-author-value">
{post.user_thanks_received_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
@@ -234,12 +740,31 @@ export default function ThreadView() {
<button type="button" className="bb-post-action" aria-label="Quote post">
<i className="bi bi-quote" aria-hidden="true" />
</button>
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
<i className="bi bi-house-door" aria-hidden="true" />
{canThank && (
<button type="button" className="bb-post-action" aria-label={t('thread.thanks')}>
<i className="bi bi-hand-thumbs-up" aria-hidden="true" />
</button>
)}
</div>
</div>
<div
className="bb-post-body"
onClick={(event) => {
if (event.target?.tagName === 'IMG') {
event.preventDefault()
setLightboxImage(event.target.src)
}
}}
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
/>
{renderAttachments(post.attachments)}
<div className="bb-post-footer">
<div className="bb-post-actions">
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
<i className="bi bi-chevron-up" aria-hidden="true" />
</a>
</div>
</div>
<div className="bb-post-body">{post.body}</div>
</div>
</article>
)
@@ -260,19 +785,113 @@ export default function ThreadView() {
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleReplyPaste}
disabled={!token || saving}
required
/>
</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">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
<div className="d-flex gap-2 justify-content-end">
<Button
type="button"
variant="outline-secondary"
onClick={handlePreview}
disabled={!token || saving || replyUploading || previewLoading}
>
{t('form.preview')}
</Button>
<Button
type="submit"
className="bb-accent-button"
disabled={!token || saving || replyUploading}
>
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</div>
</Form>
</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>
<Modal
show={Boolean(lightboxImage)}
onHide={() => setLightboxImage('')}
centered
size="lg"
>
<Modal.Body className="text-center">
{lightboxImage && (
<img src={lightboxImage} alt="" className="img-fluid rounded" />
)}
</Modal.Body>
</Modal>
</Container>
)
}

View File

@@ -45,10 +45,22 @@
"acp.add_category": "Kategorie hinzufügen",
"acp.add_forum": "Forum hinzufügen",
"acp.ranks": "Ränge",
"acp.groups": "Gruppen",
"acp.attachments": "Anh\u00e4nge",
"acp.forums_parent_root": "Wurzel (kein Parent)",
"acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ",
"acp.general": "Allgemein",
"acp.quick_access": "Schnellzugriff",
"acp.board_configuration": "Board-Konfiguration",
"acp.client_communication": "Client-Kommunikation",
"acp.server_configuration": "Server-Konfiguration",
"acp.authentication": "Authentifizierung",
"acp.email_settings": "E-Mail-Einstellungen",
"acp.security_settings": "Sicherheitseinstellungen",
"acp.search_settings": "Sucheinstellungen",
"acp.welcome_title": "Willkommen bei speedBB",
"acp.general_settings": "Allgemeine Einstellungen",
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
"acp.loading": "Laden...",
"acp.new_category": "Neue Kategorie",
@@ -76,6 +88,9 @@
"form.password": "Passwort",
"form.post_reply": "Antwort posten",
"form.posting": "Wird gesendet...",
"form.preview": "Vorschau",
"form.upload": "Hochladen",
"form.uploading": "Wird hochgeladen...",
"form.registering": "Registrierung läuft...",
"form.reply_placeholder": "Schreibe deine Antwort.",
"form.sign_in": "Anmelden",
@@ -108,11 +123,16 @@
"rank.name": "Rangname",
"rank.name_placeholder": "z. B. Operator",
"rank.create": "Rang erstellen",
"rank.create_title": "Rang erstellen",
"rank.edit_title": "Rang bearbeiten",
"rank.badge_type": "Badge-Typ",
"rank.badge_text": "Text-Badge",
"rank.badge_image": "Bild-Badge",
"rank.badge_none": "Kein Badge",
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
"rank.color": "Rangfarbe",
"rank.color_placeholder": "z. B. #f29b3f",
"rank.color_default": "Standardfarbe verwenden",
"rank.badge_text_required": "Badge-Text ist erforderlich.",
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
"rank.delete_confirm": "Diesen Rang löschen?",
@@ -122,6 +142,19 @@
"user.impersonate": "Imitieren",
"user.edit": "Bearbeiten",
"user.delete": "Löschen",
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
"group.create": "Gruppe erstellen",
"group.create_title": "Gruppe erstellen",
"group.edit_title": "Gruppe bearbeiten",
"group.name": "Gruppenname",
"group.name_placeholder": "z. B. Gründer",
"group.color": "Gruppenfarbe",
"group.color_placeholder": "z. B. #f29b3f",
"group.delete_confirm": "Diese Gruppe löschen?",
"group.empty": "Noch keine Gruppen vorhanden.",
"group.edit": "Bearbeiten",
"group.delete": "Löschen",
"group.core_locked": "Kern-Gruppen können nicht geändert werden.",
"table.rows_per_page": "Zeilen pro Seite:",
"table.range_separator": "von",
"home.browse": "Foren durchsuchen",
@@ -175,6 +208,11 @@
"profile.title": "Profil",
"profile.loading": "Profil wird geladen...",
"profile.registered": "Registriert:",
"profile.thanks_given": "Hat sich bedankt",
"profile.thanks_received": "Dank erhalten",
"profile.thanks_empty": "Noch keine Danksagungen.",
"profile.thanks_by": "Dank von",
"profile.thanks_for": "Dank für",
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
"ucp.profile": "Profil",
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
@@ -188,6 +226,69 @@
"ucp.accent_override": "Akzentfarbe überschreiben",
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
"ucp.custom_color": "Eigene Farbe",
"attachment.groups_title": "Anhanggruppen",
"attachment.settings_title": "Anhang-Einstellungen",
"attachment.display_images_inline": "Bilder inline anzeigen",
"attachment.create_thumbnails": "Vorschaubilder erstellen",
"attachment.thumbnail_max_width": "Maximale Vorschaubreite (px)",
"attachment.thumbnail_max_height": "Maximale Vorschaubildhöhe (px)",
"attachment.thumbnail_quality": "Vorschaubild-Qualität (JPEG/WebP)",
"attachment.group_create": "Neue Anhanggruppe",
"attachment.group_create_title": "Anhanggruppe erstellen",
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
"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.back_to_category": "Zurück zum Forum",
"thread.category": "Forum:",
@@ -197,9 +298,17 @@
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
"thread.posts": "Beiträge",
"thread.location": "Wohnort",
"thread.thanks_given": "Hat sich bedankt",
"thread.thanks_received": "Dank erhalten",
"thread.thanks": "Danke",
"thread.reply_prefix": "Aw:",
"thread.registered": "Registriert",
"thread.replies": "Antworten",
"thread.solved": "Gel\u00f6st",
"thread.mark_solved": "Als gel\u00f6st markieren",
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
"thread.attachments": "Anh\u00e4nge",
"thread.attach_files": "Dateien anh\u00e4ngen",
"thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag",
"thread.by": "von",

View File

@@ -45,10 +45,22 @@
"acp.add_category": "Add category",
"acp.add_forum": "Add forum",
"acp.ranks": "Ranks",
"acp.groups": "Groups",
"acp.attachments": "Attachments",
"acp.forums_parent_root": "Root (no parent)",
"acp.forums_tree": "Forum tree",
"acp.forums_type": "Type",
"acp.general": "General",
"acp.quick_access": "Quick access",
"acp.board_configuration": "Board configuration",
"acp.client_communication": "Client communication",
"acp.server_configuration": "Server configuration",
"acp.authentication": "Authentication",
"acp.email_settings": "Email settings",
"acp.security_settings": "Security settings",
"acp.search_settings": "Search settings",
"acp.welcome_title": "Welcome to speedBB",
"acp.general_settings": "General settings",
"acp.general_hint": "Global settings and board configuration will appear here.",
"acp.loading": "Loading...",
"acp.new_category": "New category",
@@ -76,6 +88,9 @@
"form.password": "Password",
"form.post_reply": "Post reply",
"form.posting": "Posting...",
"form.preview": "Preview",
"form.upload": "Upload",
"form.uploading": "Uploading...",
"form.registering": "Registering...",
"form.reply_placeholder": "Share your reply.",
"form.sign_in": "Sign in",
@@ -108,11 +123,16 @@
"rank.name": "Rank name",
"rank.name_placeholder": "e.g. Operator",
"rank.create": "Create rank",
"rank.create_title": "Create rank",
"rank.edit_title": "Edit rank",
"rank.badge_type": "Badge type",
"rank.badge_text": "Text badge",
"rank.badge_image": "Image badge",
"rank.badge_none": "No badge",
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
"rank.color": "Rank color",
"rank.color_placeholder": "e.g. #f29b3f",
"rank.color_default": "Use default color",
"rank.badge_text_required": "Badge text is required.",
"rank.badge_image_required": "Badge image is required.",
"rank.delete_confirm": "Delete this rank?",
@@ -122,6 +142,19 @@
"user.impersonate": "Impersonate",
"user.edit": "Edit",
"user.delete": "Delete",
"user.founder_locked": "Only founders can edit or assign the Founder role.",
"group.create": "Create group",
"group.create_title": "Create group",
"group.edit_title": "Edit group",
"group.name": "Group name",
"group.name_placeholder": "e.g. Founder",
"group.color": "Group color",
"group.color_placeholder": "e.g. #f29b3f",
"group.delete_confirm": "Delete this group?",
"group.empty": "No groups created yet.",
"group.edit": "Edit",
"group.delete": "Delete",
"group.core_locked": "Core groups cannot be changed.",
"table.rows_per_page": "Rows per page:",
"table.range_separator": "of",
"home.browse": "Browse forums",
@@ -175,6 +208,11 @@
"profile.title": "Profile",
"profile.loading": "Loading profile...",
"profile.registered": "Registered:",
"profile.thanks_given": "Thanks given",
"profile.thanks_received": "Thanks received",
"profile.thanks_empty": "No thanks yet.",
"profile.thanks_by": "Thanks by",
"profile.thanks_for": "Thanks for",
"ucp.intro": "Manage your basic preferences for the forum.",
"ucp.profile": "Profile",
"ucp.profile_hint": "Update the avatar shown next to your posts.",
@@ -188,6 +226,69 @@
"ucp.accent_override": "Accent color override",
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
"ucp.custom_color": "Custom color",
"attachment.groups_title": "Attachment groups",
"attachment.settings_title": "Attachment settings",
"attachment.display_images_inline": "Display images inline",
"attachment.create_thumbnails": "Create thumbnails",
"attachment.thumbnail_max_width": "Maximum thumbnail width (px)",
"attachment.thumbnail_max_height": "Maximum thumbnail height (px)",
"attachment.thumbnail_quality": "Thumbnail quality (JPEG/WebP)",
"attachment.group_create": "New attachment group",
"attachment.group_create_title": "Create attachment group",
"attachment.group_edit_title": "Edit attachment group",
"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.back_to_category": "Back to forum",
"thread.category": "Forum:",
@@ -197,9 +298,17 @@
"thread.login_hint": "Log in to reply to this thread.",
"thread.posts": "Posts",
"thread.location": "Location",
"thread.thanks_given": "Thanks given",
"thread.thanks_received": "Thanks received",
"thread.thanks": "Thank",
"thread.reply_prefix": "Re:",
"thread.registered": "Registered",
"thread.replies": "Replies",
"thread.solved": "Solved",
"thread.mark_solved": "Mark solved",
"thread.mark_unsolved": "Mark unsolved",
"thread.attachments": "Attachments",
"thread.attach_files": "Attach files",
"thread.views": "Views",
"thread.last_post": "Last post",
"thread.by": "by",

View File

@@ -8,6 +8,7 @@
@vite(['resources/js/main.jsx'])
</head>
<body>
<div id="top"></div>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>speedBB Installed</title>
<style>
:root {
color-scheme: dark;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #0b0f17;
color: #e6e8eb;
}
body {
margin: 0;
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
.card {
width: 100%;
max-width: 560px;
background: rgba(18, 23, 33, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
text-align: center;
}
h1 {
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: #9aa4b2;
}
a {
display: inline-flex;
padding: 0.7rem 1.5rem;
border-radius: 12px;
background: #ff8a3d;
color: #1a1a1a;
font-weight: 700;
text-decoration: none;
}
</style>
</head>
<body>
<div class="card">
<h1>Installation complete</h1>
<p>Your forum is ready. You can now log in with the founder account.</p>
<a href="/">Go to forum</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>speedBB Installer</title>
<style>
:root {
color-scheme: dark;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #0b0f17;
color: #e6e8eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2rem 1rem 4rem;
display: flex;
justify-content: center;
}
.card {
width: 100%;
max-width: 720px;
background: rgba(18, 23, 33, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.8rem;
}
p {
margin: 0 0 1.5rem;
color: #9aa4b2;
}
.section {
margin-bottom: 2rem;
}
label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
}
input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 16, 24, 0.9);
color: #e6e8eb;
min-width: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.grid--wide {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.error {
background: rgba(220, 80, 80, 0.15);
border: 1px solid rgba(220, 80, 80, 0.4);
color: #ffb4b4;
padding: 0.75rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
button {
padding: 0.7rem 1.5rem;
border-radius: 12px;
border: none;
background: #ff8a3d;
color: #1a1a1a;
font-weight: 700;
cursor: pointer;
}
</style>
</head>
<body>
<div class="card">
<h1>speedBB Installer</h1>
<p>Provide database details and create the first founder/admin account.</p>
@if (!empty($error))
<div class="error">{{ $error }}</div>
@endif
@if ($errors->any())
<div class="error">
<ul>
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="/install">
@csrf
<div class="section">
<h2>Application</h2>
<label for="app_url">App URL</label>
<input id="app_url" name="app_url" type="url" required value="{{ old('app_url', $old['app_url'] ?? $appUrl) }}" />
</div>
<div class="section">
<h2>Database (MySQL/MariaDB)</h2>
<div class="grid">
<div>
<label for="db_host">Host</label>
<input id="db_host" name="db_host" required value="{{ old('db_host', $old['db_host'] ?? '127.0.0.1') }}" />
</div>
<div>
<label for="db_port">Port</label>
<input id="db_port" name="db_port" type="number" value="{{ old('db_port', $old['db_port'] ?? 3306) }}" />
</div>
<div>
<label for="db_database">Database</label>
<input id="db_database" name="db_database" required value="{{ old('db_database', $old['db_database'] ?? '') }}" />
</div>
<div>
<label for="db_username">Username</label>
<input id="db_username" name="db_username" required value="{{ old('db_username', $old['db_username'] ?? '') }}" />
</div>
<div>
<label for="db_password">Password</label>
<input id="db_password" name="db_password" type="password" value="{{ old('db_password', $old['db_password'] ?? '') }}" />
</div>
</div>
</div>
<div class="section">
<h2>Founder Account</h2>
<div class="grid grid--wide">
<div>
<label for="admin_name">Username</label>
<input id="admin_name" name="admin_name" required value="{{ old('admin_name', $old['admin_name'] ?? '') }}" />
</div>
<div>
<label for="admin_email">Email</label>
<input id="admin_email" name="admin_email" type="email" required value="{{ old('admin_email', $old['admin_email'] ?? '') }}" />
</div>
<div>
<label for="admin_password">Password</label>
<input id="admin_password" name="admin_password" type="password" required />
</div>
</div>
</div>
<div class="actions">
<button type="submit">Install speedBB</button>
</div>
</form>
</div>
</body>
</html>

View File

@@ -1,10 +1,15 @@
<?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\ForumController;
use App\Http\Controllers\I18nController;
use App\Http\Controllers\PortalController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\PostThankController;
use App\Http\Controllers\PreviewController;
use App\Http\Controllers\SettingController;
use App\Http\Controllers\StatsController;
use App\Http\Controllers\ThreadController;
@@ -13,6 +18,7 @@ use App\Http\Controllers\UserSettingController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController;
use App\Http\Controllers\RankController;
use App\Http\Controllers\RoleController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
@@ -43,12 +49,37 @@ Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum'
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
Route::get('/roles', [RoleController::class, 'index'])->middleware('auth:sanctum');
Route::post('/roles', [RoleController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/roles/{role}', [RoleController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/user/{user}/thanks/given', [PostThankController::class, 'given'])->middleware('auth:sanctum');
Route::get('/user/{user}/thanks/received', [PostThankController::class, 'received'])->middleware('auth:sanctum');
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->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::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::get('/attachments/{attachment}/thumbnail', [AttachmentController::class, 'thumbnail']);
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/forums', [ForumController::class, 'index']);
Route::get('/forums/{forum}', [ForumController::class, 'show']);
Route::post('/forums', [ForumController::class, 'store'])->middleware('auth:sanctum');
@@ -59,8 +90,12 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum');

View File

@@ -1,9 +1,37 @@
<?php
use App\Http\Controllers\InstallerController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
Route::view('/', 'app');
Route::view('/login', 'app')->name('login');
Route::view('/reset-password', 'app')->name('password.reset');
Route::get('/install', [InstallerController::class, 'show']);
Route::post('/install', [InstallerController::class, 'store'])
->withoutMiddleware([VerifyCsrfToken::class]);
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
Route::get('/', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
});
Route::get('/login', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->name('login');
Route::get('/reset-password', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->name('password.reset');
Route::get('/{any}', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
}
return view('app');
})->where('any', '^(?!api).*$');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SpeedBB</title>
<?php echo app('Illuminate\Foundation\Vite')->reactRefresh(); ?>
<?php echo app('Illuminate\Foundation\Vite')(['resources/js/main.jsx']); ?>
</head>
<body>
<div id="root"></div>
</body>
</html>
<?php /**PATH /home/users/tracer/www/forum.lab.24unix.net/speedBB/resources/views/app.blade.php ENDPATH**/ ?>