32 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
Micha
24c16ed0dd Unify portal thread rows and add summary API 2026-01-16 02:44:04 +01:00
Micha
f9de433545 fixing thsu frontend views 2026-01-16 01:43:07 +01:00
Micha
fd29b928d8 Add ranks and ACP user enhancements 2026-01-14 00:15:56 +01:00
Micha
98094459e3 Tighten ACP forum actions and avatar handling 2026-01-13 00:07:25 +01:00
Micha
3bb2946656 Add avatars, profiles, and auth flows 2026-01-12 23:40:11 +01:00
Micha
bbbf8eb6c1 Show post authors and action buttons 2026-01-11 01:54:45 +01:00
Micha
c8d2bd508e Restyle thread view like phpBB 2026-01-11 01:25:16 +01:00
eef3262a53 added PSR-12 rules 2026-01-11 00:35:42 +01:00
fe1015bff1 ACP: general settings, branding, and favicon uploads 2026-01-02 20:01:22 +01:00
Micha
8604cdf95d UI: portal/header refinements, board index UX, and user settings 2026-01-01 19:54:02 +01:00
f83748cc76 Update ACP UI copy, accents, and defaults 2025-12-29 19:32:26 +01:00
116 changed files with 15704 additions and 2306 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

4
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.log
.DS_Store
._*
.env
.env.backup
.env.production
@@ -20,8 +21,11 @@
/public/build
/public/hot
/public/storage
/storage/app
/storage/framework
/storage/*.key
/storage/pail
/storage/framework/views/*.php
/vendor
Homestead.json
Homestead.yaml

View File

@@ -1,5 +1,62 @@
# Changelog
## 2026-01-12
- Switched main SPA layouts to fluid containers to reduce wasted space.
- Added username-or-email login with case-insensitive unique usernames.
- Added SPA-friendly verification and password reset/update endpoints.
- Added user avatars (upload + display) and a basic profile page/API.
- Seeded a Micha test user with verified email.
- Added rank management with badge text/image options and ACP UI controls.
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
- Added ACP users search and improved sorting indicators.
- Added thread sidebar fields for posts count, registration date, and topic header.
- Linked header logo to the portal and fixed ACP breadcrumbs.
- Added profile location field with UCP editing and post sidebar display.
- Added per-thread replies and views counts, including view tracking.
- Added per-forum topics/views counts plus last-post details in board listings.
- Added portal summary API to load forums, stats, and recent posts in one request.
- Unified portal and forum thread list row styling with shared component.
## 2026-01-11
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
- Added phpBB-style post action buttons and post author info for replies.
## 2026-01-02
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
- Added admin-only upload endpoints and ACP UI for logos and favicons.
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
## 2025-12-30
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
- Added thread seeding for forum test data.
- Enforced category-only roots for forums (API validation, UI, and database constraint).
- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu.
- Replaced the home page with a portal-style layout and latest posts list.
- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles.
- Persisted board index collapse state per user via user_settings (DB + API + client cache).
- Added category view rendering for subcategories in ForumView.
- Updated thread list UI (icons, spacing) and New topic button styling in ForumView.
- Added ACP per-category quick-create buttons for child categories and forums.
- Removed the legacy navbar and cleaned up related styling.
## 2025-12-29
- Merged the React app into the Laravel codebase under `resources/js`.
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
- Serve the SPA shell via Blade with Vite-managed React assets.
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.
- Replaced the default Laravel README with a forum placeholder.
- Updated ACP forum tools with accent-tinted buttons and larger action spacing.
- Defaulted the ACP forum tree to collapsed on page load.
- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation.
## 2025-12-26
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
- Added Laravel i18n endpoint backed by JSON translation files.
- Updated frontend auth/register flow to work with Laravel tokens.
## 2025-12-24
- Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes.
- Updated API Platform resources, filters, migrations, and JSON format support.
@@ -22,16 +79,3 @@
- Improved ACP drag-and-drop hover reordering and visual drop target feedback.
- Hardened ACP access so admin tools require authentication.
- Updated the home page to render the forum tree with ACP-style rows and icons.
## 2025-12-26
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
- Added Laravel i18n endpoint backed by JSON translation files.
- Updated frontend auth/register flow to work with Laravel tokens.
## 2025-12-29
- Merged the React app into the Laravel codebase under `resources/js`.
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
- Serve the SPA shell via Blade with Vite-managed React assets.
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.

View File

@@ -1,59 +1,7 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# SpeedBB Forum
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
Placeholder README for the forum application.
## About Laravel
## Status
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
Work in progress.

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

@@ -3,6 +3,7 @@
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -19,22 +20,31 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
Validator::make($input, [
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make(data: $input, rules: [
'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique(table: User::class, column: 'name_canonical'),
],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
Rule::unique(table: User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
return User::create(attributes: [
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'password' => Hash::make(value: $input['password']),
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@@ -9,7 +10,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View File

@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique('users', 'name_canonical')->ignore($user->id),
],
'email' => [
'required',
@@ -29,12 +38,12 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
])->save();
}
@@ -49,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
$user->forceFill([
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();

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

@@ -3,28 +3,37 @@
namespace App\Http\Controllers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\PasswordValidationRules;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
use PasswordValidationRules;
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.',
@@ -33,39 +42,140 @@ class AuthController extends Controller
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
$request->merge(input: [
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
]);
$request->validate(rules: [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $request->input('email'))->first();
$login = trim(string: (string) $request->input(key: 'login'));
$loginNormalized = Str::lower(value: $login);
$userQuery = User::query();
if (!$user || !Hash::check($request->input('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
} else {
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
}
$user = $userQuery->first();
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: $id);
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
abort(code: 403);
}
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified(user: $user));
}
return redirect(to: '/login');
}
public function forgotPassword(Request $request): JsonResponse
{
$request->validate(rules: [
'email' => ['required', 'email'],
]);
$status = Password::sendResetLink(
$request->only(keys: 'email')
);
if ($status !== Password::RESET_LINK_SENT) {
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(data: ['message' => __(key: $status)]);
}
public function resetPassword(Request $request): JsonResponse
{
$request->validate(rules: [
'token' => ['required'],
'email' => ['required', 'email'],
'password' => $this->passwordRules(),
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill(attributes: [
'password' => Hash::make(value: $password),
'remember_token' => Str::random(length: 60),
])->save();
event(new PasswordReset(user: $user));
}
);
if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(data: ['message' => __(key: $status)]);
}
public function updatePassword(Request $request): JsonResponse
{
$request->validate(rules: [
'current_password' => ['required'],
'password' => $this->passwordRules(),
]);
$user = $request->user();
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
throw ValidationException::withMessages(messages: [
'current_password' => ['Invalid current password.'],
]);
}
$user->forceFill(attributes: [
'password' => Hash::make(value: $request->input(key: 'password')),
'remember_token' => Str::random(length: 60),
])->save();
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

@@ -3,6 +3,8 @@
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;
@@ -11,39 +13,51 @@ class ForumController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Forum::query();
$query = Forum::query()
->withoutTrashed()
->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')
->get()
->map(fn (Forum $forum) => $this->serializeForum($forum));
->orderBy(column: 'position')
->orderBy(column: 'name')
->get();
return response()->json($forums);
$forumIds = $forums->pluck('id')->all();
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
$payload = $forums->map(
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
);
return response()->json($payload);
}
public function show(Forum $forum): JsonResponse
{
return response()->json($this->serializeForum($forum));
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost));
}
public function store(Request $request): JsonResponse
@@ -57,6 +71,10 @@ class ForumController extends Controller
$parentId = $this->parseIriId($data['parent'] ?? null);
if ($data['type'] === 'forum' && !$parentId) {
return response()->json(['message' => 'Forums must belong to a category.'], 422);
}
if ($parentId) {
$parent = Forum::findOrFail($parentId);
if ($parent->type !== 'category') {
@@ -64,7 +82,12 @@ class ForumController extends Controller
}
}
if ($parentId === null) {
Forum::whereNull('parent_id')->increment('position');
$position = 0;
} else {
$position = Forum::where('parent_id', $parentId)->max('position');
}
$forum = Forum::create([
'name' => $data['name'],
@@ -74,7 +97,11 @@ class ForumController extends Controller
'position' => ($position ?? 0) + 1,
]);
return response()->json($this->serializeForum($forum), 201);
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost), 201);
}
public function update(Request $request, Forum $forum): JsonResponse
@@ -87,6 +114,12 @@ class ForumController extends Controller
]);
$parentId = $this->parseIriId($data['parent'] ?? null);
$nextType = $data['type'] ?? $forum->type;
$nextParentId = array_key_exists('parent', $data) ? $parentId : $forum->parent_id;
if ($nextType === 'forum' && !$nextParentId) {
return response()->json(['message' => 'Forums must belong to a category.'], 422);
}
if (array_key_exists('parent', $data)) {
if ($parentId) {
@@ -112,11 +145,17 @@ class ForumController extends Controller
$forum->save();
return response()->json($this->serializeForum($forum));
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost));
}
public function destroy(Forum $forum): JsonResponse
public function destroy(Request $request, Forum $forum): JsonResponse
{
$forum->deleted_by = $request->user()?->id;
$forum->save();
$forum->delete();
return response()->json(null, 204);
@@ -163,7 +202,7 @@ class ForumController extends Controller
return null;
}
private function serializeForum(Forum $forum): array
private function serializeForum(Forum $forum, ?Post $lastPost): array
{
return [
'id' => $forum->id,
@@ -172,8 +211,76 @@ class ForumController extends Controller
'type' => $forum->type,
'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) + ($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(),
];
}
private function loadLastPostsByForum(array $forumIds): array
{
if (empty($forumIds)) {
return [];
}
$posts = Post::query()
->select('posts.*', 'threads.forum_id as forum_id')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->whereIn('threads.forum_id', $forumIds)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
foreach ($posts as $post) {
$forumId = (int) ($post->forum_id ?? 0);
if ($forumId && !array_key_exists($forumId, $byForum)) {
$byForum[$forumId] = $post;
}
}
return $byForum;
}
private function loadLastPostForForum(int $forumId): ?Post
{
return Post::query()
->select('posts.*')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->where('threads.forum_id', $forumId)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->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

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PortalController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$forums = Forum::query()
->withoutTrashed()
->withCount(['threads', 'posts'])
->withSum('threads', 'views_count')
->orderBy('position')
->orderBy('name')
->get();
$forumIds = $forums->pluck('id')->all();
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
$forumPayload = $forums->map(
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
);
$threads = Thread::query()
->withoutTrashed()
->withCount('posts')
->with([
'user' => fn ($query) => $query->withCount(['posts', 'threads'])->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])
->latest('created_at')
->limit(12)
->get()
->map(fn (Thread $thread) => $this->serializeThread($thread));
$stats = [
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count()
+ Thread::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
];
$user = auth('sanctum')->user();
return response()->json([
'forums' => $forumPayload,
'threads' => $threads,
'stats' => $stats,
'profile' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatar_path ? Storage::url($user->avatar_path) : null,
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
] : null,
]);
}
private function serializeForum(Forum $forum, ?Post $lastPost): array
{
return [
'id' => $forum->id,
'name' => $forum->name,
'description' => $forum->description,
'type' => $forum->type,
'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) + ($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(),
];
}
private function serializeThread(Thread $thread): array
{
return [
'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) + 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 ?? 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,
'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(),
];
}
private function loadLastPostsByForum(array $forumIds): array
{
if (empty($forumIds)) {
return [];
}
$posts = Post::query()
->select('posts.*', 'threads.forum_id as forum_id')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->whereIn('threads.forum_id', $forumIds)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
foreach ($posts as $post) {
$forumId = (int) ($post->forum_id ?? 0);
if ($forumId && !array_key_exists($forumId, $byForum)) {
$byForum[$forumId] = $post;
}
}
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,16 +2,25 @@
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;
class PostController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Post::query();
$query = Post::query()->withoutTrashed()->with([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
$threadParam = $request->query('thread');
if (is_string($threadParam)) {
@@ -45,11 +54,21 @@ class PostController extends Controller
'body' => $data['body'],
]);
$post->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
return response()->json($this->serializePost($post), 201);
}
public function destroy(Post $post): JsonResponse
public function destroy(Request $request, Post $post): JsonResponse
{
$post->deleted_by = $request->user()?->id;
$post->save();
$post->delete();
return response()->json(null, 204);
@@ -74,13 +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 ?? 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

@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Models\Rank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class RankController extends Controller
{
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
return response()->json(data: ['message' => 'Forbidden'], status: 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
$ranks = Rank::query()
->orderBy('name')
->get()
->map(fn (Rank $rank) => [
'id' => $rank->id,
'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,
]);
return response()->json($ranks);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
'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([
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => null,
], 201);
}
public function update(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
'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 !== 'image' && $rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
$rank->badge_image_path = null;
}
$rank->update([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
'color' => $color,
]);
return response()->json([
'id' => $rank->id,
'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,
]);
}
public function destroy(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$rank->delete();
return response()->json(null, 204);
}
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
]);
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$path = $data['file']->store('rank-badges', 'public');
$rank->badge_type = 'image';
$rank->badge_text = null;
$rank->badge_image_path = $path;
$rank->save();
return response()->json([
'id' => $rank->id,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => Storage::url($path),
]);
}
}

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

@@ -24,4 +24,60 @@ class SettingController extends Controller
return response()->json($settings);
}
public function store(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'key' => ['required', 'string', 'max:191'],
'value' => ['nullable', 'string'],
]);
$value = $data['value'] ?? '';
$setting = Setting::updateOrCreate(
['key' => $data['key']],
['value' => $value]
);
return response()->json([
'id' => $setting->id,
'key' => $setting->key,
'value' => $setting->value,
]);
}
public function bulkStore(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'settings' => ['required', 'array'],
'settings.*.key' => ['required', 'string', 'max:191'],
'settings.*.value' => ['nullable', 'string'],
]);
$updated = [];
foreach ($data['settings'] as $entry) {
$setting = Setting::updateOrCreate(
['key' => $entry['key']],
['value' => $entry['value'] ?? '']
);
$updated[] = [
'id' => $setting->id,
'key' => $setting->key,
'value' => $setting->value,
];
}
return response()->json($updated);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class StatsController extends Controller
{
public function __invoke(): JsonResponse
{
return response()->json([
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count()
+ Thread::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
]);
}
}

View File

@@ -4,14 +4,28 @@ 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
{
public function index(Request $request): JsonResponse
{
$query = Thread::query();
$query = Thread::query()
->withoutTrashed()
->withCount('posts')
->withMax('posts', 'created_at')
->with([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
]);
$forumParam = $request->query('forum');
if (is_string($forumParam)) {
@@ -22,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));
@@ -31,6 +45,17 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse
{
$thread->increment('views_count');
$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));
}
@@ -56,16 +81,60 @@ class ThreadController extends Controller
'body' => $data['body'],
]);
$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), 201);
}
public function destroy(Thread $thread): JsonResponse
public function destroy(Request $request, Thread $thread): JsonResponse
{
$thread->deleted_by = $request->user()?->id;
$thread->save();
$thread->delete();
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) {
@@ -85,14 +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) + 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 ?? 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

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UploadController extends Controller
{
public function storeAvatar(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = $request->validate([
'file' => [
'required',
'image',
'mimes:jpg,jpeg,png,gif,webp',
'max:2048',
'dimensions:max_width=150,max_height=150',
],
]);
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$path = $data['file']->store('avatars', 'public');
$user->avatar_path = $path;
$user->save();
return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}
public function storeLogo(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'],
]);
$path = $data['file']->store('logos', 'public');
return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}
public function storeFavicon(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
]);
$path = $data['file']->store('favicons', 'public');
return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}
}

View File

@@ -2,24 +2,276 @@
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
public function index(): JsonResponse
{
$users = User::query()
->with('roles')
->with(['roles', 'rank'])
->orderBy('id')
->get()
->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'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(),
]);
return response()->json($users);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'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(),
]);
}
public function profile(User $user): JsonResponse
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'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(),
]);
}
public function updateMe(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$data = $request->validate([
'location' => ['nullable', 'string', 'max:255'],
]);
$location = isset($data['location']) ? trim($data['location']) : null;
if ($location === '') {
$location = null;
}
$user->forceFill([
'location' => $location,
])->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'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(),
]);
}
public function updateRank(Request $request, User $user): JsonResponse
{
$actor = $request->user();
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'],
]);
$user->rank_id = $data['rank_id'] ?? null;
$user->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
]);
}
public function update(Request $request, User $user): JsonResponse
{
$actor = $request->user();
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'],
'email' => [
'required',
'string',
'email',
'max:255',
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)
->where('name_canonical', $nameCanonical)
->exists();
if ($nameConflict) {
return response()->json(['message' => 'Name already exists.'], 422);
}
if ($data['email'] !== $user->email) {
$user->email_verified_at = null;
}
$user->forceFill([
'name' => $data['name'],
'name_canonical' => $nameCanonical,
'email' => $data['email'],
'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([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'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(),
]);
}
private function resolveAvatarUrl(User $user): ?string
{
if (!$user->avatar_path) {
return null;
}
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();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\UserSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserSettingController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
$query = UserSetting::query()->where('user_id', $user->id);
if ($request->filled('key')) {
$query->where('key', $request->query('key'));
}
$settings = $query->get()->map(fn (UserSetting $setting) => [
'id' => $setting->id,
'key' => $setting->key,
'value' => $setting->value,
]);
return response()->json($settings);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'key' => ['required', 'string', 'max:191'],
'value' => ['nullable', 'array'],
]);
$setting = UserSetting::updateOrCreate(
['user_id' => $request->user()->id, 'key' => $data['key']],
['value' => $data['value'] ?? []]
);
return response()->json([
'id' => $setting->id,
'key' => $setting->key,
'value' => $setting->value,
]);
}
}

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

@@ -4,7 +4,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
@@ -35,6 +39,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class Forum extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'description',
@@ -57,4 +63,20 @@ class Forum extends Model
{
return $this->hasMany(Thread::class);
}
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, Thread::class, 'forum_id', 'thread_id');
}
public function latestThread(): HasOne
{
return $this->hasOne(Thread::class)->latestOfMany();
}
public function latestPost(): HasOneThrough
{
return $this->hasOneThrough(Post::class, Thread::class, 'forum_id', 'thread_id')
->latestOfMany();
}
}

View File

@@ -3,7 +3,9 @@
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;
/**
* @property int $id
@@ -12,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
* @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()
@@ -27,6 +30,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class Post extends Model
{
use SoftDeletes;
protected $fillable = [
'thread_id',
'user_id',
@@ -42,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);
}
}

29
app/Models/Rank.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $users
*/
class Rank extends Model
{
protected $fillable = [
'name',
'badge_type',
'badge_text',
'badge_image_path',
'color',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

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

View File

@@ -4,7 +4,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
@@ -12,9 +14,11 @@ use Illuminate\Database\Eloquent\Model;
* @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
@@ -32,11 +36,18 @@ use Illuminate\Database\Eloquent\Model;
*/
class Thread extends Model
{
use SoftDeletes;
protected $fillable = [
'forum_id',
'user_id',
'title',
'body',
'solved',
];
protected $casts = [
'solved' => 'bool',
];
public function forum(): BelongsTo
@@ -53,4 +64,14 @@ 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

@@ -2,32 +2,40 @@
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
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;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\PersonalAccessToken;
/**
* @property int $id
* @property string $name
* @property string $email
* @property \Illuminate\Support\Carbon|null $email_verified_at
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property string|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Role> $roles
* @property-read Collection<int, Role> $roles
* @property-read int|null $roles_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
* @property-read Collection<int, PersonalAccessToken> $tokens
* @property-read int|null $tokens_count
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
@@ -46,8 +54,10 @@ use Laravel\Sanctum\HasApiTokens;
*/
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/** @use HasFactory<UserFactory> */
use HasApiTokens;
use HasFactory;
use Notifiable;
/**
* The attributes that are mass assignable.
@@ -56,6 +66,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected $fillable = [
'name',
'name_canonical',
'avatar_path',
'location',
'rank_id',
'email',
'password',
];
@@ -87,4 +101,29 @@ class User extends Authenticatable implements MustVerifyEmail
{
return $this->belongsToMany(Role::class);
}
public function posts(): HasMany
{
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

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserSetting extends Model
{
protected $fillable = [
'user_id',
'key',
'value',
];
protected $casts = [
'value' => 'array',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Model::preventLazyLoading(true);
}
}

View File

@@ -4,7 +4,7 @@
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
@@ -13,6 +13,6 @@ require __DIR__.'/vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
$status = $app->handleCommand(input: new ArgvInput);
exit($status);

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

@@ -6,11 +6,13 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.4",
"laravel/fortify": "*",
"laravel/framework": "^12.0",
"laravel/sanctum": "*",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"s9e/text-formatter": "^2.5",
"ext-pdo": "*"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.6",
@@ -20,7 +22,8 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"phpunit/phpunit": "^11.5.3",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
"psr-4": {
@@ -35,6 +38,7 @@
}
},
"scripts": {
"phpcs": "phpcs",
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",

603
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,9 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ?
\Pdo\Mysql::ATTR_SSL_CA :
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -79,7 +81,9 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ?
\Pdo\Mysql::ATTR_SSL_CA :
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

@@ -9,7 +9,7 @@ return [
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
| based disks, are available to your application for file storage.
|
*/

View File

@@ -46,7 +46,10 @@ return [
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
'local_domain' => env(
'MAIL_EHLO_DOMAIN',
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
),
],
'ses' => [

View File

@@ -23,8 +23,11 @@ class UserFactory extends Factory
*/
public function definition(): array
{
$name = fake()->unique()->userName();
return [
'name' => fake()->name(),
'name' => $name,
'name_canonical' => Str::lower($name),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),

View File

@@ -36,12 +36,6 @@ return new class extends Migration
'created_at' => now(),
'updated_at' => now(),
],
[
'key' => 'accent_color',
'value' => '#f29b3f',
'created_at' => now(),
'updated_at' => now(),
],
]);
}

View File

@@ -0,0 +1,50 @@
<?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('forums', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_forums_deleted_meta');
});
Schema::table('threads', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_threads_deleted_meta');
});
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_posts_deleted_meta');
});
}
public function down(): void
{
Schema::table('forums', function (Blueprint $table) {
$table->dropIndex('idx_forums_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
Schema::table('threads', function (Blueprint $table) {
$table->dropIndex('idx_threads_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
Schema::table('posts', function (Blueprint $table) {
$table->dropIndex('idx_posts_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,52 @@
<?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
{
public function up(): void
{
DB::table('forums')
->where('type', 'forum')
->whereNull('parent_id')
->update(['type' => 'category']);
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'mysql') {
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
DB::statement(<<<'SQL'
CREATE TRIGGER trg_forums_parent_insert
BEFORE INSERT ON forums
FOR EACH ROW
BEGIN
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
END IF;
END
SQL);
DB::statement(<<<'SQL'
CREATE TRIGGER trg_forums_parent_update
BEFORE UPDATE ON forums
FOR EACH ROW
BEGIN
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
END IF;
END
SQL);
}
}
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'mysql') {
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
}
}
};

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('user_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('key');
$table->json('value');
$table->timestamps();
$table->unique(['user_id', 'key'], 'uniq_user_settings_user_key');
});
}
public function down(): void
{
Schema::dropIfExists('user_settings');
}
};

View File

@@ -0,0 +1,38 @@
<?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('users', function (Blueprint $table) {
$table->string('name_canonical')->nullable()->after('name');
});
DB::table('users')
->whereNull('name_canonical')
->update(['name_canonical' => DB::raw('lower(name)')]);
Schema::table('users', function (Blueprint $table) {
$table->unique('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['name_canonical']);
$table->dropColumn('name_canonical');
});
}
};

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('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('avatar_path');
});
}
};

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::create('ranks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ranks');
}
};

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('users', function (Blueprint $table) {
$table->foreignId('rank_id')->nullable()->after('avatar_path')->constrained('ranks')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('rank_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?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('badge_type')->default('text')->after('name');
$table->string('badge_text')->nullable()->after('badge_type');
$table->string('badge_image_path')->nullable()->after('badge_text');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn(['badge_type', 'badge_text', 'badge_image_path']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?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('users', function (Blueprint $table) {
$table->string('location')->nullable()->after('avatar_path');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('location');
});
}
};

View File

@@ -0,0 +1,22 @@
<?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('threads', function (Blueprint $table) {
$table->unsignedInteger('views_count')->default(0)->after('body');
});
}
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('views_count');
});
}
};

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

View File

@@ -16,8 +16,10 @@ class DatabaseSeeder extends Seeder
{
$this->call([
RoleSeeder::class,
RankSeeder::class,
UserSeeder::class,
ForumSeeder::class,
ThreadSeeder::class,
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\Rank;
use App\Models\User;
use Illuminate\Database\Seeder;
class RankSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$member = Rank::firstOrCreate(
['name' => 'Member'],
['badge_type' => 'text', 'badge_text' => 'Member']
);
$operator = Rank::firstOrCreate(
['name' => 'Operator'],
['badge_type' => 'text', 'badge_text' => 'Operator']
);
$moderator = Rank::firstOrCreate(
['name' => 'Moderator'],
['badge_type' => 'text', 'badge_text' => 'Moderator']
);
User::query()
->whereNull('rank_id')
->update(['rank_id' => $member->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_ADMIN'))
->update(['rank_id' => $operator->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_MODERATOR'))
->update(['rank_id' => $moderator->id]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Database\Seeders;
use App\Models\Forum;
use App\Models\Thread;
use App\Models\User;
use Faker\Factory as FakerFactory;
use Illuminate\Database\Seeder;
class ThreadSeeder extends Seeder
{
public function run(): void
{
$faker = FakerFactory::create();
$users = User::all();
$forums = Forum::where('type', 'forum')->get();
if ($users->isEmpty() || $forums->isEmpty()) {
return;
}
foreach ($forums as $forum) {
$threadCount = $faker->numberBetween(2, 8);
for ($i = 0; $i < $threadCount; $i += 1) {
$author = $users->random();
Thread::create([
'forum_id' => $forum->id,
'user_id' => $author->id,
'title' => ucfirst($faker->words($faker->numberBetween(3, 6), true)),
'body' => $faker->paragraphs($faker->numberBetween(2, 4), true),
]);
}
}
}
}

View File

@@ -4,6 +4,8 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\Rank;
use App\Models\Role;
use App\Models\User;
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
*/
public function run(): void
{
$adminRole = Role::where('name', 'ROLE_ADMIN')->first();
$userRole = Role::where('name', 'ROLE_USER')->first();
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
$operatorRank = Rank::where('name', 'Operator')->first();
$memberRank = Rank::where('name', 'Member')->first();
$admin = User::firstOrCreate(
['email' => 'tracer@24unix.net'],
[
$admin = User::updateOrCreate(
attributes: ['email' => 'tracer@24unix.net'],
values : [
'name' => 'tracer',
'password' => Hash::make('password'),
'name_canonical' => Str::lower('tracer'),
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(),
]
);
$micha = User::updateOrCreate(
attributes: ['email' => 'micha@24unix.net'],
values : [
'name' => 'Micha',
'name_canonical' => Str::lower('Micha'),
'rank_id' => $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(),
]
);
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
$admin->roles()->syncWithoutDetaching([$userRole->id]);
}
if ($userRole) {
$micha->roles()->syncWithoutDetaching([$userRole->id]);
}
$users = User::factory()->count(100)->create([
'email_verified_at' => now(),
]);

337
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"react-bootstrap": "^2.10.10",
"react-data-table-component": "^7.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-i18next": "^16.5.0",
"react-router-dom": "^7.11.0"
},
@@ -1657,6 +1658,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
@@ -2062,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",
@@ -2463,6 +2461,18 @@
"node": ">=16.0.0"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2866,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",
@@ -2988,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",
@@ -3680,6 +3404,23 @@
"react": "^19.2.3"
}
},
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-i18next": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.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": {
@@ -17,6 +17,7 @@
"react-bootstrap": "^2.10.10",
"react-data-table-component": "^7.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-i18next": "^16.5.0",
"react-router-dom": "^7.11.0"
},

18
phpcs.xml Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<ruleset name="speedBB">
<description>Project coding standard based on PSR-12.</description>
<rule ref="PSR12"/>
<file>app</file>
<file>config</file>
<file>database</file>
<file>routes</file>
<file>tests</file>
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>public/build/*</exclude-pattern>
<exclude-pattern>storage/*</exclude-pattern>
<exclude-pattern>vendor/*</exclude-pattern>
</ruleset>

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

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Container, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home'
import ForumView from './pages/ForumView'
@@ -8,96 +8,262 @@ import ThreadView from './pages/ThreadView'
import Login from './pages/Login'
import Register from './pages/Register'
import Acp from './pages/Acp'
import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
import Profile from './pages/Profile'
import { useTranslation } from 'react-i18next'
import { fetchSetting, fetchVersion } from './api/client'
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function Navigation({ theme, onThemeChange }) {
const { token, email, logout, isAdmin } = useAuth()
const { t, i18n } = useTranslation()
function PortalHeader({
userMenu,
isAuthenticated,
forumName,
logoUrl,
showHeaderName,
canAccessAcp,
canAccessMcp,
}) {
const { t } = useTranslation()
const location = useLocation()
const [crumbs, setCrumbs] = useState([])
const handleLanguageChange = (locale) => {
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
useEffect(() => {
let active = true
const parseForumId = (parent) => {
if (!parent) return null
if (typeof parent === 'string') {
const parts = parent.split('/')
return parts[parts.length - 1] || null
}
if (typeof parent === 'object' && parent.id) {
return parent.id
}
return null
}
const handleThemeChange = (value) => {
onThemeChange(value)
localStorage.setItem('speedbb_theme', value)
const buildForumChain = async (forum) => {
const chain = []
let cursor = forum
while (cursor) {
chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` })
const parentId = parseForumId(cursor.parent)
if (!parentId) break
cursor = await getForum(parentId)
}
return chain
}
const buildCrumbs = async () => {
const base = [
{ label: t('portal.portal'), to: '/' },
{ label: t('portal.board_index'), to: '/forums' },
]
if (location.pathname === '/') {
setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
return
}
if (location.pathname === '/forums') {
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
return
}
if (location.pathname.startsWith('/forum/')) {
const forumId = location.pathname.split('/')[2]
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
setCrumbs([...base, ...chain.map((crumb, idx) => ({
...crumb,
current: idx === chain.length - 1,
}))])
return
}
}
if (location.pathname.startsWith('/thread/')) {
const threadId = location.pathname.split('/')[2]
if (threadId) {
const thread = await getThread(threadId)
const forumId = thread?.forum?.split('/').pop()
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
const chainWithCurrent = chain.map((crumb, index) => ({
...crumb,
current: index === chain.length - 1,
}))
setCrumbs([...base, ...chainWithCurrent])
return
}
}
}
if (location.pathname.startsWith('/acp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.link_acp'), to: '/acp', current: true },
])
return
}
if (location.pathname.startsWith('/ucp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.user_control_panel'), to: '/ucp', current: true },
])
return
}
if (location.pathname.startsWith('/profile/')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.user_profile'), to: location.pathname, current: true },
])
return
}
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
}
buildCrumbs()
return () => {
active = false
}
}, [location.pathname, t])
return (
<Navbar expand="lg" className="bb-nav">
<Container>
<Navbar.Brand as={Link} to="/" className="fw-semibold">
{t('app.brand')}
</Navbar.Brand>
{isAdmin && (
<Nav className="me-auto">
<Nav.Link as={Link} to="/acp">
{t('nav.acp')}
</Nav.Link>
</Nav>
<Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
{logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)}
<Navbar.Toggle aria-controls="bb-nav" />
<Navbar.Collapse id="bb-nav">
<Nav className="ms-auto align-items-lg-center gap-2">
{!token && (
{(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
)}
</Link>
</div>
<div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
<span className="bb-portal-search-icon">
<i className="bi bi-search" aria-hidden="true" />
</span>
</div>
</div>
<div className="bb-portal-bars">
<div className="bb-portal-bar bb-portal-bar--top">
<div className="bb-portal-bar-left">
<span className="bb-portal-bar-title">
<i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')}
</span>
<div className="bb-portal-bar-links">
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
{isAuthenticated && canAccessAcp && (
<>
<Nav.Link as={Link} to="/login">
{t('nav.login')}
</Nav.Link>
<Nav.Link as={Link} to="/register">
{t('nav.register')}
</Nav.Link>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
</>
)}
{token && (
{isAuthenticated && canAccessMcp && (
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
)}
</div>
</div>
<div
className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`}
>
{isAuthenticated ? (
<>
<span className="bb-chip">{email}</span>
<Nav.Link onClick={logout}>{t('nav.logout')}</Nav.Link>
<span>
<i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')}
</span>
<span>
<i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')}
</span>
{userMenu}
</>
) : (
<>
<Link to="/register" className="bb-portal-user-link">
<i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')}
</Link>
<Link to="/login" className="bb-portal-user-link">
<i className="bi bi-power" aria-hidden="true" /> {t('nav.login')}
</Link>
</>
)}
<NavDropdown title={t('nav.language')} align="end">
<NavDropdown.Item onClick={() => handleLanguageChange('en')}>
English
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleLanguageChange('de')}>
Deutsch
</NavDropdown.Item>
</NavDropdown>
<NavDropdown title={t('nav.theme')} align="end">
<NavDropdown.Item onClick={() => handleThemeChange('auto')} active={theme === 'auto'}>
{t('nav.theme_auto')}
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleThemeChange('light')} active={theme === 'light'}>
{t('nav.theme_light')}
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleThemeChange('dark')} active={theme === 'dark'}>
{t('nav.theme_dark')}
</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
</div>
</div>
<div className="bb-portal-bar bb-portal-bar--bottom">
<div className="bb-portal-breadcrumb">
{crumbs.map((crumb, index) => (
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>}
{crumb.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}
</Link>
) : (
<Link to={crumb.to} className="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}
</Link>
)}
</span>
))}
</div>
</div>
</div>
</Container>
</Navbar>
)
}
function AppShell() {
const { t } = useTranslation()
const { isAdmin } = useAuth()
const [loadMs, setLoadMs] = useState(null)
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
useEffect(() => {
const [entry] = performance.getEntriesByType('navigation')
if (entry?.duration) {
setLoadMs(Math.round(entry.duration))
return
}
setLoadMs(Math.round(performance.now()))
}, [])
const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light')
const [accentOverride, setAccentOverride] = useState(
() => localStorage.getItem('speedbb_accent') || ''
)
const [settings, setSettings] = useState({
forumName: '',
defaultTheme: 'auto',
accentDark: '',
accentLight: '',
logoDark: '',
logoLight: '',
showHeaderName: true,
faviconIco: '',
favicon16: '',
favicon32: '',
favicon48: '',
favicon64: '',
favicon128: '',
favicon256: '',
})
useEffect(() => {
fetchVersion()
@@ -106,24 +272,78 @@ function AppShell() {
}, [])
useEffect(() => {
fetchSetting('accent_color')
.then((setting) => {
if (setting?.value) {
document.documentElement.style.setProperty('--bb-accent', setting.value)
let active = true
const loadSettings = async () => {
try {
const allSettings = await fetchSettings()
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
if (!active) return
const next = {
forumName: settingsMap.get('forum_name') || '',
defaultTheme: settingsMap.get('default_theme') || 'auto',
accentDark: settingsMap.get('accent_color_dark') || '',
accentLight: settingsMap.get('accent_color_light') || '',
logoDark: settingsMap.get('logo_dark') || '',
logoLight: settingsMap.get('logo_light') || '',
showHeaderName: settingsMap.get('show_header_name') !== 'false',
faviconIco: settingsMap.get('favicon_ico') || '',
favicon16: settingsMap.get('favicon_16') || '',
favicon32: settingsMap.get('favicon_32') || '',
favicon48: settingsMap.get('favicon_48') || '',
favicon64: settingsMap.get('favicon_64') || '',
favicon128: settingsMap.get('favicon_128') || '',
favicon256: settingsMap.get('favicon_256') || '',
}
setSettings(next)
} catch {
// keep defaults
}
}
loadSettings()
return () => {
active = false
}
})
.catch(() => {})
}, [])
useEffect(() => {
const stored = token ? localStorage.getItem('speedbb_theme') : null
const nextTheme = stored || settings.defaultTheme || 'auto'
setTheme(nextTheme)
}, [token, settings.defaultTheme])
useEffect(() => {
const handleSettingsUpdate = (event) => {
const next = event.detail
if (!next) return
setSettings((prev) => ({ ...prev, ...next }))
}
window.addEventListener('speedbb-settings-updated', handleSettingsUpdate)
return () => {
window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate)
}
}, [])
useEffect(() => {
if (accentOverride) {
localStorage.setItem('speedbb_accent', accentOverride)
} else {
localStorage.removeItem('speedbb_accent')
}
}, [accentOverride])
useEffect(() => {
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
const applyTheme = (mode) => {
if (mode === 'auto') {
root.setAttribute('data-bs-theme', media.matches ? 'dark' : 'light')
const next = media.matches ? 'dark' : 'light'
root.setAttribute('data-bs-theme', next)
setResolvedTheme(next)
} else {
root.setAttribute('data-bs-theme', mode)
setResolvedTheme(mode)
}
}
@@ -142,16 +362,124 @@ function AppShell() {
}
}, [theme])
useEffect(() => {
const accent =
accentOverride ||
(resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) ||
settings.accentDark ||
settings.accentLight
if (accent) {
document.documentElement.style.setProperty('--bb-accent', accent)
}
}, [accentOverride, resolvedTheme, settings])
useEffect(() => {
if (settings.forumName) {
document.title = settings.forumName
}
}, [settings.forumName])
useEffect(() => {
const upsertIcon = (id, rel, href, sizes, type) => {
if (!href) {
const existing = document.getElementById(id)
if (existing) {
existing.remove()
}
return
}
let link = document.getElementById(id)
if (!link) {
link = document.createElement('link')
link.id = id
document.head.appendChild(link)
}
link.setAttribute('rel', rel)
link.setAttribute('href', href)
if (sizes) {
link.setAttribute('sizes', sizes)
} else {
link.removeAttribute('sizes')
}
if (type) {
link.setAttribute('type', type)
} else {
link.removeAttribute('type')
}
}
upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon')
upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png')
upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png')
upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png')
upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png')
upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png')
upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png')
}, [
settings.faviconIco,
settings.favicon16,
settings.favicon32,
settings.favicon48,
settings.favicon64,
settings.favicon128,
settings.favicon256,
])
return (
<div className="bb-shell">
<Navigation theme={theme} onThemeChange={setTheme} />
<div className="bb-shell" id="top">
<PortalHeader
isAuthenticated={!!token}
forumName={settings.forumName}
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
showHeaderName={settings.showHeaderName}
userMenu={
token ? (
<NavDropdown
title={
<span className="bb-user-menu">
<span className="bb-user-menu__name">{email}</span>
<i className="bi bi-caret-down-fill" aria-hidden="true" />
</span>
}
align="end"
className="bb-user-menu__dropdown"
>
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={logout}>
<i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')}
</NavDropdown.Item>
</NavDropdown>
) : null
}
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"
element={
<Ucp
theme={theme}
setTheme={setTheme}
accentOverride={accentOverride}
setAccentOverride={setAccentOverride}
/>
}
/>
</Routes>
<footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3">
@@ -165,12 +493,6 @@ function AppShell() {
<span className="bb-version-label">)</span>
</span>
)}
{loadMs !== null && (
<span className="bb-load-time">
<span className="bb-load-label">Page load time</span>{' '}
<span className="bb-load-value">{loadMs}ms</span>
</span>
)}
</div>
</footer>
</div>

View File

@@ -48,10 +48,10 @@ export async function getCollection(path) {
return data?.['hydra:member'] || []
}
export async function login(email, password) {
export async function login(login, password) {
return apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ login, password }),
})
}
@@ -70,14 +70,125 @@ export async function listAllForums() {
return getCollection('/forums?pagination=false')
}
export async function getCurrentUser() {
return apiFetch('/user/me')
}
export async function updateCurrentUser(payload) {
return apiFetch('/user/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function uploadAvatar(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/user/avatar', {
method: 'POST',
body,
})
}
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')
}
export async function fetchStats() {
return apiFetch('/stats')
}
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) {
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
// TODO: Prefer fetchSettings() when multiple settings are needed.
const cacheBust = Date.now()
const data = await apiFetch(
`/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`,
{ cache: 'no-store' }
)
if (Array.isArray(data)) {
return data[0] || null
}
return data?.['hydra:member']?.[0] || null
}
export async function fetchSettings() {
const cacheBust = Date.now()
const data = await apiFetch(`/settings?pagination=false&_=${cacheBust}`, { cache: 'no-store' })
if (Array.isArray(data)) {
return data
}
return data?.['hydra:member'] || []
}
export async function saveSetting(key, value) {
return apiFetch('/settings', {
method: 'POST',
body: JSON.stringify({ key, value }),
})
}
export async function saveSettings(settings) {
return apiFetch('/settings/bulk', {
method: 'POST',
body: JSON.stringify({ settings }),
})
}
export async function uploadLogo(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/uploads/logo', {
method: 'POST',
body,
})
}
export async function uploadFavicon(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/uploads/favicon', {
method: 'POST',
body,
})
}
export async function fetchUserSetting(key) {
const data = await getCollection(`/user-settings?key=${encodeURIComponent(key)}&pagination=false`)
return data[0] || null
}
export async function saveUserSetting(key, value) {
return apiFetch('/user-settings', {
method: 'POST',
body: JSON.stringify({ key, value }),
})
}
export async function listForumsByParent(parentId) {
return getCollection(`/forums?parent=/api/forums/${parentId}`)
@@ -134,10 +245,108 @@ export async function listThreadsByForum(forumId) {
return getCollection(`/threads?forum=/api/forums/${forumId}`)
}
export async function listThreads() {
return getCollection('/threads')
}
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}`)
}
@@ -146,6 +355,77 @@ export async function listUsers() {
return getCollection('/users')
}
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',
body: JSON.stringify({ rank_id: rankId }),
})
}
export async function createRank(payload) {
return apiFetch('/ranks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRank(rankId, payload) {
return apiFetch(`/ranks/${rankId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRank(rankId) {
return apiFetch(`/ranks/${rankId}`, {
method: 'DELETE',
})
}
export async function uploadRankBadgeImage(rankId, file) {
const body = new FormData()
body.append('file', file)
return apiFetch(`/ranks/${rankId}/badge-image`, {
method: 'POST',
body,
})
}
export async function updateUser(userId, payload) {
return apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function createThread({ title, body, forumId }) {
return apiFetch('/threads', {
method: 'POST',

View File

@@ -0,0 +1,112 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
const { t } = useTranslation()
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 '—'
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}`
}
const repliesCount = Math.max((thread.posts_count ?? 0) - 1, 0)
return (
<div className="bb-portal-topic-row">
<div className="bb-portal-topic-main">
<span className="bb-portal-topic-icon" aria-hidden="true">
<i className="bi bi-chat-left-text" />
</span>
<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"
style={authorLinkStyle}
>
{authorName}
</Link>
) : (
<span className="bb-portal-topic-author">{authorName}</span>
)}
<span className="bb-portal-topic-meta-sep">»</span>
<span className="bb-portal-topic-meta-date">{formatDateTime(thread.created_at)}</span>
</div>
{showForum && (
<div className="bb-portal-topic-meta-line">
<span className="bb-portal-topic-meta-label">{t('portal.forum_label')}</span>
<span className="bb-portal-topic-forum">
{forumId ? (
<Link to={`/forum/${forumId}`} className="bb-portal-topic-forum-link">
{forumName}
</Link>
) : (
forumName
)}
</span>
</div>
)}
</div>
</div>
</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"
style={lastAuthorLinkStyle}
>
{lastAuthorName}
</Link>
) : (
<span className="bb-portal-last-user">{lastAuthorName}</span>
)}
<Link
to={`/thread/${thread.id}${lastPostAnchor}`}
className="bb-portal-last-jump ms-2"
aria-label={t('thread.view')}
>
<i className="bi bi-eye" aria-hidden="true" />
</Link>
</span>
<span className="bb-portal-last-date">
{formatDateTime(thread.last_post_at || thread.created_at)}
</span>
</div>
</div>
</div>
)
}

View File

@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) {
const data = await apiLogin(emailInput, password)
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || emailInput)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
setRoles([])
}
setToken(data.token)
setEmail(data.email || emailInput)
setEmail(data.email || loginInput)
},
logout() {
localStorage.removeItem('speedbb_token')
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Container } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { fetchUserSetting, listAllForums, saveUserSetting } from '../api/client'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
export default function BoardIndex() {
const [forums, setForums] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState({})
const { t } = useTranslation()
const { token } = useAuth()
const collapsedKey = 'board_index.collapsed_categories'
const storageKey = `speedbb_user_setting_${collapsedKey}`
const saveTimer = useRef(null)
useEffect(() => {
listAllForums()
.then(setForums)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!token) return
let active = true
const cached = localStorage.getItem(storageKey)
if (cached) {
try {
const parsed = JSON.parse(cached)
if (Array.isArray(parsed)) {
const next = {}
parsed.forEach((id) => {
next[String(id)] = true
})
setCollapsed(next)
}
} catch {
localStorage.removeItem(storageKey)
}
}
fetchUserSetting(collapsedKey)
.then((setting) => {
if (!active) return
const next = {}
if (Array.isArray(setting?.value)) {
setting.value.forEach((id) => {
next[String(id)] = true
})
}
setCollapsed(next)
localStorage.setItem(storageKey, JSON.stringify(setting?.value || []))
})
.catch(() => {})
return () => {
active = false
}
}, [token])
const getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
forums.forEach((forum) => {
const parentId = getParentId(forum)
const node = map.get(String(forum.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const sortNodes = (nodes) => {
nodes.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
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])
const renderRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
{node.children?.length > 0 && (
<div className="bb-board-subforums">
{t('forum.children')}:{' '}
{node.children.map((child, index) => (
<span key={child.id}>
<Link to={`/forum/${child.id}`} className="bb-board-subforum-link">
{child.name}
</Link>
{index < node.children.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<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"
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>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">
{node.last_post_at.slice(0, 10)}
</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
</div>
))
return (
<Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (
<p className="bb-muted">{t('home.empty')}</p>
)}
{forumTree.length > 0 && (
<div className="bb-board-index">
{forumTree.map((category) => (
<section className="bb-board-section" key={category.id}>
<header className="bb-board-section__header">
<span className="bb-board-section__title">{category.name}</span>
<div className="bb-board-section__controls">
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
<button
type="button"
className="bb-board-toggle"
onClick={() =>
setCollapsed((prev) => {
const next = {
...prev,
[category.id]: !prev[category.id],
}
const collapsedIds = Object.keys(next).filter((key) => next[key])
localStorage.setItem(storageKey, JSON.stringify(collapsedIds))
if (token) {
if (saveTimer.current) {
clearTimeout(saveTimer.current)
}
saveTimer.current = setTimeout(() => {
saveUserSetting(collapsedKey, collapsedIds).catch(() => {})
}, 400)
}
return next
})
}
aria-label={
collapsed[category.id]
? t('forum.expand_category')
: t('forum.collapse_category')
}
>
<i
className={`bi ${
collapsed[category.id] ? 'bi-plus-square' : 'bi-dash-square'
}`}
aria-hidden="true"
/>
</button>
</div>
</header>
{!collapsed[category.id] && (
<div className="bb-board-section__body">
{category.children?.length > 0 ? (
renderRows(category.children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
)}
</section>
))}
</div>
)}
</Container>
)
}

View File

@@ -1,7 +1,16 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
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'
@@ -13,11 +22,152 @@ export default function ForumView() {
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
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) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || ''}</div>
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
<div className="bb-board-cell bb-board-cell--last">
{node.last_post_at ? (
<div className="bb-board-last">
<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"
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>
) : (
<span>{node.last_post_user_name || t('thread.anonymous')}</span>
)}
</span>
<span className="bb-board-last-date">{node.last_post_at.slice(0, 10)}</span>
</div>
) : (
<span className="bb-muted">{t('thread.no_replies')}</span>
)}
</div>
</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
@@ -28,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
@@ -52,93 +204,489 @@ 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 className="py-5">
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<div className="bb-hero mb-4">
<p className="bb-chip">
{forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')}
</p>
<h2 className="mt-3">{forum.name}</h2>
<p className="bb-muted mb-0">
{forum.description || t('forum.no_description')}
</p>
</div>
<Row className="g-4">
<Col lg={7}>
<h4 className="bb-section-title mb-3">{t('forum.children')}</h4>
{children.length === 0 && (
<p className="bb-muted">{t('forum.empty_children')}</p>
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
</section>
</div>
)}
{children.map((child) => (
<Card className="bb-card mb-3" key={child.id}>
<Card.Body>
<Card.Title>{child.name}</Card.Title>
<Card.Text className="bb-muted">
{child.description || t('forum.no_description')}
</Card.Text>
<Link to={`/forum/${child.id}`} className="stretched-link">
{t('forum.open')}
</Link>
</Card.Body>
</Card>
))}
{forum.type === 'forum' && (
<>
<h4 className="bb-section-title mb-3 mt-4">{t('forum.threads')}</h4>
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</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 tr-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{threads.length === 0 && (
<p className="bb-muted">{t('forum.empty_threads')}</p>
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)}
{threads.map((thread) => (
<Card className="bb-card mb-3" key={thread.id}>
<Card.Body>
<Card.Title>{thread.title}</Card.Title>
<Card.Text className="bb-muted">
{thread.body.length > 160
? `${thread.body.slice(0, 160)}...`
: thread.body}
</Card.Text>
<Link to={`/thread/${thread.id}`} className="stretched-link">
{t('thread.view')}
</Link>
</Card.Body>
</Card>
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={forum?.name || t('portal.unknown_forum')}
forumId={forum?.id}
showForum={false}
/>
))}
</div>
</>
)}
</Col>
<Col lg={5}>
<h4 className="bb-section-title mb-3">{t('forum.start_thread')}</h4>
<div className="bb-form">
{forum.type !== 'forum' && (
<p className="bb-muted mb-3">{t('forum.only_forums')}</p>
</Row>
</>
)}
{forum.type === 'forum' && !token && (
<p className="bb-muted mb-3">{t('forum.login_hint')}</p>
)}
<Form onSubmit={handleSubmit}>
{forum?.type === 'forum' && (
<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 className="d-flex flex-column p-0">
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<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
@@ -146,35 +694,117 @@ export default function ForumView() {
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving || forum.type !== 'forum'}
disabled={!token || saving}
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={5}
rows={6}
className="flex-grow-1"
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving || forum.type !== 'forum'}
onPaste={handleThreadPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<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>
<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"
variant="dark"
disabled={!token || saving || forum.type !== 'forum'}
className="bb-accent-button"
disabled={!token || saving || uploading}
>
{saving ? t('form.posting') : t('form.create_thread')}
{saving || uploading ? t('form.posting') : t('form.create_thread')}
</Button>
</Form>
</div>
</Col>
</Row>
</>
</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

@@ -1,21 +1,61 @@
import { useEffect, useMemo, useState } from 'react'
import { Container } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { listAllForums } from '../api/client'
import { fetchPortalSummary } from '../api/client'
import PortalTopicRow from '../components/PortalTopicRow'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
export default function Home() {
const [forums, setForums] = useState([])
const [threads, setThreads] = useState([])
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [loadingForums, setLoadingForums] = useState(true)
const [loadingThreads, setLoadingThreads] = useState(true)
const [loadingStats, setLoadingStats] = useState(true)
const [profile, setProfile] = useState(null)
const { token, roles, email } = useAuth()
const { t } = useTranslation()
useEffect(() => {
listAllForums()
.then(setForums)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
let active = true
setLoadingForums(true)
setLoadingThreads(true)
setLoadingStats(true)
setError('')
fetchPortalSummary()
.then((data) => {
if (!active) return
setForums(data?.forums || [])
setThreads(data?.threads || [])
setStats({
threads: data?.stats?.threads ?? 0,
posts: data?.stats?.posts ?? 0,
users: data?.stats?.users ?? 0,
})
setProfile(data?.profile || null)
})
.catch((err) => {
if (!active) return
setError(err.message)
setForums([])
setThreads([])
setStats({ threads: 0, posts: 0, users: 0 })
setProfile(null)
})
.finally(() => {
if (!active) return
setLoadingForums(false)
setLoadingThreads(false)
setLoadingStats(false)
})
return () => {
active = false
}
}, [token])
const getParentId = (forum) => {
if (!forum.parent) return null
@@ -51,11 +91,86 @@ 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])
const forumMap = useMemo(() => {
const map = new Map()
forums.forEach((forum) => {
map.set(String(forum.id), forum)
})
return map
}, [forums])
const recentThreads = useMemo(() => {
return [...threads]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 12)
}, [threads])
const roleLabel = useMemo(() => {
if (!roles?.length) return t('portal.user_role_member')
if (roles.includes('ROLE_ADMIN')) return t('portal.user_role_operator')
if (roles.includes('ROLE_MODERATOR')) return t('portal.user_role_moderator')
return t('portal.user_role_member')
}, [roles, t])
const resolveForumName = (thread) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
}
const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
@@ -71,7 +186,7 @@ export default function Home() {
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
{node.name}
</Link>
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
<div className="bb-muted">{node.description || ''}</div>
</div>
</div>
</div>
@@ -82,22 +197,107 @@ export default function Home() {
))
return (
<Container className="py-5">
<div className="bb-hero mb-4">
<p className="bb-chip">{t('app.brand')}</p>
<h1 className="mt-3">{t('home.hero_title')}</h1>
<p className="bb-muted mb-0">
{t('home.hero_body')}
</p>
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.menu')}</div>
<ul className="bb-portal-list">
<li>{t('portal.menu_news')}</li>
<li>{t('portal.menu_gallery')}</li>
<li>{t('portal.menu_calendar')}</li>
<li>{t('portal.menu_rules')}</li>
</ul>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{loadingStats ? '—' : stats.threads}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_users')}</span>
<strong>{loadingStats ? '—' : stats.users}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_posts')}</span>
<strong>{loadingStats ? '—' : stats.posts}</strong>
</div>
</div>
</aside>
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (
<p className="bb-muted">{t('home.empty')}</p>
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{forumTree.length > 0 && <div className="mt-2">{renderTree(forumTree)}</div>}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header tr-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{recentThreads.map((thread) => (
<PortalTopicRow
key={thread.id}
thread={thread}
forumName={resolveForumName(thread)}
forumId={resolveForumId(thread)}
/>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<Link to="/ucp" className="bb-portal-user-avatar">
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</Link>
<div className="bb-portal-user-name">
{profile?.id ? (
<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>
) : (
profile?.name || email || 'User'
)}
</div>
<div className="bb-portal-user-role">{roleLabel}</div>
</div>
<ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li>
<li>{t('portal.user_control_panel')}</li>
<li>{t('portal.user_logout')}</li>
</ul>
</div>
<div className="bb-portal-card bb-portal-card--ad">
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
<div className="bb-portal-ad-box">example.com</div>
</div>
</aside>
</div>
{error && <p className="text-danger mt-3">{error}</p>}
</Container>
)
}

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -18,7 +18,7 @@ export default function Login() {
setError('')
setLoading(true)
try {
await login(email, password)
await login(loginValue, password)
navigate('/')
} catch (err) {
setError(err.message)
@@ -28,7 +28,7 @@ export default function Login() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
@@ -36,11 +36,12 @@ export default function Login() {
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
type="text"
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required
/>
</Form.Group>

View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
export default function Profile() {
const { id } = useParams()
const { t } = useTranslation()
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('')
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
.then(([profileData, givenData, receivedData]) => {
if (!active) return
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) return
setLoading(false)
setLoadingThanks(false)
})
return () => {
active = false
}
}, [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">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</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

@@ -33,7 +33,7 @@ export default function Register() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>

View File

@@ -1,20 +1,50 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread } from '../api/client'
import { useEffect, useMemo, useRef, useState } from 'react'
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)
useEffect(() => {
setLoading(true)
@@ -27,62 +57,722 @@ 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
if (!hash) return
const targetId = hash.replace('#', '')
if (!targetId) return
const target = document.getElementById(targetId)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [thread, posts])
const handleSubmit = async (event) => {
event.preventDefault()
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 formatDate = (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())
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 (
<Container className="py-5">
<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]
}, [posts, thread])
const handleJumpToReply = () => {
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 (
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
<>
<div className="bb-hero mb-4">
<p className="bb-chip">{t('thread.label')}</p>
<h2 className="mt-3">{thread.title}</h2>
<p className="bb-muted mb-2">{thread.body}</p>
{thread.forum && (
<p className="bb-muted mb-0">
{t('thread.category')}{' '}
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
{thread.forum.name || t('thread.back_to_category')}
</Link>
</p>
<div className="bb-thread">
<div className="bb-thread-titlebar">
<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">
{thread.user_name || t('thread.anonymous')}
</span>
{thread.created_at && (
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
<Row className="g-4">
<Col lg={7}>
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
{posts.length === 0 && (
<p className="bb-muted">{t('thread.empty')}</p>
<div className="bb-thread-toolbar">
<div className="bb-thread-actions">
<Button className="bb-accent-button" onClick={handleJumpToReply}>
<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>
)}
{posts.map((post) => (
<Card className="bb-card mb-3" key={post.id}>
<Card.Body>
<Card.Text>{post.body}</Card.Text>
<small className="bb-muted">
{post.author?.username || t('thread.anonymous')}
</small>
</Card.Body>
</Card>
))}
</Col>
<Col lg={5}>
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
<div className="bb-form">
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
<i className="bi bi-wrench" aria-hidden="true" />
</button>
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
<i className="bi bi-gear" aria-hidden="true" />
</button>
</div>
<div className="bb-thread-meta-right">
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
<span></span>
<span>Page 1 of 1</span>
</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
: `${t('thread.reply_prefix')} ${thread.title}`
: ''
const postNumber = index + 1
return (
<article className="bb-post-row" key={post.id} id={`post-${post.id}`}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">
{post.user_rank_name || ''}
</div>
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
<div className="bb-post-author-badge">
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
<img src={post.user_rank_badge_url} alt="" />
) : (
<span>{post.user_rank_badge_text}</span>
)}
</div>
)}
<div className="bb-post-author-meta">
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.posts')}:</span>
<span className="bb-post-author-value">
{post.user_posts_count ?? 0}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.registered')}:</span>
<span className="bb-post-author-value">
{formatDate(post.user_created_at)}
</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">{t('thread.location')}:</span>
<span className="bb-post-author-value">
{post.user_location || '-'}
</span>
</div>
<div className="bb-post-author-stat">
<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">{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>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<div className="bb-post-content">
<div className="bb-post-header">
<div className="bb-post-header-meta">
{topicLabel && (
<span className="bb-post-topic">
#{postNumber} {topicLabel}
</span>
)}
<span>{t('thread.by')} {authorName}</span>
{post.created_at && (
<span>{post.created_at.slice(0, 10)}</span>
)}
</div>
<div className="bb-post-actions">
<button type="button" className="bb-post-action" aria-label="Edit post">
<i className="bi bi-pencil" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Delete post">
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Report post">
<i className="bi bi-exclamation-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" />
</button>
<button type="button" className="bb-post-action" aria-label="Quote post">
<i className="bi bi-quote" aria-hidden="true" />
</button>
{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>
</article>
)
})}
</div>
<div className="bb-thread-reply" ref={replyRef}>
<div className="bb-thread-reply-title">{t('thread.reply')}</div>
{!token && (
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
)}
@@ -91,23 +781,117 @@ export default function ThreadView() {
<Form.Label>{t('form.message')}</Form.Label>
<Form.Control
as="textarea"
rows={5}
rows={6}
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleReplyPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<Button type="submit" variant="dark" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
<Form.Control
id="bb-reply-attachment-input"
type="file"
multiple
className="bb-attachment-input"
disabled={!token || saving || replyUploading}
onChange={(event) => {
applyReplyFiles(event.target.files)
event.target.value = ''
}}
/>
<div
className={`bb-attachment-drop ${replyDropActive ? 'is-dragover' : ''}`}
role="button"
tabIndex={0}
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
document.getElementById('bb-reply-attachment-input')?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setReplyDropActive(true)
}}
onDragLeave={() => setReplyDropActive(false)}
onDrop={(event) => {
event.preventDefault()
setReplyDropActive(false)
applyReplyFiles(event.dataTransfer.files)
}}
>
<span>
{t('attachment.drop_hint')}{' '}
<button
type="button"
className="bb-attachment-drop-link"
onClick={(event) => {
event.stopPropagation()
document.getElementById('bb-reply-attachment-input')?.click()
}}
>
{t('attachment.drop_browse')}
</button>
</span>
</div>
{renderAttachmentFooter()}
<div className="bb-thread-reply-actions">
<div className="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>
</Col>
</Row>
</>
</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>
)
}

179
resources/js/pages/Ucp.jsx Normal file
View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react'
import { Container, Form, Row, Col, Button } from 'react-bootstrap'
import { getCurrentUser, updateCurrentUser, uploadAvatar } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
const [location, setLocation] = useState('')
const [profileError, setProfileError] = useState('')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
useEffect(() => {
if (!token) return
let active = true
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
setLocation(data?.location || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
return () => {
active = false
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group>
<Form.Group className="mt-3">
<Form.Label>{t('ucp.location_label')}</Form.Label>
<Form.Control
type="text"
value={location}
disabled={!token || profileSaving}
onChange={(event) => {
setLocation(event.target.value)
setProfileSaved(false)
}}
/>
<Form.Text className="bb-muted">{t('ucp.location_hint')}</Form.Text>
</Form.Group>
{profileError && <p className="text-danger mt-2 mb-0">{profileError}</p>}
{profileSaved && <p className="text-success mt-2 mb-0">{t('ucp.profile_saved')}</p>}
<Button
type="button"
variant="outline-light"
className="mt-3"
disabled={!token || profileSaving}
onClick={async () => {
setProfileError('')
setProfileSaved(false)
setProfileSaving(true)
try {
const response = await updateCurrentUser({ location })
setLocation(response?.location || '')
setProfileSaved(true)
} catch (err) {
setProfileError(err.message)
} finally {
setProfileSaving(false)
}
}}
>
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button>
</Col>
</Row>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Row className="g-3">
<Col xs={12}>
<Form.Group>
<Form.Label>{t('nav.language')}</Form.Label>
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('nav.theme')}</Form.Label>
<Form.Select value={theme} onChange={(event) => setTheme(event.target.value)}>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('ucp.accent_override')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Select
value={accentMode}
onChange={(event) => {
const mode = event.target.value
if (mode === 'system') {
setAccentOverride('')
} else if (!accentOverride) {
setAccentOverride('#f29b3f')
}
}}
>
<option value="system">{t('ucp.system_default')}</option>
<option value="custom">{t('ucp.custom_color')}</option>
</Form.Select>
<Form.Control
type="color"
value={accentOverride || '#f29b3f'}
onChange={(event) => setAccentOverride(event.target.value)}
disabled={accentMode !== 'custom'}
/>
</div>
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
</Container>
)
}

Some files were not shown because too many files have changed in this diff Show More