Compare commits
28 Commits
eef3262a53
...
v26.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 64244567c0 | |||
| 7fbc566129 | |||
| c33cde6f04 | |||
| 2409feb06f | |||
| e3dcf99362 | |||
| 357f6fb755 | |||
| 2281b80980 | |||
| f23363fdcc | |||
| c1814c0d47 | |||
| 7489a3903d | |||
| b967aa912b | |||
| 67ae9517f4 | |||
| 653905d5e2 | |||
| bc893b644d | |||
| 662e00bec1 | |||
| a96913bffa | |||
| 79ac0cdca5 | |||
| fe4b7ccd7c | |||
| fc9de4c9fd | |||
| 6b6f787351 | |||
| d4fb86633b | |||
|
|
24c16ed0dd | ||
|
|
f9de433545 | ||
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 | ||
|
|
c8d2bd508e |
39
.gitea/workflows/commit.yaml
Normal 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
@@ -1,5 +1,6 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
@@ -20,8 +21,11 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/storage/app
|
||||||
|
/storage/framework
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/framework/views/*.php
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|||||||
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# 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
|
## 2026-01-02
|
||||||
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
|
- 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.
|
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
||||||
|
|||||||
4
ansible/ansible.cfg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = ./hosts.ini
|
||||||
|
set_remote_user = yes
|
||||||
|
allow_world_readable_tmpfiles=true
|
||||||
15
ansible/deploy-to-prod.yaml
Normal 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
@@ -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=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
104
ansible/roles/speedBB/tasks/main.yaml
Normal 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
@@ -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
@@ -0,0 +1,9 @@
|
|||||||
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
|
31623264303535663263613235356231623137333734626164376138656532623937316534333835
|
||||||
|
3661666237386534373466356136393566333162326562330a383833363737323637363738616666
|
||||||
|
62393164326465376634356666303861613362313430656161653531373733353530636265353738
|
||||||
|
3863633131313834390a356663373338346137373662356161643336636534626130313466343566
|
||||||
|
36653636333838633938323363646335663935646135613632356434396436326131323361366561
|
||||||
|
32633939346163356131663266346539323330613536333838616332646139313731326133646165
|
||||||
|
31343763636337306263646631353562646462323631383439353738333035623664623163303839
|
||||||
|
34343261383738396534
|
||||||
58
app/Actions/BbcodeFormatter.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use s9e\TextFormatter\Configurator;
|
||||||
|
use s9e\TextFormatter\Parser;
|
||||||
|
use s9e\TextFormatter\Renderer;
|
||||||
|
|
||||||
|
class BbcodeFormatter
|
||||||
|
{
|
||||||
|
private static ?Parser $parser = null;
|
||||||
|
private static ?Renderer $renderer = null;
|
||||||
|
|
||||||
|
public static function format(?string $text): string
|
||||||
|
{
|
||||||
|
if ($text === null || $text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::$parser || !self::$renderer) {
|
||||||
|
[$parser, $renderer] = self::build();
|
||||||
|
self::$parser = $parser;
|
||||||
|
self::$renderer = $renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml = self::$parser->parse($text);
|
||||||
|
|
||||||
|
return self::$renderer->render($xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function build(): array
|
||||||
|
{
|
||||||
|
$configurator = new Configurator();
|
||||||
|
$bbcodes = $configurator->plugins->load('BBCodes');
|
||||||
|
$bbcodes->addFromRepository('B');
|
||||||
|
$bbcodes->addFromRepository('I');
|
||||||
|
$bbcodes->addFromRepository('U');
|
||||||
|
$bbcodes->addFromRepository('S');
|
||||||
|
$bbcodes->addFromRepository('URL');
|
||||||
|
$bbcodes->addFromRepository('IMG');
|
||||||
|
$bbcodes->addFromRepository('QUOTE');
|
||||||
|
$bbcodes->addFromRepository('CODE');
|
||||||
|
$bbcodes->addFromRepository('LIST');
|
||||||
|
$bbcodes->addFromRepository('*');
|
||||||
|
|
||||||
|
$configurator->tags->add('BR')->template = '<br/>';
|
||||||
|
|
||||||
|
$bundle = $configurator->finalize();
|
||||||
|
$parser = $bundle['parser'] ?? null;
|
||||||
|
$renderer = $bundle['renderer'] ?? null;
|
||||||
|
|
||||||
|
if (!$parser || !$renderer) {
|
||||||
|
throw new \RuntimeException('Unable to initialize BBCode formatter.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$parser, $renderer];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
Validator::make(data: $input, rules: [
|
Validator::make(data: $input, rules: [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(table: User::class, column: 'name_canonical'),
|
||||||
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
|
|
||||||
return User::create(attributes: [
|
return User::create(attributes: [
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make(value: $input['password']),
|
'password' => Hash::make(value: $input['password']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'name_canonical')->ignore($user->id),
|
||||||
|
],
|
||||||
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
@@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
{
|
{
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'email_verified_at' => null,
|
'email_verified_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|||||||
97
app/Console/Commands/VersionFetch.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
482
app/Http/Controllers/AttachmentController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\AttachmentExtension;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AttachmentExtensionController extends Controller
|
||||||
|
{
|
||||||
|
private function ensureAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensions = AttachmentExtension::query()
|
||||||
|
->with('group')
|
||||||
|
->orderBy('extension')
|
||||||
|
->get()
|
||||||
|
->map(fn (AttachmentExtension $extension) => $this->serializeExtension($extension));
|
||||||
|
|
||||||
|
return response()->json($extensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicIndex(): JsonResponse
|
||||||
|
{
|
||||||
|
$extensions = AttachmentExtension::query()
|
||||||
|
->whereNotNull('attachment_group_id')
|
||||||
|
->whereHas('group', fn ($query) => $query->where('is_active', true))
|
||||||
|
->orderBy('extension')
|
||||||
|
->pluck('extension')
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json($extensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->validatePayload($request, true);
|
||||||
|
$extension = $this->normalizeExtension($data['extension']);
|
||||||
|
if ($extension === '') {
|
||||||
|
return response()->json(['message' => 'Invalid extension.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AttachmentExtension::query()->where('extension', $extension)->exists()) {
|
||||||
|
return response()->json(['message' => 'Extension already exists.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = AttachmentExtension::create([
|
||||||
|
'extension' => $extension,
|
||||||
|
'attachment_group_id' => $data['attachment_group_id'] ?? null,
|
||||||
|
'allowed_mimes' => $data['allowed_mimes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created->load('group');
|
||||||
|
|
||||||
|
return response()->json($this->serializeExtension($created), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->validatePayload($request, false);
|
||||||
|
|
||||||
|
if (array_key_exists('attachment_group_id', $data)) {
|
||||||
|
$attachmentExtension->attachment_group_id = $data['attachment_group_id'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('allowed_mimes', $data)) {
|
||||||
|
$attachmentExtension->allowed_mimes = $data['allowed_mimes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentExtension->save();
|
||||||
|
$attachmentExtension->load('group');
|
||||||
|
|
||||||
|
return response()->json($this->serializeExtension($attachmentExtension));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Attachment::query()->where('attachment_extension_id', $attachmentExtension->id)->exists()) {
|
||||||
|
return response()->json(['message' => 'Extension is in use.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentExtension->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePayload(Request $request, bool $requireExtension): array
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'attachment_group_id' => ['nullable', 'integer', 'exists:attachment_groups,id'],
|
||||||
|
'allowed_mimes' => ['nullable', 'array'],
|
||||||
|
'allowed_mimes.*' => ['string', 'max:150'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($requireExtension) {
|
||||||
|
$rules['extension'] = ['required', 'string', 'max:30'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->validate($rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeExtension(string $value): string
|
||||||
|
{
|
||||||
|
return ltrim(strtolower(trim($value)), '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeExtension(AttachmentExtension $extension): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $extension->id,
|
||||||
|
'extension' => $extension->extension,
|
||||||
|
'attachment_group_id' => $extension->attachment_group_id,
|
||||||
|
'allowed_mimes' => $extension->allowed_mimes,
|
||||||
|
'group' => $extension->group ? [
|
||||||
|
'id' => $extension->group->id,
|
||||||
|
'name' => $extension->group->name,
|
||||||
|
'is_active' => $extension->group->is_active,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,28 +3,37 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Fortify\CreateNewUser;
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\PasswordValidationRules;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||||
{
|
{
|
||||||
$input = [
|
$input = [
|
||||||
'name' => $request->input('name') ?? $request->input('username'),
|
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
|
||||||
'email' => $request->input('email'),
|
'email' => $request->input(key: 'email'),
|
||||||
'password' => $request->input('password') ?? $request->input('plainPassword'),
|
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
|
||||||
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
|
'password_confirmation' => $request->input(key: 'password_confirmation')
|
||||||
|
?? $request->input(key: 'plainPassword'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$user = $creator->create($input);
|
$user = $creator->create(input: $input);
|
||||||
|
|
||||||
$user->sendEmailVerificationNotification();
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json(data: [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'message' => 'Verification email sent.',
|
'message' => 'Verification email sent.',
|
||||||
@@ -33,39 +42,140 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->merge(input: [
|
||||||
'email' => ['required', 'email'],
|
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->validate(rules: [
|
||||||
|
'login' => ['required', 'string'],
|
||||||
'password' => ['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)) {
|
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
|
||||||
throw ValidationException::withMessages([
|
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
|
||||||
'email' => ['Invalid credentials.'],
|
} 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()) {
|
if (!$user->hasVerifiedEmail()) {
|
||||||
return response()->json([
|
return response()->json(data : [
|
||||||
'message' => 'Email not verified.',
|
'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,
|
'token' => $token,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'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
|
public function logout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->user()?->currentAccessToken()?->delete();
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(data: null, status: 204);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -11,39 +13,51 @@ class ForumController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Forum::query()->withoutTrashed();
|
$query = Forum::query()
|
||||||
|
->withoutTrashed()
|
||||||
|
->withCount(relations: ['threads', 'posts'])
|
||||||
|
->withSum(relation: 'threads', column: 'views_count');
|
||||||
|
|
||||||
$parentParam = $request->query('parent');
|
$parentParam = $request->query(key: 'parent');
|
||||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||||
if ($exists === false) {
|
if ($exists === false) {
|
||||||
$query->whereNull('parent_id');
|
$query->whereNull(columns: 'parent_id');
|
||||||
} elseif ($exists === true) {
|
} elseif ($exists === true) {
|
||||||
$query->whereNotNull('parent_id');
|
$query->whereNotNull(columns: 'parent_id');
|
||||||
}
|
}
|
||||||
} elseif (is_string($parentParam)) {
|
} elseif (is_string(value: $parentParam)) {
|
||||||
$parentId = $this->parseIriId($parentParam);
|
$parentId = $this->parseIriId(value: $parentParam);
|
||||||
if ($parentId !== null) {
|
if ($parentId !== null) {
|
||||||
$query->where('parent_id', $parentId);
|
$query->where(column: 'parent_id', operator: $parentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('type')) {
|
if ($request->filled(key: 'type')) {
|
||||||
$query->where('type', $request->query('type'));
|
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$forums = $query
|
$forums = $query
|
||||||
->orderBy('position')
|
->orderBy(column: 'position')
|
||||||
->orderBy('name')
|
->orderBy(column: 'name')
|
||||||
->get()
|
->get();
|
||||||
->map(fn (Forum $forum) => $this->serializeForum($forum));
|
|
||||||
|
|
||||||
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
|
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
|
public function store(Request $request): JsonResponse
|
||||||
@@ -68,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');
|
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||||
|
}
|
||||||
|
|
||||||
$forum = Forum::create([
|
$forum = Forum::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@@ -78,7 +97,11 @@ class ForumController extends Controller
|
|||||||
'position' => ($position ?? 0) + 1,
|
'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
|
public function update(Request $request, Forum $forum): JsonResponse
|
||||||
@@ -122,7 +145,11 @@ class ForumController extends Controller
|
|||||||
|
|
||||||
$forum->save();
|
$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(Request $request, Forum $forum): JsonResponse
|
public function destroy(Request $request, Forum $forum): JsonResponse
|
||||||
@@ -175,7 +202,7 @@ class ForumController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serializeForum(Forum $forum): array
|
private function serializeForum(Forum $forum, ?Post $lastPost): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $forum->id,
|
'id' => $forum->id,
|
||||||
@@ -184,8 +211,76 @@ class ForumController extends Controller
|
|||||||
'type' => $forum->type,
|
'type' => $forum->type,
|
||||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||||
'position' => $forum->position,
|
'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(),
|
'created_at' => $forum->created_at?->toIso8601String(),
|
||||||
'updated_at' => $forum->updated_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
app/Http/Controllers/InstallerController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/Http/Controllers/PortalController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,25 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Post::query()->withoutTrashed();
|
$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');
|
$threadParam = $request->query('thread');
|
||||||
if (is_string($threadParam)) {
|
if (is_string($threadParam)) {
|
||||||
@@ -45,6 +54,14 @@ class PostController extends Controller
|
|||||||
'body' => $data['body'],
|
'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);
|
return response()->json($this->serializePost($post), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +93,126 @@ class PostController extends Controller
|
|||||||
|
|
||||||
private function serializePost(Post $post): array
|
private function serializePost(Post $post): array
|
||||||
{
|
{
|
||||||
|
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($post->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'body' => $post->body,
|
'body' => $post->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
'thread' => "/api/threads/{$post->thread_id}",
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
'user_id' => $post->user_id,
|
'user_id' => $post->user_id,
|
||||||
|
'user_name' => $post->user?->name,
|
||||||
|
'user_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(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||||
|
'attachments' => $post->relationLoaded('attachments')
|
||||||
|
? $attachments
|
||||||
|
->map(fn ($attachment) => [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'group' => $attachment->group ? [
|
||||||
|
'id' => $attachment->group->id,
|
||||||
|
'name' => $attachment->group->name,
|
||||||
|
] : null,
|
||||||
|
'original_name' => $attachment->original_name,
|
||||||
|
'extension' => $attachment->extension,
|
||||||
|
'mime_type' => $attachment->mime_type,
|
||||||
|
'size_bytes' => $attachment->size_bytes,
|
||||||
|
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
122
app/Http/Controllers/PostThankController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Controllers/PreviewController.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PreviewController extends Controller
|
||||||
|
{
|
||||||
|
public function preview(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'html' => BbcodeFormatter::format($data['body']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/Http/Controllers/RankController.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Http/Controllers/RoleController.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Http/Controllers/StatsController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,28 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ThreadController extends Controller
|
class ThreadController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Thread::query()->withoutTrashed()->with('user');
|
$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');
|
$forumParam = $request->query('forum');
|
||||||
if (is_string($forumParam)) {
|
if (is_string($forumParam)) {
|
||||||
@@ -22,7 +36,7 @@ class ThreadController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$threads = $query
|
$threads = $query
|
||||||
->latest('created_at')
|
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||||
|
|
||||||
@@ -31,7 +45,17 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
public function show(Thread $thread): JsonResponse
|
public function show(Thread $thread): JsonResponse
|
||||||
{
|
{
|
||||||
$thread->loadMissing('user');
|
$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));
|
return response()->json($this->serializeThread($thread));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +81,16 @@ class ThreadController extends Controller
|
|||||||
'body' => $data['body'],
|
'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);
|
return response()->json($this->serializeThread($thread), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +103,38 @@ class ThreadController extends Controller
|
|||||||
return response()->json(null, 204);
|
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
|
private function parseIriId(?string $value): ?int
|
||||||
{
|
{
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
@@ -88,15 +154,140 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
private function serializeThread(Thread $thread): array
|
private function serializeThread(Thread $thread): array
|
||||||
{
|
{
|
||||||
|
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $thread->id,
|
'id' => $thread->id,
|
||||||
'title' => $thread->title,
|
'title' => $thread->title,
|
||||||
'body' => $thread->body,
|
'body' => $thread->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
'solved' => (bool) $thread->solved,
|
||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_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_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(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
|
'attachments' => $thread->relationLoaded('attachments')
|
||||||
|
? $attachments
|
||||||
|
->map(fn ($attachment) => [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'group' => $attachment->group ? [
|
||||||
|
'id' => $attachment->group->id,
|
||||||
|
'name' => $attachment->group->name,
|
||||||
|
] : null,
|
||||||
|
'original_name' => $attachment->original_name,
|
||||||
|
'extension' => $attachment->extension,
|
||||||
|
'mime_type' => $attachment->mime_type,
|
||||||
|
'size_bytes' => $attachment->size_bytes,
|
||||||
|
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,37 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class UploadController extends Controller
|
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
|
public function storeLogo(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|||||||
@@ -2,24 +2,276 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
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
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->with('roles')
|
->with(['roles', 'rank'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get()
|
->get()
|
||||||
->map(fn (User $user) => [
|
->map(fn (User $user) => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'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(),
|
'roles' => $user->roles->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($users);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
app/Models/Attachment.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/AttachmentExtension.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $extension
|
||||||
|
* @property int|null $attachment_group_id
|
||||||
|
* @property array|null $allowed_mimes
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class AttachmentExtension extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'extension',
|
||||||
|
'attachment_group_id',
|
||||||
|
'allowed_mimes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'allowed_mimes' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function group(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/AttachmentGroup.php
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
@@ -60,4 +63,20 @@ class Forum extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Thread::class);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property string $body
|
* @property string $body
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||||
* @property-read \App\Models\Thread $thread
|
* @property-read \App\Models\Thread $thread
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
||||||
@@ -45,4 +47,14 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class Role extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'color',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function users(): BelongsToMany
|
public function users(): BelongsToMany
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
@@ -13,9 +14,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property int|null $user_id
|
* @property int|null $user_id
|
||||||
* @property string $title
|
* @property string $title
|
||||||
* @property string $body
|
* @property string $body
|
||||||
|
* @property bool $solved
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Forum $forum
|
* @property-read \App\Models\Forum $forum
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
||||||
* @property-read int|null $posts_count
|
* @property-read int|null $posts_count
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
@@ -40,6 +43,11 @@ class Thread extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'title',
|
'title',
|
||||||
'body',
|
'body',
|
||||||
|
'solved',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'solved' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function forum(): BelongsTo
|
public function forum(): BelongsTo
|
||||||
@@ -56,4 +64,14 @@ class Thread extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Post::class);
|
return $this->hasMany(Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestPost(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Post::class)->latestOfMany();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\DatabaseNotification;
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
use Illuminate\Notifications\DatabaseNotificationCollection;
|
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||||
@@ -64,6 +66,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'name_canonical',
|
||||||
|
'avatar_path',
|
||||||
|
'location',
|
||||||
|
'rank_id',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
@@ -95,4 +101,29 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return $this->belongsToMany(Role::class);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
artisan
@@ -4,7 +4,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
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...
|
// Register the Composer autoloader...
|
||||||
require __DIR__.'/vendor/autoload.php';
|
require __DIR__.'/vendor/autoload.php';
|
||||||
@@ -13,6 +13,6 @@ require __DIR__.'/vendor/autoload.php';
|
|||||||
/** @var Application $app */
|
/** @var Application $app */
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
$status = $app->handleCommand(new ArgvInput);
|
$status = $app->handleCommand(input: new ArgvInput);
|
||||||
|
|
||||||
exit($status);
|
exit($status);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
->withCommands([
|
||||||
|
__DIR__.'/../app/Console/Commands',
|
||||||
|
])
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.4",
|
||||||
"laravel/fortify": "*",
|
"laravel/fortify": "*",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"s9e/text-formatter": "^2.5",
|
||||||
"ext-pdo": "*"
|
"ext-pdo": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
524
composer.lock
generated
@@ -48,7 +48,7 @@ return [
|
|||||||
'timeout' => null,
|
'timeout' => null,
|
||||||
'local_domain' => env(
|
'local_domain' => env(
|
||||||
'MAIL_EHLO_DOMAIN',
|
'MAIL_EHLO_DOMAIN',
|
||||||
parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)
|
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ class UserFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
$name = fake()->unique()->userName();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => $name,
|
||||||
|
'name_canonical' => Str::lower($name),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
28
database/migrations/2026_01_05_020000_create_ranks_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('thread_id')->nullable()->constrained('threads')->nullOnDelete();
|
||||||
|
$table->foreignId('post_id')->nullable()->constrained('posts')->nullOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('disk', 50)->default('local');
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('original_name');
|
||||||
|
$table->string('extension', 30)->nullable();
|
||||||
|
$table->string('mime_type', 150);
|
||||||
|
$table->unsignedBigInteger('size_bytes');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index('thread_id', 'idx_attachments_thread_id');
|
||||||
|
$table->index('post_id', 'idx_attachments_post_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('attachments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 150);
|
||||||
|
$table->unsignedInteger('max_size_kb')->default(25600);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Schema::hasTable('attachment_types')) {
|
||||||
|
$types = DB::table('attachment_types')->orderBy('id')->get();
|
||||||
|
foreach ($types as $type) {
|
||||||
|
DB::table('attachment_groups')->insert([
|
||||||
|
'name' => $type->label ?? $type->key ?? 'General',
|
||||||
|
'max_size_kb' => $type->max_size_kb ?? 25600,
|
||||||
|
'is_active' => $type->is_active ?? true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('attachment_groups')->count() === 0) {
|
||||||
|
DB::table('attachment_groups')->insert([
|
||||||
|
'name' => 'General',
|
||||||
|
'max_size_kb' => 25600,
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('attachment_groups');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('attachment_extensions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('extension', 30)->unique();
|
||||||
|
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
||||||
|
$table->json('allowed_mimes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Schema::hasTable('attachment_types') && Schema::hasTable('attachment_groups')) {
|
||||||
|
$groups = DB::table('attachment_groups')->orderBy('id')->get()->values();
|
||||||
|
$types = DB::table('attachment_types')->orderBy('id')->get()->values();
|
||||||
|
|
||||||
|
foreach ($types as $index => $type) {
|
||||||
|
$group = $groups[$index] ?? null;
|
||||||
|
if (!$group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$extensions = [];
|
||||||
|
if (!empty($type->allowed_extensions)) {
|
||||||
|
$decoded = json_decode($type->allowed_extensions, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$extensions = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($extensions as $ext) {
|
||||||
|
$ext = strtolower(trim((string) $ext));
|
||||||
|
if ($ext === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DB::table('attachment_extensions')->updateOrInsert(
|
||||||
|
['extension' => $ext],
|
||||||
|
[
|
||||||
|
'attachment_group_id' => $group->id,
|
||||||
|
'allowed_mimes' => $type->allowed_mimes,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('attachment_extensions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->foreignId('attachment_extension_id')->nullable()->constrained('attachment_extensions')->nullOnDelete();
|
||||||
|
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
||||||
|
$table->index('attachment_extension_id', 'idx_attachments_extension_id');
|
||||||
|
$table->index('attachment_group_id', 'idx_attachments_group_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Schema::hasTable('attachment_extensions')) {
|
||||||
|
$extensions = DB::table('attachment_extensions')->get()->keyBy('extension');
|
||||||
|
$attachments = DB::table('attachments')->select('id', 'extension')->get();
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$ext = strtolower(trim((string) $attachment->extension));
|
||||||
|
if ($ext === '' || !$extensions->has($ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$extRow = $extensions->get($ext);
|
||||||
|
DB::table('attachments')
|
||||||
|
->where('id', $attachment->id)
|
||||||
|
->update([
|
||||||
|
'attachment_extension_id' => $extRow->id,
|
||||||
|
'attachment_group_id' => $extRow->attachment_group_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_attachments_extension_id');
|
||||||
|
$table->dropIndex('idx_attachments_group_id');
|
||||||
|
$table->dropConstrainedForeignId('attachment_extension_id');
|
||||||
|
$table->dropConstrainedForeignId('attachment_group_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('attachments', 'attachment_type_id')) {
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['attachment_type_id']);
|
||||||
|
$table->dropIndex('idx_attachments_type_id');
|
||||||
|
$table->dropColumn('attachment_type_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasColumn('attachments', 'attachment_type_id')) {
|
||||||
|
Schema::table('attachments', function (Blueprint $table) {
|
||||||
|
$table->foreignId('attachment_type_id')->constrained('attachment_types');
|
||||||
|
$table->index('attachment_type_id', 'idx_attachments_type_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('attachment_types');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Intentionally left empty. attachment_types is deprecated.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('attachment_groups', 'category')) {
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('category');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
|
||||||
|
if (Schema::hasTable('attachment_extensions')) {
|
||||||
|
if (!Schema::hasColumn('attachment_extensions', 'allowed_mimes')) {
|
||||||
|
Schema::table('attachment_extensions', function (Blueprint $table) {
|
||||||
|
$table->json('allowed_mimes')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = DB::table('attachment_groups')
|
||||||
|
->select('id', 'allowed_mimes')
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
$extensions = DB::table('attachment_extensions')
|
||||||
|
->select('id', 'attachment_group_id', 'allowed_mimes')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($extensions as $extension) {
|
||||||
|
if (!empty($extension->allowed_mimes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$group = $groups->get($extension->attachment_group_id);
|
||||||
|
if (!$group || empty($group->allowed_mimes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DB::table('attachment_extensions')
|
||||||
|
->where('id', $extension->id)
|
||||||
|
->update([
|
||||||
|
'allowed_mimes' => $group->allowed_mimes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('allowed_mimes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasColumn('attachment_groups', 'category')) {
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->string('category', 50)->default('other');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->json('allowed_mimes')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->foreignId('parent_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
||||||
|
$table->unsignedInteger('position')->default(1);
|
||||||
|
$table->index(['parent_id', 'position'], 'idx_attachment_groups_parent_position');
|
||||||
|
});
|
||||||
|
|
||||||
|
$groups = DB::table('attachment_groups')->orderBy('id')->get();
|
||||||
|
$position = 1;
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
DB::table('attachment_groups')
|
||||||
|
->where('id', $group->id)
|
||||||
|
->update(['position' => $position++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('attachment_groups', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_attachment_groups_parent_position');
|
||||||
|
$table->dropConstrainedForeignId('parent_id');
|
||||||
|
$table->dropColumn('position');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
RoleSeeder::class,
|
RoleSeeder::class,
|
||||||
|
RankSeeder::class,
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
ForumSeeder::class,
|
ForumSeeder::class,
|
||||||
ThreadSeeder::class,
|
ThreadSeeder::class,
|
||||||
|
|||||||
41
database/seeders/RankSeeder.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace Database\Seeders;
|
|||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\Rank;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$adminRole = Role::where('name', 'ROLE_ADMIN')->first();
|
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
|
||||||
$userRole = Role::where('name', 'ROLE_USER')->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(
|
$admin = User::updateOrCreate(
|
||||||
['email' => 'tracer@24unix.net'],
|
attributes: ['email' => 'tracer@24unix.net'],
|
||||||
[
|
values : [
|
||||||
'name' => 'tracer',
|
'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(),
|
'email_verified_at' => now(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
|
|||||||
$admin->roles()->syncWithoutDetaching([$userRole->id]);
|
$admin->roles()->syncWithoutDetaching([$userRole->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($userRole) {
|
||||||
|
$micha->roles()->syncWithoutDetaching([$userRole->id]);
|
||||||
|
}
|
||||||
|
|
||||||
$users = User::factory()->count(100)->create([
|
$users = User::factory()->count(100)->create([
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
298
package-lock.json
generated
@@ -2072,18 +2072,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -2888,18 +2876,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
|
||||||
"version": "2.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3010,280 +2986,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"lightningcss-android-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-x64": "1.30.2",
|
|
||||||
"lightningcss-freebsd-x64": "1.30.2",
|
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-musl": "1.30.2",
|
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
|
||||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-android-arm64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:watch": "vite build --watch",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"watch": "vite build --watch",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
|
|||||||
// Register the Composer autoloader...
|
// Register the Composer autoloader...
|
||||||
require __DIR__.'/../vendor/autoload.php';
|
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...
|
// Bootstrap Laravel and handle the request...
|
||||||
/** @var Application $app */
|
/** @var Application $app */
|
||||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import Register from './pages/Register'
|
|||||||
import Acp from './pages/Acp'
|
import Acp from './pages/Acp'
|
||||||
import BoardIndex from './pages/BoardIndex'
|
import BoardIndex from './pages/BoardIndex'
|
||||||
import Ucp from './pages/Ucp'
|
import Ucp from './pages/Ucp'
|
||||||
|
import Profile from './pages/Profile'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
||||||
|
|
||||||
function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) {
|
function PortalHeader({
|
||||||
|
userMenu,
|
||||||
|
isAuthenticated,
|
||||||
|
forumName,
|
||||||
|
logoUrl,
|
||||||
|
showHeaderName,
|
||||||
|
canAccessAcp,
|
||||||
|
canAccessMcp,
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [crumbs, setCrumbs] = useState([])
|
const [crumbs, setCrumbs] = useState([])
|
||||||
@@ -96,6 +105,33 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }])
|
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +143,17 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
}, [location.pathname, t])
|
}, [location.pathname, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="pt-2 pb-2 bb-portal-shell">
|
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
||||||
<div className="bb-portal-banner">
|
<div className="bb-portal-banner">
|
||||||
<div className="bb-portal-brand">
|
<div className="bb-portal-brand">
|
||||||
|
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||||
)}
|
)}
|
||||||
{(showHeaderName || !logoUrl) && (
|
{(showHeaderName || !logoUrl) && (
|
||||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||||
)}
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-search">
|
<div className="bb-portal-search">
|
||||||
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
||||||
@@ -135,12 +173,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
<span>
|
<span>
|
||||||
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
|
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
|
||||||
</span>
|
</span>
|
||||||
|
{isAuthenticated && canAccessAcp && (
|
||||||
|
<>
|
||||||
<Link to="/acp" className="bb-portal-link">
|
<Link to="/acp" className="bb-portal-link">
|
||||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && canAccessMcp && (
|
||||||
<span>
|
<span>
|
||||||
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
|
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -174,11 +218,11 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
|
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
|
||||||
{index > 0 && <span className="bb-portal-sep">›</span>}
|
{index > 0 && <span className="bb-portal-sep">›</span>}
|
||||||
{crumb.current ? (
|
{crumb.current ? (
|
||||||
<span className="bb-portal-current">
|
<Link to={crumb.to} className="bb-portal-current bb-portal-link">
|
||||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||||
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link to={crumb.to} className="bb-portal-link">
|
<Link to={crumb.to} className="bb-portal-link">
|
||||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||||
@@ -197,7 +241,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
|
|||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { token, email, logout, isAdmin } = useAuth()
|
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
||||||
const [versionInfo, setVersionInfo] = useState(null)
|
const [versionInfo, setVersionInfo] = useState(null)
|
||||||
const [theme, setTheme] = useState('auto')
|
const [theme, setTheme] = useState('auto')
|
||||||
const [resolvedTheme, setResolvedTheme] = useState('light')
|
const [resolvedTheme, setResolvedTheme] = useState('light')
|
||||||
@@ -382,7 +426,7 @@ function AppShell() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bb-shell">
|
<div className="bb-shell" id="top">
|
||||||
<PortalHeader
|
<PortalHeader
|
||||||
isAuthenticated={!!token}
|
isAuthenticated={!!token}
|
||||||
forumName={settings.forumName}
|
forumName={settings.forumName}
|
||||||
@@ -403,7 +447,7 @@ function AppShell() {
|
|||||||
<NavDropdown.Item as={Link} to="/ucp">
|
<NavDropdown.Item as={Link} to="/ucp">
|
||||||
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
|
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
<NavDropdown.Item as={Link} to="/ucp">
|
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
|
||||||
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
|
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
@@ -413,6 +457,8 @@ function AppShell() {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
canAccessAcp={isAdmin}
|
||||||
|
canAccessMcp={isModerator}
|
||||||
/>
|
/>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
@@ -421,6 +467,7 @@ function AppShell() {
|
|||||||
<Route path="/thread/:id" element={<ThreadView />} />
|
<Route path="/thread/:id" element={<ThreadView />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/profile/:id" element={<Profile />} />
|
||||||
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/ucp"
|
path="/ucp"
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ export async function getCollection(path) {
|
|||||||
return data?.['hydra:member'] || []
|
return data?.['hydra:member'] || []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email, password) {
|
export async function login(login, password) {
|
||||||
return apiFetch('/login', {
|
return apiFetch('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +70,60 @@ export async function listAllForums() {
|
|||||||
return getCollection('/forums?pagination=false')
|
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() {
|
export async function fetchVersion() {
|
||||||
return apiFetch('/version')
|
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) {
|
export async function fetchSetting(key) {
|
||||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||||
const cacheBust = Date.now()
|
const cacheBust = Date.now()
|
||||||
@@ -203,6 +253,100 @@ export async function getThread(id) {
|
|||||||
return apiFetch(`/threads/${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) {
|
export async function listPostsByThread(threadId) {
|
||||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||||
}
|
}
|
||||||
@@ -211,6 +355,77 @@ export async function listUsers() {
|
|||||||
return getCollection('/users')
|
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 }) {
|
export async function createThread({ title, body, forumId }) {
|
||||||
return apiFetch('/threads', {
|
return apiFetch('/threads', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
112
resources/js/components/PortalTopicRow.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
|
|||||||
userId: effectiveUserId,
|
userId: effectiveUserId,
|
||||||
roles: effectiveRoles,
|
roles: effectiveRoles,
|
||||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
async login(emailInput, password) {
|
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
const data = await apiLogin(emailInput, password)
|
async login(loginInput, password) {
|
||||||
|
const data = await apiLogin(loginInput, password)
|
||||||
localStorage.setItem('speedbb_token', data.token)
|
localStorage.setItem('speedbb_token', data.token)
|
||||||
localStorage.setItem('speedbb_email', data.email || emailInput)
|
localStorage.setItem('speedbb_email', data.email || loginInput)
|
||||||
if (data.user_id) {
|
if (data.user_id) {
|
||||||
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
localStorage.setItem('speedbb_user_id', String(data.user_id))
|
||||||
setUserId(String(data.user_id))
|
setUserId(String(data.user_id))
|
||||||
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
|
|||||||
setRoles([])
|
setRoles([])
|
||||||
}
|
}
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setEmail(data.email || emailInput)
|
setEmail(data.email || loginInput)
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
localStorage.removeItem('speedbb_token')
|
localStorage.removeItem('speedbb_token')
|
||||||
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
|
|||||||
userId: effectiveUserId,
|
userId: effectiveUserId,
|
||||||
roles: effectiveRoles,
|
roles: effectiveRoles,
|
||||||
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
|
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
|
||||||
hasToken: Boolean(token),
|
hasToken: Boolean(token),
|
||||||
})
|
})
|
||||||
}, [email, effectiveUserId, effectiveRoles, token])
|
}, [email, effectiveUserId, effectiveRoles, token])
|
||||||
|
|||||||
@@ -96,7 +96,48 @@ export default function BoardIndex() {
|
|||||||
nodes.forEach((node) => sortNodes(node.children))
|
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)
|
sortNodes(roots)
|
||||||
|
roots.forEach((root) => aggregateNodes(root))
|
||||||
|
|
||||||
return roots
|
return roots
|
||||||
}, [forums])
|
}, [forums])
|
||||||
@@ -113,7 +154,7 @@ export default function BoardIndex() {
|
|||||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||||
{node.name}
|
{node.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div>
|
<div className="bb-board-desc">{node.description || ''}</div>
|
||||||
{node.children?.length > 0 && (
|
{node.children?.length > 0 && (
|
||||||
<div className="bb-board-subforums">
|
<div className="bb-board-subforums">
|
||||||
{t('forum.children')}:{' '}
|
{t('forum.children')}:{' '}
|
||||||
@@ -130,16 +171,45 @@ export default function BoardIndex() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
|
||||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
|
||||||
<div className="bb-board-cell bb-board-cell--last">
|
<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>
|
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-4 bb-portal-shell">
|
<Container fluid className="py-4 bb-portal-shell">
|
||||||
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{!loading && forumTree.length === 0 && (
|
{!loading && forumTree.length === 0 && (
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { createThread, getForum, 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 { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -17,6 +26,24 @@ export default function ForumView() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [threadFiles, setThreadFiles] = useState([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
|
||||||
|
const [attachmentValidationError, setAttachmentValidationError] = useState('')
|
||||||
|
const [threadDropActive, setThreadDropActive] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('')
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewUrls, setPreviewUrls] = useState([])
|
||||||
|
const [attachmentTab, setAttachmentTab] = useState('options')
|
||||||
|
const [attachmentOptions, setAttachmentOptions] = useState({
|
||||||
|
disableBbcode: false,
|
||||||
|
disableSmilies: false,
|
||||||
|
disableAutoUrls: false,
|
||||||
|
attachSignature: true,
|
||||||
|
notifyReplies: false,
|
||||||
|
lockTopic: false,
|
||||||
|
})
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const renderChildRows = (nodes) =>
|
const renderChildRows = (nodes) =>
|
||||||
@@ -31,18 +58,116 @@ export default function ForumView() {
|
|||||||
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
<Link to={`/forum/${node.id}`} className="bb-board-link">
|
||||||
{node.name}
|
{node.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div>
|
<div className="bb-board-desc">{node.description || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-board-cell bb-board-cell--topics">—</div>
|
<div className="bb-board-cell bb-board-cell--topics">{node.threads_count ?? 0}</div>
|
||||||
<div className="bb-board-cell bb-board-cell--posts">—</div>
|
<div className="bb-board-cell bb-board-cell--posts">{node.views_count ?? 0}</div>
|
||||||
<div className="bb-board-cell bb-board-cell--last">
|
<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>
|
<span className="bb-muted">{t('thread.no_replies')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
@@ -53,9 +178,11 @@ export default function ForumView() {
|
|||||||
const forumData = await getForum(id)
|
const forumData = await getForum(id)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setForum(forumData)
|
setForum(forumData)
|
||||||
const childData = await listForumsByParent(id)
|
const allForums = await listAllForums()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setChildren(childData)
|
const treeMap = buildForumTree(allForums)
|
||||||
|
const currentNode = treeMap.get(String(forumData.id))
|
||||||
|
setChildren(currentNode?.children ?? [])
|
||||||
if (forumData.type === 'forum') {
|
if (forumData.type === 'forum') {
|
||||||
const threadData = await listThreadsByForum(id)
|
const threadData = await listThreadsByForum(id)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -77,26 +204,390 @@ export default function ForumView() {
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAttachmentExtensionsPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await createThread({ title, body, forumId: id })
|
const created = await createThread({ title, body, forumId: id })
|
||||||
|
if (threadFiles.length > 0 && created?.id) {
|
||||||
|
setUploading(true)
|
||||||
|
for (const entry of threadFiles) {
|
||||||
|
await uploadAttachment({ threadId: created.id, file: entry.file })
|
||||||
|
}
|
||||||
|
}
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setBody('')
|
setBody('')
|
||||||
|
setThreadFiles([])
|
||||||
const updated = await listThreadsByForum(id)
|
const updated = await listThreadsByForum(id)
|
||||||
setThreads(updated)
|
setThreads(updated)
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (!bytes && bytes !== 0) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const kb = bytes / 1024
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||||
|
const mb = kb / 1024
|
||||||
|
return `${mb.toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInlineInsert = (entry) => {
|
||||||
|
const marker = `[attachment]${entry.file.name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPreviewUrls = () => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
setPreviewUrls([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPreviewBody = (rawBody, entries) => {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { body: rawBody, urls: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const map = new Map()
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const file = entry.file
|
||||||
|
if (!file) return
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
urls.push(url)
|
||||||
|
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
|
||||||
|
const key = String(name || '').trim().toLowerCase()
|
||||||
|
if (!map.has(key)) return match
|
||||||
|
const { url, mime } = map.get(key)
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return `[img]${url}[/img]`
|
||||||
|
}
|
||||||
|
return `[url=${url}]${name}[/url]`
|
||||||
|
})
|
||||||
|
|
||||||
|
return { body: replaced, urls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
clearPreviewUrls()
|
||||||
|
const { body: previewBody, urls } = buildPreviewBody(body || '', threadFiles)
|
||||||
|
const result = await previewBbcode(previewBody || '')
|
||||||
|
setPreviewHtml(result?.html || '')
|
||||||
|
setShowPreview(true)
|
||||||
|
setPreviewUrls(urls)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyThreadFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreadFiles(
|
||||||
|
accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendThreadFiles = (files) => {
|
||||||
|
const fileList = Array.from(files || [])
|
||||||
|
const allowed = allowedAttachmentExtensions
|
||||||
|
const rejected = []
|
||||||
|
const accepted = fileList.filter((file) => {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
||||||
|
rejected.push(file.name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
setAttachmentValidationError(
|
||||||
|
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
||||||
|
)
|
||||||
|
} else if (accepted.length > 0) {
|
||||||
|
setAttachmentValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length === 0) return
|
||||||
|
setThreadFiles((prev) => [
|
||||||
|
...prev,
|
||||||
|
...accepted.map((file) => ({
|
||||||
|
id: `${file.name}-${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
comment: '',
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
setAttachmentTab('attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThreadPaste = (event) => {
|
||||||
|
const items = Array.from(event.clipboardData?.items || [])
|
||||||
|
if (items.length === 0) return
|
||||||
|
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
|
||||||
|
if (imageItems.length === 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
const files = imageItems
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((file) => {
|
||||||
|
const ext = file.type?.split('/')[1] || 'png'
|
||||||
|
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
|
||||||
|
return new File([file], name, { type: file.type })
|
||||||
|
})
|
||||||
|
appendThreadFiles(files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
const marker = `[attachment]${files[0].name}[/attachment]`
|
||||||
|
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAttachmentFooter = () => (
|
||||||
|
<div className="bb-attachment-panel">
|
||||||
|
<div className="bb-attachment-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setAttachmentTab('options')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_options')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setAttachmentTab('attachments')}
|
||||||
|
>
|
||||||
|
{t('attachment.tab_attachments')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bb-attachment-body">
|
||||||
|
{attachmentTab === 'options' && (
|
||||||
|
<div className="bb-attachment-options">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-bbcode"
|
||||||
|
label={t('attachment.option_disable_bbcode')}
|
||||||
|
checked={attachmentOptions.disableBbcode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableBbcode: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-smilies"
|
||||||
|
label={t('attachment.option_disable_smilies')}
|
||||||
|
checked={attachmentOptions.disableSmilies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableSmilies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-disable-auto-urls"
|
||||||
|
label={t('attachment.option_disable_auto_urls')}
|
||||||
|
checked={attachmentOptions.disableAutoUrls}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableAutoUrls: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-attach-signature"
|
||||||
|
label={t('attachment.option_attach_signature')}
|
||||||
|
checked={attachmentOptions.attachSignature}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
attachSignature: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-notify-replies"
|
||||||
|
label={t('attachment.option_notify_replies')}
|
||||||
|
checked={attachmentOptions.notifyReplies}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
notifyReplies: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="bb-option-lock-topic"
|
||||||
|
label={t('attachment.option_lock_topic')}
|
||||||
|
checked={attachmentOptions.lockTopic}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAttachmentOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lockTopic: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachmentTab === 'attachments' && (
|
||||||
|
<>
|
||||||
|
<p className="bb-muted mb-2">
|
||||||
|
{t('attachment.hint')}
|
||||||
|
</p>
|
||||||
|
<p className="bb-muted mb-3">
|
||||||
|
{t('attachment.max_size', { size: '25 MB' })}
|
||||||
|
</p>
|
||||||
|
<div className="bb-attachment-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||||
|
>
|
||||||
|
{t('attachment.add_files')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{attachmentValidationError && (
|
||||||
|
<p className="text-danger mb-2">{attachmentValidationError}</p>
|
||||||
|
)}
|
||||||
|
<table className="table bb-attachment-table">
|
||||||
|
<thead className="tr-header">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.filename')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.size')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.status')}</th>
|
||||||
|
<th scope="col" className="text-start">{t('attachment.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{threadFiles.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="bb-attachment-empty">
|
||||||
|
{t('attachment.empty')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{threadFiles.map((entry) => (
|
||||||
|
<tr key={entry.id} className="bb-attachment-row">
|
||||||
|
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{entry.file.name}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-cell-comment">
|
||||||
|
<Form.Control
|
||||||
|
className="bb-attachment-comment"
|
||||||
|
value={entry.comment}
|
||||||
|
onChange={(event) =>
|
||||||
|
setThreadFiles((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === entry.id
|
||||||
|
? { ...item, comment: event.target.value }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t('attachment.file_comment_placeholder')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
|
||||||
|
{formatBytes(entry.file.size)}
|
||||||
|
</td>
|
||||||
|
<td className="bb-attachment-status text-center">
|
||||||
|
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="bb-attachment-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() => handleInlineInsert(entry)}
|
||||||
|
title={t('attachment.place_inline')}
|
||||||
|
aria-label={t('attachment.place_inline')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-paperclip" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-action"
|
||||||
|
onClick={() =>
|
||||||
|
setThreadFiles((prev) =>
|
||||||
|
prev.filter((item) => item.id !== entry.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t('attachment.delete_file')}
|
||||||
|
aria-label={t('attachment.delete_file')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5 bb-shell-container">
|
||||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{forum && (
|
{forum && (
|
||||||
@@ -156,56 +647,24 @@ export default function ForumView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<div className="bb-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-topic-header">
|
<div className="bb-portal-topic-header tr-header">
|
||||||
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
|
<span>{t('portal.topic')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
|
<span>{t('thread.replies')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
|
<span>{t('thread.views')}</span>
|
||||||
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
|
<span>{t('thread.last_post')}</span>
|
||||||
</div>
|
</div>
|
||||||
{threads.length === 0 && (
|
{threads.length === 0 && (
|
||||||
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
|
||||||
)}
|
)}
|
||||||
{threads.map((thread) => (
|
{threads.map((thread) => (
|
||||||
<div className="bb-topic-row" key={thread.id}>
|
<PortalTopicRow
|
||||||
<div className="bb-topic-cell bb-topic-cell--title">
|
key={thread.id}
|
||||||
<div className="bb-topic-title">
|
thread={thread}
|
||||||
<span className="bb-topic-icon" aria-hidden="true">
|
forumName={forum?.name || t('portal.unknown_forum')}
|
||||||
<i className="bi bi-chat-left" />
|
forumId={forum?.id}
|
||||||
</span>
|
showForum={false}
|
||||||
<div className="bb-topic-text">
|
/>
|
||||||
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
|
|
||||||
<div className="bb-topic-meta">
|
|
||||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
|
||||||
<span>{t('thread.by')}</span>
|
|
||||||
<span className="bb-topic-author">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</span>
|
|
||||||
{thread.created_at && (
|
|
||||||
<span className="bb-topic-date">
|
|
||||||
{thread.created_at.slice(0, 10)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
|
||||||
<div className="bb-topic-cell bb-topic-cell--last">
|
|
||||||
<div className="bb-topic-last">
|
|
||||||
<span className="bb-topic-last-by">
|
|
||||||
{t('thread.by')}{' '}
|
|
||||||
<span className="bb-topic-author">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{thread.created_at && (
|
|
||||||
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -215,13 +674,19 @@ export default function ForumView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{forum?.type === 'forum' && (
|
{forum?.type === 'forum' && (
|
||||||
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
|
<Modal
|
||||||
|
show={showModal}
|
||||||
|
onHide={() => setShowModal(false)}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
dialogClassName="bb-thread-modal"
|
||||||
|
>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body className="d-flex flex-column p-0">
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit} className="d-flex flex-column flex-grow-1 px-3 pb-3 pt-2">
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.title')}</Form.Label>
|
<Form.Label>{t('form.title')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -233,30 +698,113 @@ export default function ForumView() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3 d-flex flex-column flex-grow-1">
|
||||||
<Form.Label>{t('form.body')}</Form.Label>
|
<Form.Label>{t('form.body')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={6}
|
rows={6}
|
||||||
|
className="flex-grow-1"
|
||||||
placeholder={t('form.thread_body_placeholder')}
|
placeholder={t('form.thread_body_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
onPaste={handleThreadPaste}
|
||||||
disabled={!token || saving}
|
disabled={!token || saving}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<div className="d-flex gap-2 justify-content-between">
|
<Form.Control
|
||||||
|
id="bb-thread-attachment-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="bb-attachment-input"
|
||||||
|
disabled={!token || saving || uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
applyThreadFiles(event.target.files)
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`bb-attachment-drop ${threadDropActive ? 'is-dragover' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
document.getElementById('bb-thread-attachment-input')?.click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setThreadDropActive(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setThreadDropActive(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setThreadDropActive(false)
|
||||||
|
applyThreadFiles(event.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t('attachment.drop_hint')}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-attachment-drop-link"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
document.getElementById('bb-thread-attachment-input')?.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('attachment.drop_browse')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{renderAttachmentFooter()}
|
||||||
|
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
|
||||||
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
<div className="d-flex gap-2">
|
||||||
{saving ? t('form.posting') : t('form.create_thread')}
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!token || saving || uploading || previewLoading}
|
||||||
|
>
|
||||||
|
{t('form.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={!token || saving || uploading}
|
||||||
|
>
|
||||||
|
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<Modal
|
||||||
|
show={showPreview}
|
||||||
|
onHide={() => {
|
||||||
|
setShowPreview(false)
|
||||||
|
clearPreviewUrls()
|
||||||
|
}}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('form.preview')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<div
|
||||||
|
className="bb-post-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
|
||||||
|
/>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,61 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Badge, Container } from 'react-bootstrap'
|
import { Container } from 'react-bootstrap'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listAllForums, listThreads } from '../api/client'
|
import { fetchPortalSummary } from '../api/client'
|
||||||
|
import PortalTopicRow from '../components/PortalTopicRow'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [forums, setForums] = useState([])
|
const [forums, setForums] = useState([])
|
||||||
const [threads, setThreads] = useState([])
|
const [threads, setThreads] = useState([])
|
||||||
|
const [stats, setStats] = useState({ threads: 0, posts: 0, users: 0 })
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loadingForums, setLoadingForums] = useState(true)
|
const [loadingForums, setLoadingForums] = useState(true)
|
||||||
const [loadingThreads, setLoadingThreads] = 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()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listAllForums()
|
let active = true
|
||||||
.then(setForums)
|
setLoadingForums(true)
|
||||||
.catch((err) => setError(err.message))
|
setLoadingThreads(true)
|
||||||
.finally(() => setLoadingForums(false))
|
setLoadingStats(true)
|
||||||
}, [])
|
setError('')
|
||||||
|
|
||||||
useEffect(() => {
|
fetchPortalSummary()
|
||||||
listThreads()
|
.then((data) => {
|
||||||
.then(setThreads)
|
if (!active) return
|
||||||
.catch((err) => setError(err.message))
|
setForums(data?.forums || [])
|
||||||
.finally(() => setLoadingThreads(false))
|
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) => {
|
const getParentId = (forum) => {
|
||||||
if (!forum.parent) return null
|
if (!forum.parent) return null
|
||||||
@@ -60,7 +91,48 @@ export default function Home() {
|
|||||||
nodes.forEach((node) => sortNodes(node.children))
|
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)
|
sortNodes(roots)
|
||||||
|
roots.forEach((root) => aggregateNodes(root))
|
||||||
|
|
||||||
return roots
|
return roots
|
||||||
}, [forums])
|
}, [forums])
|
||||||
@@ -79,6 +151,13 @@ export default function Home() {
|
|||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
}, [threads])
|
}, [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) => {
|
const resolveForumName = (thread) => {
|
||||||
if (!thread?.forum) return t('portal.unknown_forum')
|
if (!thread?.forum) return t('portal.unknown_forum')
|
||||||
const parts = thread.forum.split('/')
|
const parts = thread.forum.split('/')
|
||||||
@@ -107,7 +186,7 @@ export default function Home() {
|
|||||||
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
|
<Link to={`/forum/${node.id}`} className="bb-forum-link fw-semibold">
|
||||||
{node.name}
|
{node.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
|
<div className="bb-muted">{node.description || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +197,7 @@ export default function Home() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="pb-4 bb-portal-shell">
|
<Container fluid className="pb-4 bb-portal-shell">
|
||||||
<div className="bb-portal-layout">
|
<div className="bb-portal-layout">
|
||||||
<aside className="bb-portal-column bb-portal-column--left">
|
<aside className="bb-portal-column bb-portal-column--left">
|
||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
@@ -134,11 +213,15 @@ export default function Home() {
|
|||||||
<div className="bb-portal-card-title">{t('portal.stats')}</div>
|
<div className="bb-portal-card-title">{t('portal.stats')}</div>
|
||||||
<div className="bb-portal-stat">
|
<div className="bb-portal-stat">
|
||||||
<span>{t('portal.stat_threads')}</span>
|
<span>{t('portal.stat_threads')}</span>
|
||||||
<strong>{threads.length}</strong>
|
<strong>{loadingStats ? '—' : stats.threads}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-stat">
|
<div className="bb-portal-stat">
|
||||||
<span>{t('portal.stat_forums')}</span>
|
<span>{t('portal.stat_users')}</span>
|
||||||
<strong>{forums.length}</strong>
|
<strong>{loadingStats ? '—' : stats.users}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bb-portal-stat">
|
||||||
|
<span>{t('portal.stat_posts')}</span>
|
||||||
|
<strong>{loadingStats ? '—' : stats.posts}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -152,49 +235,19 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
{!loadingThreads && recentThreads.length > 0 && (
|
{!loadingThreads && recentThreads.length > 0 && (
|
||||||
<div className="bb-portal-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-portal-topic-header">
|
<div className="bb-portal-topic-header tr-header">
|
||||||
<span>{t('portal.topic')}</span>
|
<span>{t('portal.topic')}</span>
|
||||||
<span>{t('thread.replies')}</span>
|
<span>{t('thread.replies')}</span>
|
||||||
<span>{t('thread.views')}</span>
|
<span>{t('thread.views')}</span>
|
||||||
<span>{t('thread.last_post')}</span>
|
<span>{t('thread.last_post')}</span>
|
||||||
</div>
|
</div>
|
||||||
{recentThreads.map((thread) => (
|
{recentThreads.map((thread) => (
|
||||||
<div className="bb-portal-topic-row" key={thread.id}>
|
<PortalTopicRow
|
||||||
<div className="bb-portal-topic-main">
|
key={thread.id}
|
||||||
<span className="bb-portal-topic-icon" aria-hidden="true">
|
thread={thread}
|
||||||
<i className="bi bi-chat-left-text" />
|
forumName={resolveForumName(thread)}
|
||||||
</span>
|
forumId={resolveForumId(thread)}
|
||||||
<div>
|
/>
|
||||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
|
||||||
{thread.title}
|
|
||||||
</Link>
|
|
||||||
<div className="bb-portal-topic-meta">
|
|
||||||
<span>{t('thread.by')}</span>
|
|
||||||
<Badge bg="secondary">
|
|
||||||
{thread.user_name || t('thread.anonymous')}
|
|
||||||
</Badge>
|
|
||||||
<span className="bb-portal-topic-forum">
|
|
||||||
{t('portal.forum_label')}{' '}
|
|
||||||
{resolveForumId(thread) ? (
|
|
||||||
<Link
|
|
||||||
to={`/forum/${resolveForumId(thread)}`}
|
|
||||||
className="bb-portal-topic-forum-link"
|
|
||||||
>
|
|
||||||
{resolveForumName(thread)}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
resolveForumName(thread)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bb-portal-topic-cell">0</div>
|
|
||||||
<div className="bb-portal-topic-cell">—</div>
|
|
||||||
<div className="bb-portal-topic-cell">
|
|
||||||
{thread.created_at?.slice(0, 10) || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -205,9 +258,31 @@ export default function Home() {
|
|||||||
<div className="bb-portal-card">
|
<div className="bb-portal-card">
|
||||||
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
|
||||||
<div className="bb-portal-user-card">
|
<div className="bb-portal-user-card">
|
||||||
<div className="bb-portal-user-avatar" />
|
<Link to="/ucp" className="bb-portal-user-avatar">
|
||||||
<div className="bb-portal-user-name">tracer</div>
|
{profile?.avatar_url ? (
|
||||||
<div className="bb-portal-user-role">Operator</div>
|
<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>
|
</div>
|
||||||
<ul className="bb-portal-list">
|
<ul className="bb-portal-list">
|
||||||
<li>{t('portal.user_new_posts')}</li>
|
<li>{t('portal.user_new_posts')}</li>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [email, setEmail] = useState('')
|
const [loginValue, setLoginValue] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -18,7 +18,7 @@ export default function Login() {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(loginValue, password)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -28,7 +28,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5">
|
||||||
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
|
<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>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.email')}</Form.Label>
|
<Form.Label>{t('auth.login_identifier')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
value={loginValue}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setLoginValue(event.target.value)}
|
||||||
|
placeholder={t('auth.login_placeholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|||||||
178
resources/js/pages/Profile.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5">
|
<Container fluid className="py-5">
|
||||||
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
import { Button, Container, Form, Modal } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
import {
|
||||||
|
createPost,
|
||||||
|
getThread,
|
||||||
|
listPostsByThread,
|
||||||
|
updateThreadSolved,
|
||||||
|
uploadAttachment,
|
||||||
|
listAttachmentExtensionsPublic,
|
||||||
|
previewBbcode,
|
||||||
|
} from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ThreadView() {
|
export default function ThreadView() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { token } = useAuth()
|
const { token, userId, isAdmin } = useAuth()
|
||||||
const [thread, setThread] = useState(null)
|
const [thread, setThread] = useState(null)
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
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 { t } = useTranslation()
|
||||||
|
const replyRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -27,62 +57,722 @@ export default function ThreadView() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listAttachmentExtensionsPublic()
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await createPost({ body, threadId: id })
|
const created = await createPost({ body, threadId: id })
|
||||||
|
if (replyFiles.length > 0 && created?.id) {
|
||||||
|
setReplyUploading(true)
|
||||||
|
for (const entry of replyFiles) {
|
||||||
|
await uploadAttachment({ postId: created.id, file: entry.file })
|
||||||
|
}
|
||||||
|
}
|
||||||
setBody('')
|
setBody('')
|
||||||
|
setReplyFiles([])
|
||||||
const updated = await listPostsByThread(id)
|
const updated = await listPostsByThread(id)
|
||||||
setPosts(updated)
|
setPosts(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
setReplyUploading(false)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
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>}
|
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
{thread && (
|
{thread && (
|
||||||
<>
|
<div className="bb-thread">
|
||||||
<div className="bb-hero mb-4">
|
<div className="bb-thread-titlebar">
|
||||||
<p className="bb-chip">{t('thread.label')}</p>
|
<h1 className="bb-thread-title">
|
||||||
<h2 className="mt-3">{thread.title}</h2>
|
{thread.title}
|
||||||
<p className="bb-muted mb-2">{thread.body}</p>
|
{thread.solved && (
|
||||||
{thread.forum && (
|
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
|
||||||
<p className="bb-muted mb-0">
|
)}
|
||||||
{t('thread.category')}{' '}
|
</h1>
|
||||||
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
|
<div className="bb-thread-meta">
|
||||||
{thread.forum.name || t('thread.back_to_category')}
|
<span>{t('thread.by')}</span>
|
||||||
</Link>
|
<span className="bb-thread-author">
|
||||||
</p>
|
{thread.user_name || t('thread.anonymous')}
|
||||||
|
</span>
|
||||||
|
{thread.created_at && (
|
||||||
|
<span className="bb-thread-date">{thread.created_at.slice(0, 10)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Row className="g-4">
|
<div className="bb-thread-toolbar">
|
||||||
<Col lg={7}>
|
<div className="bb-thread-actions">
|
||||||
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
|
<Button className="bb-accent-button" onClick={handleJumpToReply}>
|
||||||
{posts.length === 0 && (
|
<i className="bi bi-reply-fill" aria-hidden="true" />
|
||||||
<p className="bb-muted">{t('thread.empty')}</p>
|
<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) => (
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
|
||||||
<Card className="bb-card mb-3" key={post.id}>
|
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
|
||||||
<Card.Body>
|
</button>
|
||||||
<Card.Text>{post.body}</Card.Text>
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.views')}>
|
||||||
<small className="bb-muted">
|
<i className="bi bi-wrench" aria-hidden="true" />
|
||||||
{post.author?.username || t('thread.anonymous')}
|
</button>
|
||||||
</small>
|
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.last_post')}>
|
||||||
</Card.Body>
|
<i className="bi bi-gear" aria-hidden="true" />
|
||||||
</Card>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</Col>
|
<div className="bb-thread-meta-right">
|
||||||
<Col lg={5}>
|
<span>{totalPosts} {totalPosts === 1 ? 'post' : 'posts'}</span>
|
||||||
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
|
<span>•</span>
|
||||||
<div className="bb-form">
|
<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 && (
|
{!token && (
|
||||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
<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.Label>{t('form.message')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={5}
|
rows={6}
|
||||||
placeholder={t('form.reply_placeholder')}
|
placeholder={t('form.reply_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
onPaste={handleReplyPaste}
|
||||||
disabled={!token || saving}
|
disabled={!token || saving}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
<Form.Control
|
||||||
{saving ? t('form.posting') : t('form.post_reply')}
|
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>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button"
|
||||||
|
disabled={!token || saving || replyUploading}
|
||||||
|
>
|
||||||
|
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,39 @@
|
|||||||
import { Container, Form, Row, Col } from 'react-bootstrap'
|
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'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { token } = useAuth()
|
||||||
const accentMode = accentOverride ? 'custom' : 'system'
|
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 handleLanguageChange = (event) => {
|
||||||
const locale = event.target.value
|
const locale = event.target.value
|
||||||
@@ -12,7 +42,85 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-5 bb-portal-shell">
|
<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">
|
||||||
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
|
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
|
||||||
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
|
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
|
||||||
|
|||||||
@@ -44,10 +44,23 @@
|
|||||||
"acp.show_header_name": "Forenname im Header anzeigen",
|
"acp.show_header_name": "Forenname im Header anzeigen",
|
||||||
"acp.add_category": "Kategorie hinzufügen",
|
"acp.add_category": "Kategorie hinzufügen",
|
||||||
"acp.add_forum": "Forum hinzufügen",
|
"acp.add_forum": "Forum hinzufügen",
|
||||||
|
"acp.ranks": "Ränge",
|
||||||
|
"acp.groups": "Gruppen",
|
||||||
|
"acp.attachments": "Anh\u00e4nge",
|
||||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||||
"acp.forums_tree": "Forenbaum",
|
"acp.forums_tree": "Forenbaum",
|
||||||
"acp.forums_type": "Typ",
|
"acp.forums_type": "Typ",
|
||||||
"acp.general": "Allgemein",
|
"acp.general": "Allgemein",
|
||||||
|
"acp.quick_access": "Schnellzugriff",
|
||||||
|
"acp.board_configuration": "Board-Konfiguration",
|
||||||
|
"acp.client_communication": "Client-Kommunikation",
|
||||||
|
"acp.server_configuration": "Server-Konfiguration",
|
||||||
|
"acp.authentication": "Authentifizierung",
|
||||||
|
"acp.email_settings": "E-Mail-Einstellungen",
|
||||||
|
"acp.security_settings": "Sicherheitseinstellungen",
|
||||||
|
"acp.search_settings": "Sucheinstellungen",
|
||||||
|
"acp.welcome_title": "Willkommen bei speedBB",
|
||||||
|
"acp.general_settings": "Allgemeine Einstellungen",
|
||||||
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
|
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
|
||||||
"acp.loading": "Laden...",
|
"acp.loading": "Laden...",
|
||||||
"acp.new_category": "Neue Kategorie",
|
"acp.new_category": "Neue Kategorie",
|
||||||
@@ -60,6 +73,8 @@
|
|||||||
"acp.users": "Benutzer",
|
"acp.users": "Benutzer",
|
||||||
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
||||||
"auth.login_title": "Anmelden",
|
"auth.login_title": "Anmelden",
|
||||||
|
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||||
|
"auth.login_placeholder": "name@example.com oder benutzername",
|
||||||
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
||||||
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||||
"auth.register_title": "Konto erstellen",
|
"auth.register_title": "Konto erstellen",
|
||||||
@@ -73,6 +88,9 @@
|
|||||||
"form.password": "Passwort",
|
"form.password": "Passwort",
|
||||||
"form.post_reply": "Antwort posten",
|
"form.post_reply": "Antwort posten",
|
||||||
"form.posting": "Wird gesendet...",
|
"form.posting": "Wird gesendet...",
|
||||||
|
"form.preview": "Vorschau",
|
||||||
|
"form.upload": "Hochladen",
|
||||||
|
"form.uploading": "Wird hochgeladen...",
|
||||||
"form.registering": "Registrierung läuft...",
|
"form.registering": "Registrierung läuft...",
|
||||||
"form.reply_placeholder": "Schreibe deine Antwort.",
|
"form.reply_placeholder": "Schreibe deine Antwort.",
|
||||||
"form.sign_in": "Anmelden",
|
"form.sign_in": "Anmelden",
|
||||||
@@ -87,8 +105,7 @@
|
|||||||
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
||||||
"forum.loading": "Forum wird geladen...",
|
"forum.loading": "Forum wird geladen...",
|
||||||
"forum.login_hint": "Melde dich an, um einen neuen Thread zu erstellen.",
|
"forum.login_hint": "Melde dich an, um einen neuen Thread zu erstellen.",
|
||||||
"forum.no_description": "Noch keine Beschreibung vorhanden.",
|
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
|
||||||
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
|
|
||||||
"forum.open": "Forum öffnen",
|
"forum.open": "Forum öffnen",
|
||||||
"forum.collapse_category": "Kategorie einklappen",
|
"forum.collapse_category": "Kategorie einklappen",
|
||||||
"forum.expand_category": "Kategorie ausklappen",
|
"forum.expand_category": "Kategorie ausklappen",
|
||||||
@@ -99,11 +116,45 @@
|
|||||||
"user.id": "ID",
|
"user.id": "ID",
|
||||||
"user.name": "Name",
|
"user.name": "Name",
|
||||||
"user.email": "E-Mail",
|
"user.email": "E-Mail",
|
||||||
|
"user.rank": "Rang",
|
||||||
|
"user.rank_unassigned": "Nicht zugewiesen",
|
||||||
|
"user.edit_title": "Benutzer bearbeiten",
|
||||||
|
"user.search": "Benutzer suchen...",
|
||||||
|
"rank.name": "Rangname",
|
||||||
|
"rank.name_placeholder": "z. B. Operator",
|
||||||
|
"rank.create": "Rang erstellen",
|
||||||
|
"rank.create_title": "Rang erstellen",
|
||||||
|
"rank.edit_title": "Rang bearbeiten",
|
||||||
|
"rank.badge_type": "Badge-Typ",
|
||||||
|
"rank.badge_text": "Text-Badge",
|
||||||
|
"rank.badge_image": "Bild-Badge",
|
||||||
|
"rank.badge_none": "Kein Badge",
|
||||||
|
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
|
||||||
|
"rank.color": "Rangfarbe",
|
||||||
|
"rank.color_placeholder": "z. B. #f29b3f",
|
||||||
|
"rank.color_default": "Standardfarbe verwenden",
|
||||||
|
"rank.badge_text_required": "Badge-Text ist erforderlich.",
|
||||||
|
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
|
||||||
|
"rank.delete_confirm": "Diesen Rang löschen?",
|
||||||
|
"rank.empty": "Noch keine Ränge vorhanden.",
|
||||||
"user.roles": "Rollen",
|
"user.roles": "Rollen",
|
||||||
"user.actions": "Aktionen",
|
"user.actions": "Aktionen",
|
||||||
"user.impersonate": "Imitieren",
|
"user.impersonate": "Imitieren",
|
||||||
"user.edit": "Bearbeiten",
|
"user.edit": "Bearbeiten",
|
||||||
"user.delete": "Löschen",
|
"user.delete": "Löschen",
|
||||||
|
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
|
||||||
|
"group.create": "Gruppe erstellen",
|
||||||
|
"group.create_title": "Gruppe erstellen",
|
||||||
|
"group.edit_title": "Gruppe bearbeiten",
|
||||||
|
"group.name": "Gruppenname",
|
||||||
|
"group.name_placeholder": "z. B. Gründer",
|
||||||
|
"group.color": "Gruppenfarbe",
|
||||||
|
"group.color_placeholder": "z. B. #f29b3f",
|
||||||
|
"group.delete_confirm": "Diese Gruppe löschen?",
|
||||||
|
"group.empty": "Noch keine Gruppen vorhanden.",
|
||||||
|
"group.edit": "Bearbeiten",
|
||||||
|
"group.delete": "Löschen",
|
||||||
|
"group.core_locked": "Kern-Gruppen können nicht geändert werden.",
|
||||||
"table.rows_per_page": "Zeilen pro Seite:",
|
"table.rows_per_page": "Zeilen pro Seite:",
|
||||||
"table.range_separator": "von",
|
"table.range_separator": "von",
|
||||||
"home.browse": "Foren durchsuchen",
|
"home.browse": "Foren durchsuchen",
|
||||||
@@ -136,10 +187,12 @@
|
|||||||
"portal.menu_rules": "Forenregeln",
|
"portal.menu_rules": "Forenregeln",
|
||||||
"portal.stats": "Statistik",
|
"portal.stats": "Statistik",
|
||||||
"portal.stat_threads": "Themen",
|
"portal.stat_threads": "Themen",
|
||||||
"portal.stat_forums": "Foren",
|
"portal.stat_users": "Benutzer",
|
||||||
|
"portal.stat_posts": "Beiträge",
|
||||||
"portal.latest_posts": "Aktuelle Beiträge",
|
"portal.latest_posts": "Aktuelle Beiträge",
|
||||||
"portal.empty_posts": "Noch keine Beiträge.",
|
"portal.empty_posts": "Noch keine Beiträge.",
|
||||||
"portal.topic": "Themen",
|
"portal.topic": "Themen",
|
||||||
|
"portal.posted_by": "Verfasst von",
|
||||||
"portal.forum_label": "Forum:",
|
"portal.forum_label": "Forum:",
|
||||||
"portal.unknown_forum": "Unbekannt",
|
"portal.unknown_forum": "Unbekannt",
|
||||||
"portal.user_menu": "Benutzer-Menü",
|
"portal.user_menu": "Benutzer-Menü",
|
||||||
@@ -148,12 +201,94 @@
|
|||||||
"portal.user_control_panel": "Benutzerkontrollzentrum",
|
"portal.user_control_panel": "Benutzerkontrollzentrum",
|
||||||
"portal.user_profile": "Profil",
|
"portal.user_profile": "Profil",
|
||||||
"portal.user_logout": "Logout",
|
"portal.user_logout": "Logout",
|
||||||
|
"portal.user_role_operator": "Operator",
|
||||||
|
"portal.user_role_moderator": "Moderator",
|
||||||
|
"portal.user_role_member": "Mitglied",
|
||||||
"portal.advertisement": "Werbung",
|
"portal.advertisement": "Werbung",
|
||||||
|
"profile.title": "Profil",
|
||||||
|
"profile.loading": "Profil wird geladen...",
|
||||||
|
"profile.registered": "Registriert:",
|
||||||
|
"profile.thanks_given": "Hat sich bedankt",
|
||||||
|
"profile.thanks_received": "Dank erhalten",
|
||||||
|
"profile.thanks_empty": "Noch keine Danksagungen.",
|
||||||
|
"profile.thanks_by": "Dank von",
|
||||||
|
"profile.thanks_for": "Dank für",
|
||||||
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
||||||
|
"ucp.profile": "Profil",
|
||||||
|
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
|
||||||
|
"ucp.avatar_label": "Profilbild",
|
||||||
|
"ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).",
|
||||||
|
"ucp.location_label": "Wohnort",
|
||||||
|
"ucp.location_hint": "Wird neben Deinen Beiträgen und im Profil angezeigt.",
|
||||||
|
"ucp.save_profile": "Profil speichern",
|
||||||
|
"ucp.profile_saved": "Profil gespeichert.",
|
||||||
"ucp.system_default": "Systemstandard",
|
"ucp.system_default": "Systemstandard",
|
||||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||||
"ucp.custom_color": "Eigene Farbe",
|
"ucp.custom_color": "Eigene Farbe",
|
||||||
|
"attachment.groups_title": "Anhanggruppen",
|
||||||
|
"attachment.settings_title": "Anhang-Einstellungen",
|
||||||
|
"attachment.display_images_inline": "Bilder inline anzeigen",
|
||||||
|
"attachment.create_thumbnails": "Vorschaubilder erstellen",
|
||||||
|
"attachment.thumbnail_max_width": "Maximale Vorschaubreite (px)",
|
||||||
|
"attachment.thumbnail_max_height": "Maximale Vorschaubildhöhe (px)",
|
||||||
|
"attachment.thumbnail_quality": "Vorschaubild-Qualität (JPEG/WebP)",
|
||||||
|
"attachment.group_create": "Neue Anhanggruppe",
|
||||||
|
"attachment.group_create_title": "Anhanggruppe erstellen",
|
||||||
|
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
|
||||||
|
"attachment.group_empty": "Noch keine Anhanggruppen.",
|
||||||
|
"attachment.seed_defaults": "Standard-Anhangset erstellen",
|
||||||
|
"attachment.seed_in_progress": "Standardwerte werden erstellt...",
|
||||||
|
"attachment.seed_hint": "Fügt eine Media- und Files-Struktur mit üblichen Endungen hinzu.",
|
||||||
|
"attachment.group_extensions": "{{count}} Endungen",
|
||||||
|
"attachment.group_delete_confirm": "Diese Anhanggruppe l\u00f6schen?",
|
||||||
|
"attachment.group_name": "Name",
|
||||||
|
"attachment.group_parent": "\u00dcbergeordnete Gruppe",
|
||||||
|
"attachment.group_parent_none": "Keine",
|
||||||
|
"attachment.group_max_size": "Max. Gr\u00f6\u00dfe (KB)",
|
||||||
|
"attachment.group_max_size_hint": "Standard 25600 KB (25 MB).",
|
||||||
|
"attachment.group_active": "Aktiv",
|
||||||
|
"attachment.group_add_child": "Untergruppe hinzuf\u00fcgen",
|
||||||
|
"attachment.group_auto_nest": "Standardgruppen automatisch verschachteln",
|
||||||
|
"attachment.group_auto_nest_hint": "Erstellt Media- und Files-Eltern und ordnet die Standardgruppen darunter ein.",
|
||||||
|
"attachment.extensions_title": "Dateiendungen verwalten",
|
||||||
|
"attachment.extension_placeholder": "Endung hinzuf\u00fcgen (z. B. pdf)",
|
||||||
|
"attachment.extension_mimes_placeholder": "Erlaubte MIME-Typen (kommagetrennt)",
|
||||||
|
"attachment.extension_unassigned": "Nicht zugewiesen",
|
||||||
|
"attachment.extension_add": "Endung hinzuf\u00fcgen",
|
||||||
|
"attachment.extension_edit": "Endung bearbeiten",
|
||||||
|
"attachment.extension_add_button": "Endung hinzuf\u00fcgen",
|
||||||
|
"attachment.extension_empty": "Noch keine Endungen.",
|
||||||
|
"attachment.extension": "Endung",
|
||||||
|
"attachment.extension_group": "Endungsgruppe",
|
||||||
|
"attachment.extension_delete_confirm": "Diese Endung l\u00f6schen?",
|
||||||
|
"attachment.actions": "Aktionen",
|
||||||
|
"attachment.allowed_mimes": "MIME-Typen:",
|
||||||
|
"attachment.active": "Aktiv",
|
||||||
|
"attachment.inactive": "Inaktiv",
|
||||||
|
"attachment.tab_options": "Optionen",
|
||||||
|
"attachment.tab_attachments": "Anh\u00e4nge",
|
||||||
|
"attachment.hint": "Wenn du Dateien anh\u00e4ngen m\u00f6chtest, f\u00fcge sie unten hinzu.",
|
||||||
|
"attachment.max_size": "Maximale Dateigr\u00f6\u00dfe pro Anhang: {{size}}.",
|
||||||
|
"attachment.add_files": "Dateien hinzuf\u00fcgen",
|
||||||
|
"attachment.drop_hint": "Sie k\u00f6nnen Dateien zum Hochladen hier hineinziehen oder",
|
||||||
|
"attachment.drop_browse": "Durchsuchen",
|
||||||
|
"attachment.filename": "Dateiname",
|
||||||
|
"attachment.size": "Gr\u00f6\u00dfe",
|
||||||
|
"attachment.status": "Status",
|
||||||
|
"attachment.empty": "Noch keine Dateien hinzugef\u00fcgt.",
|
||||||
|
"attachment.remove": "Datei entfernen",
|
||||||
|
"attachment.file_comment": "Dateikommentar",
|
||||||
|
"attachment.file_comment_placeholder": "Kommentar (optional)",
|
||||||
|
"attachment.place_inline": "Inline platzieren",
|
||||||
|
"attachment.delete_file": "Datei l\u00f6schen",
|
||||||
|
"attachment.option_disable_bbcode": "BBCode deaktivieren",
|
||||||
|
"attachment.option_disable_smilies": "Smileys deaktivieren",
|
||||||
|
"attachment.option_disable_auto_urls": "URLs nicht automatisch verlinken",
|
||||||
|
"attachment.option_attach_signature": "Signatur anh\u00e4ngen (über die UCP \u00e4nderbar)",
|
||||||
|
"attachment.option_notify_replies": "Bei Antworten benachrichtigen",
|
||||||
|
"attachment.option_lock_topic": "Thema sperren",
|
||||||
|
"attachment.invalid_extensions": "Nicht erlaubt: {{names}}.",
|
||||||
"thread.anonymous": "Anonym",
|
"thread.anonymous": "Anonym",
|
||||||
"thread.back_to_category": "Zurück zum Forum",
|
"thread.back_to_category": "Zurück zum Forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -161,7 +296,19 @@
|
|||||||
"thread.label": "Thread",
|
"thread.label": "Thread",
|
||||||
"thread.loading": "Thread wird geladen...",
|
"thread.loading": "Thread wird geladen...",
|
||||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||||
|
"thread.posts": "Beiträge",
|
||||||
|
"thread.location": "Wohnort",
|
||||||
|
"thread.thanks_given": "Hat sich bedankt",
|
||||||
|
"thread.thanks_received": "Dank erhalten",
|
||||||
|
"thread.thanks": "Danke",
|
||||||
|
"thread.reply_prefix": "Aw:",
|
||||||
|
"thread.registered": "Registriert",
|
||||||
"thread.replies": "Antworten",
|
"thread.replies": "Antworten",
|
||||||
|
"thread.solved": "Gel\u00f6st",
|
||||||
|
"thread.mark_solved": "Als gel\u00f6st markieren",
|
||||||
|
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
|
||||||
|
"thread.attachments": "Anh\u00e4nge",
|
||||||
|
"thread.attach_files": "Dateien anh\u00e4ngen",
|
||||||
"thread.views": "Zugriffe",
|
"thread.views": "Zugriffe",
|
||||||
"thread.last_post": "Letzter Beitrag",
|
"thread.last_post": "Letzter Beitrag",
|
||||||
"thread.by": "von",
|
"thread.by": "von",
|
||||||
|
|||||||
@@ -44,10 +44,23 @@
|
|||||||
"acp.show_header_name": "Display name in header",
|
"acp.show_header_name": "Display name in header",
|
||||||
"acp.add_category": "Add category",
|
"acp.add_category": "Add category",
|
||||||
"acp.add_forum": "Add forum",
|
"acp.add_forum": "Add forum",
|
||||||
|
"acp.ranks": "Ranks",
|
||||||
|
"acp.groups": "Groups",
|
||||||
|
"acp.attachments": "Attachments",
|
||||||
"acp.forums_parent_root": "Root (no parent)",
|
"acp.forums_parent_root": "Root (no parent)",
|
||||||
"acp.forums_tree": "Forum tree",
|
"acp.forums_tree": "Forum tree",
|
||||||
"acp.forums_type": "Type",
|
"acp.forums_type": "Type",
|
||||||
"acp.general": "General",
|
"acp.general": "General",
|
||||||
|
"acp.quick_access": "Quick access",
|
||||||
|
"acp.board_configuration": "Board configuration",
|
||||||
|
"acp.client_communication": "Client communication",
|
||||||
|
"acp.server_configuration": "Server configuration",
|
||||||
|
"acp.authentication": "Authentication",
|
||||||
|
"acp.email_settings": "Email settings",
|
||||||
|
"acp.security_settings": "Security settings",
|
||||||
|
"acp.search_settings": "Search settings",
|
||||||
|
"acp.welcome_title": "Welcome to speedBB",
|
||||||
|
"acp.general_settings": "General settings",
|
||||||
"acp.general_hint": "Global settings and board configuration will appear here.",
|
"acp.general_hint": "Global settings and board configuration will appear here.",
|
||||||
"acp.loading": "Loading...",
|
"acp.loading": "Loading...",
|
||||||
"acp.new_category": "New category",
|
"acp.new_category": "New category",
|
||||||
@@ -60,6 +73,8 @@
|
|||||||
"acp.users": "Users",
|
"acp.users": "Users",
|
||||||
"auth.login_hint": "Access your account to start new threads and reply.",
|
"auth.login_hint": "Access your account to start new threads and reply.",
|
||||||
"auth.login_title": "Log in",
|
"auth.login_title": "Log in",
|
||||||
|
"auth.login_identifier": "Email or username",
|
||||||
|
"auth.login_placeholder": "name@example.com or username",
|
||||||
"auth.register_hint": "Register with an email and a unique username.",
|
"auth.register_hint": "Register with an email and a unique username.",
|
||||||
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
||||||
"auth.register_title": "Create account",
|
"auth.register_title": "Create account",
|
||||||
@@ -73,6 +88,9 @@
|
|||||||
"form.password": "Password",
|
"form.password": "Password",
|
||||||
"form.post_reply": "Post reply",
|
"form.post_reply": "Post reply",
|
||||||
"form.posting": "Posting...",
|
"form.posting": "Posting...",
|
||||||
|
"form.preview": "Preview",
|
||||||
|
"form.upload": "Upload",
|
||||||
|
"form.uploading": "Uploading...",
|
||||||
"form.registering": "Registering...",
|
"form.registering": "Registering...",
|
||||||
"form.reply_placeholder": "Share your reply.",
|
"form.reply_placeholder": "Share your reply.",
|
||||||
"form.sign_in": "Sign in",
|
"form.sign_in": "Sign in",
|
||||||
@@ -87,7 +105,6 @@
|
|||||||
"forum.empty_threads": "No threads here yet. Start one below.",
|
"forum.empty_threads": "No threads here yet. Start one below.",
|
||||||
"forum.loading": "Loading forum...",
|
"forum.loading": "Loading forum...",
|
||||||
"forum.login_hint": "Log in to create a new thread.",
|
"forum.login_hint": "Log in to create a new thread.",
|
||||||
"forum.no_description": "No description added yet.",
|
|
||||||
"forum.only_forums": "Threads can only be created in forums.",
|
"forum.only_forums": "Threads can only be created in forums.",
|
||||||
"forum.open": "Open forum",
|
"forum.open": "Open forum",
|
||||||
"forum.collapse_category": "Collapse category",
|
"forum.collapse_category": "Collapse category",
|
||||||
@@ -99,11 +116,45 @@
|
|||||||
"user.id": "ID",
|
"user.id": "ID",
|
||||||
"user.name": "Name",
|
"user.name": "Name",
|
||||||
"user.email": "Email",
|
"user.email": "Email",
|
||||||
|
"user.rank": "Rank",
|
||||||
|
"user.rank_unassigned": "Unassigned",
|
||||||
|
"user.edit_title": "Edit user",
|
||||||
|
"user.search": "Search users...",
|
||||||
|
"rank.name": "Rank name",
|
||||||
|
"rank.name_placeholder": "e.g. Operator",
|
||||||
|
"rank.create": "Create rank",
|
||||||
|
"rank.create_title": "Create rank",
|
||||||
|
"rank.edit_title": "Edit rank",
|
||||||
|
"rank.badge_type": "Badge type",
|
||||||
|
"rank.badge_text": "Text badge",
|
||||||
|
"rank.badge_image": "Image badge",
|
||||||
|
"rank.badge_none": "No badge",
|
||||||
|
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
|
||||||
|
"rank.color": "Rank color",
|
||||||
|
"rank.color_placeholder": "e.g. #f29b3f",
|
||||||
|
"rank.color_default": "Use default color",
|
||||||
|
"rank.badge_text_required": "Badge text is required.",
|
||||||
|
"rank.badge_image_required": "Badge image is required.",
|
||||||
|
"rank.delete_confirm": "Delete this rank?",
|
||||||
|
"rank.empty": "No ranks created yet.",
|
||||||
"user.roles": "Roles",
|
"user.roles": "Roles",
|
||||||
"user.actions": "Actions",
|
"user.actions": "Actions",
|
||||||
"user.impersonate": "Impersonate",
|
"user.impersonate": "Impersonate",
|
||||||
"user.edit": "Edit",
|
"user.edit": "Edit",
|
||||||
"user.delete": "Delete",
|
"user.delete": "Delete",
|
||||||
|
"user.founder_locked": "Only founders can edit or assign the Founder role.",
|
||||||
|
"group.create": "Create group",
|
||||||
|
"group.create_title": "Create group",
|
||||||
|
"group.edit_title": "Edit group",
|
||||||
|
"group.name": "Group name",
|
||||||
|
"group.name_placeholder": "e.g. Founder",
|
||||||
|
"group.color": "Group color",
|
||||||
|
"group.color_placeholder": "e.g. #f29b3f",
|
||||||
|
"group.delete_confirm": "Delete this group?",
|
||||||
|
"group.empty": "No groups created yet.",
|
||||||
|
"group.edit": "Edit",
|
||||||
|
"group.delete": "Delete",
|
||||||
|
"group.core_locked": "Core groups cannot be changed.",
|
||||||
"table.rows_per_page": "Rows per page:",
|
"table.rows_per_page": "Rows per page:",
|
||||||
"table.range_separator": "of",
|
"table.range_separator": "of",
|
||||||
"home.browse": "Browse forums",
|
"home.browse": "Browse forums",
|
||||||
@@ -136,10 +187,12 @@
|
|||||||
"portal.menu_rules": "Forum rules",
|
"portal.menu_rules": "Forum rules",
|
||||||
"portal.stats": "Statistics",
|
"portal.stats": "Statistics",
|
||||||
"portal.stat_threads": "Threads",
|
"portal.stat_threads": "Threads",
|
||||||
"portal.stat_forums": "Forums",
|
"portal.stat_users": "Users",
|
||||||
|
"portal.stat_posts": "Posts",
|
||||||
"portal.latest_posts": "Latest posts",
|
"portal.latest_posts": "Latest posts",
|
||||||
"portal.empty_posts": "No posts yet.",
|
"portal.empty_posts": "No posts yet.",
|
||||||
"portal.topic": "Topics",
|
"portal.topic": "Topics",
|
||||||
|
"portal.posted_by": "Posted by",
|
||||||
"portal.forum_label": "Forum:",
|
"portal.forum_label": "Forum:",
|
||||||
"portal.unknown_forum": "Unknown",
|
"portal.unknown_forum": "Unknown",
|
||||||
"portal.user_menu": "User menu",
|
"portal.user_menu": "User menu",
|
||||||
@@ -148,12 +201,94 @@
|
|||||||
"portal.user_control_panel": "User Control Panel",
|
"portal.user_control_panel": "User Control Panel",
|
||||||
"portal.user_profile": "Profile",
|
"portal.user_profile": "Profile",
|
||||||
"portal.user_logout": "Logout",
|
"portal.user_logout": "Logout",
|
||||||
|
"portal.user_role_operator": "Operator",
|
||||||
|
"portal.user_role_moderator": "Moderator",
|
||||||
|
"portal.user_role_member": "Member",
|
||||||
"portal.advertisement": "Advertisement",
|
"portal.advertisement": "Advertisement",
|
||||||
|
"profile.title": "Profile",
|
||||||
|
"profile.loading": "Loading profile...",
|
||||||
|
"profile.registered": "Registered:",
|
||||||
|
"profile.thanks_given": "Thanks given",
|
||||||
|
"profile.thanks_received": "Thanks received",
|
||||||
|
"profile.thanks_empty": "No thanks yet.",
|
||||||
|
"profile.thanks_by": "Thanks by",
|
||||||
|
"profile.thanks_for": "Thanks for",
|
||||||
"ucp.intro": "Manage your basic preferences for the forum.",
|
"ucp.intro": "Manage your basic preferences for the forum.",
|
||||||
|
"ucp.profile": "Profile",
|
||||||
|
"ucp.profile_hint": "Update the avatar shown next to your posts.",
|
||||||
|
"ucp.avatar_label": "Profile image",
|
||||||
|
"ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).",
|
||||||
|
"ucp.location_label": "Location",
|
||||||
|
"ucp.location_hint": "Shown next to your posts and profile.",
|
||||||
|
"ucp.save_profile": "Save profile",
|
||||||
|
"ucp.profile_saved": "Profile saved.",
|
||||||
"ucp.system_default": "System default",
|
"ucp.system_default": "System default",
|
||||||
"ucp.accent_override": "Accent color override",
|
"ucp.accent_override": "Accent color override",
|
||||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||||
"ucp.custom_color": "Custom color",
|
"ucp.custom_color": "Custom color",
|
||||||
|
"attachment.groups_title": "Attachment groups",
|
||||||
|
"attachment.settings_title": "Attachment settings",
|
||||||
|
"attachment.display_images_inline": "Display images inline",
|
||||||
|
"attachment.create_thumbnails": "Create thumbnails",
|
||||||
|
"attachment.thumbnail_max_width": "Maximum thumbnail width (px)",
|
||||||
|
"attachment.thumbnail_max_height": "Maximum thumbnail height (px)",
|
||||||
|
"attachment.thumbnail_quality": "Thumbnail quality (JPEG/WebP)",
|
||||||
|
"attachment.group_create": "New attachment group",
|
||||||
|
"attachment.group_create_title": "Create attachment group",
|
||||||
|
"attachment.group_edit_title": "Edit attachment group",
|
||||||
|
"attachment.group_empty": "No attachment groups yet.",
|
||||||
|
"attachment.seed_defaults": "Create default attachment set",
|
||||||
|
"attachment.seed_in_progress": "Creating defaults...",
|
||||||
|
"attachment.seed_hint": "Adds a Media and Files hierarchy with common extensions.",
|
||||||
|
"attachment.group_extensions": "{{count}} extensions",
|
||||||
|
"attachment.group_delete_confirm": "Delete this attachment group?",
|
||||||
|
"attachment.group_name": "Name",
|
||||||
|
"attachment.group_parent": "Parent group",
|
||||||
|
"attachment.group_parent_none": "No parent",
|
||||||
|
"attachment.group_max_size": "Max size (KB)",
|
||||||
|
"attachment.group_max_size_hint": "Default 25600 KB (25 MB).",
|
||||||
|
"attachment.group_active": "Active",
|
||||||
|
"attachment.group_add_child": "Add child group",
|
||||||
|
"attachment.group_auto_nest": "Auto-nest default groups",
|
||||||
|
"attachment.group_auto_nest_hint": "Creates Media and Files parents and nests the default groups underneath.",
|
||||||
|
"attachment.extensions_title": "Manage attachment extensions",
|
||||||
|
"attachment.extension_placeholder": "Add extension (e.g. pdf)",
|
||||||
|
"attachment.extension_mimes_placeholder": "Allowed MIME types (comma-separated)",
|
||||||
|
"attachment.extension_unassigned": "Not assigned",
|
||||||
|
"attachment.extension_add": "Add extension",
|
||||||
|
"attachment.extension_edit": "Edit extension",
|
||||||
|
"attachment.extension_add_button": "Add extension",
|
||||||
|
"attachment.extension_empty": "No extensions yet.",
|
||||||
|
"attachment.extension": "Extension",
|
||||||
|
"attachment.extension_group": "Extension group",
|
||||||
|
"attachment.extension_delete_confirm": "Delete this extension?",
|
||||||
|
"attachment.actions": "Actions",
|
||||||
|
"attachment.allowed_mimes": "MIME types:",
|
||||||
|
"attachment.active": "Active",
|
||||||
|
"attachment.inactive": "Inactive",
|
||||||
|
"attachment.tab_options": "Options",
|
||||||
|
"attachment.tab_attachments": "Attachments",
|
||||||
|
"attachment.hint": "If you wish to attach one or more files enter the details below.",
|
||||||
|
"attachment.max_size": "Maximum filesize per attachment: {{size}}.",
|
||||||
|
"attachment.add_files": "Add files",
|
||||||
|
"attachment.drop_hint": "Drag files here to upload or",
|
||||||
|
"attachment.drop_browse": "Browse",
|
||||||
|
"attachment.filename": "Filename",
|
||||||
|
"attachment.size": "Size",
|
||||||
|
"attachment.status": "Status",
|
||||||
|
"attachment.empty": "No files added yet.",
|
||||||
|
"attachment.remove": "Remove file",
|
||||||
|
"attachment.file_comment": "File comment",
|
||||||
|
"attachment.file_comment_placeholder": "Comment (optional)",
|
||||||
|
"attachment.place_inline": "Place inline",
|
||||||
|
"attachment.delete_file": "Delete file",
|
||||||
|
"attachment.option_disable_bbcode": "Disable BBCode",
|
||||||
|
"attachment.option_disable_smilies": "Disable smilies",
|
||||||
|
"attachment.option_disable_auto_urls": "Do not automatically parse URLs",
|
||||||
|
"attachment.option_attach_signature": "Attach a signature (signatures can be altered via the UCP)",
|
||||||
|
"attachment.option_notify_replies": "Notify me when a reply is posted",
|
||||||
|
"attachment.option_lock_topic": "Lock topic",
|
||||||
|
"attachment.invalid_extensions": "Not allowed: {{names}}.",
|
||||||
"thread.anonymous": "Anonymous",
|
"thread.anonymous": "Anonymous",
|
||||||
"thread.back_to_category": "Back to forum",
|
"thread.back_to_category": "Back to forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -161,7 +296,19 @@
|
|||||||
"thread.label": "Thread",
|
"thread.label": "Thread",
|
||||||
"thread.loading": "Loading thread...",
|
"thread.loading": "Loading thread...",
|
||||||
"thread.login_hint": "Log in to reply to this thread.",
|
"thread.login_hint": "Log in to reply to this thread.",
|
||||||
|
"thread.posts": "Posts",
|
||||||
|
"thread.location": "Location",
|
||||||
|
"thread.thanks_given": "Thanks given",
|
||||||
|
"thread.thanks_received": "Thanks received",
|
||||||
|
"thread.thanks": "Thank",
|
||||||
|
"thread.reply_prefix": "Re:",
|
||||||
|
"thread.registered": "Registered",
|
||||||
"thread.replies": "Replies",
|
"thread.replies": "Replies",
|
||||||
|
"thread.solved": "Solved",
|
||||||
|
"thread.mark_solved": "Mark solved",
|
||||||
|
"thread.mark_unsolved": "Mark unsolved",
|
||||||
|
"thread.attachments": "Attachments",
|
||||||
|
"thread.attach_files": "Attach files",
|
||||||
"thread.views": "Views",
|
"thread.views": "Views",
|
||||||
"thread.last_post": "Last post",
|
"thread.last_post": "Last post",
|
||||||
"thread.by": "by",
|
"thread.by": "by",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@vite(['resources/js/main.jsx'])
|
@vite(['resources/js/main.jsx'])
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="top"></div>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
55
resources/views/installer-success.blade.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>speedBB Installed</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
background: #0b0f17;
|
||||||
|
color: #e6e8eb;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
background: rgba(18, 23, 33, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ff8a3d;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Installation complete</h1>
|
||||||
|
<p>Your forum is ready. You can now log in with the founder account.</p>
|
||||||
|
<a href="/">Go to forum</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
165
resources/views/installer.blade.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>speedBB Installer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
background: #0b0f17;
|
||||||
|
color: #e6e8eb;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
background: rgba(18, 23, 33, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(12, 16, 24, 0.9);
|
||||||
|
color: #e6e8eb;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.grid--wide {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: rgba(220, 80, 80, 0.15);
|
||||||
|
border: 1px solid rgba(220, 80, 80, 0.4);
|
||||||
|
color: #ffb4b4;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: #ff8a3d;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>speedBB Installer</h1>
|
||||||
|
<p>Provide database details and create the first founder/admin account.</p>
|
||||||
|
|
||||||
|
@if (!empty($error))
|
||||||
|
<div class="error">{{ $error }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="error">
|
||||||
|
<ul>
|
||||||
|
@foreach ($errors->all() as $message)
|
||||||
|
<li>{{ $message }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form method="POST" action="/install">
|
||||||
|
@csrf
|
||||||
|
<div class="section">
|
||||||
|
<h2>Application</h2>
|
||||||
|
<label for="app_url">App URL</label>
|
||||||
|
<input id="app_url" name="app_url" type="url" required value="{{ old('app_url', $old['app_url'] ?? $appUrl) }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database (MySQL/MariaDB)</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="db_host">Host</label>
|
||||||
|
<input id="db_host" name="db_host" required value="{{ old('db_host', $old['db_host'] ?? '127.0.0.1') }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="db_port">Port</label>
|
||||||
|
<input id="db_port" name="db_port" type="number" value="{{ old('db_port', $old['db_port'] ?? 3306) }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="db_database">Database</label>
|
||||||
|
<input id="db_database" name="db_database" required value="{{ old('db_database', $old['db_database'] ?? '') }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="db_username">Username</label>
|
||||||
|
<input id="db_username" name="db_username" required value="{{ old('db_username', $old['db_username'] ?? '') }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="db_password">Password</label>
|
||||||
|
<input id="db_password" name="db_password" type="password" value="{{ old('db_password', $old['db_password'] ?? '') }}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Founder Account</h2>
|
||||||
|
<div class="grid grid--wide">
|
||||||
|
<div>
|
||||||
|
<label for="admin_name">Username</label>
|
||||||
|
<input id="admin_name" name="admin_name" required value="{{ old('admin_name', $old['admin_name'] ?? '') }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="admin_email">Email</label>
|
||||||
|
<input id="admin_email" name="admin_email" type="email" required value="{{ old('admin_email', $old['admin_email'] ?? '') }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="admin_password">Password</label>
|
||||||
|
<input id="admin_password" name="admin_password" type="password" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Install speedBB</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AttachmentController;
|
||||||
|
use App\Http\Controllers\AttachmentExtensionController;
|
||||||
|
use App\Http\Controllers\AttachmentGroupController;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\ForumController;
|
use App\Http\Controllers\ForumController;
|
||||||
use App\Http\Controllers\I18nController;
|
use App\Http\Controllers\I18nController;
|
||||||
|
use App\Http\Controllers\PortalController;
|
||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
|
use App\Http\Controllers\PostThankController;
|
||||||
|
use App\Http\Controllers\PreviewController;
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Http\Controllers\StatsController;
|
||||||
use App\Http\Controllers\ThreadController;
|
use App\Http\Controllers\ThreadController;
|
||||||
use App\Http\Controllers\UploadController;
|
use App\Http\Controllers\UploadController;
|
||||||
use App\Http\Controllers\UserSettingController;
|
use App\Http\Controllers\UserSettingController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
use App\Http\Controllers\VersionController;
|
use App\Http\Controllers\VersionController;
|
||||||
|
use App\Http\Controllers\RankController;
|
||||||
|
use App\Http\Controllers\RoleController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
Route::post('/register', [AuthController::class, 'register']);
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('guest');
|
||||||
|
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('guest');
|
||||||
|
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
|
||||||
|
->middleware('signed')
|
||||||
|
->name('verification.verify');
|
||||||
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/version', VersionController::class);
|
Route::get('/version', VersionController::class);
|
||||||
|
Route::get('/portal/summary', PortalController::class);
|
||||||
|
Route::get('/stats', StatsController::class);
|
||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
||||||
@@ -24,8 +41,44 @@ Route::get('/user-settings', [UserSettingController::class, 'index'])->middlewar
|
|||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
|
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
||||||
Route::get('/i18n/{locale}', I18nController::class);
|
Route::get('/i18n/{locale}', I18nController::class);
|
||||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/users/{user}/rank', [UserController::class, 'updateRank'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/roles', [RoleController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/roles', [RoleController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/roles/{role}', [RoleController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/user/{user}/thanks/given', [PostThankController::class, 'given'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/user/{user}/thanks/received', [PostThankController::class, 'received'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachment-groups', [AttachmentGroupController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/attachment-groups', [AttachmentGroupController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/attachment-groups/reorder', [AttachmentGroupController::class, 'reorder'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachment-extensions', [AttachmentExtensionController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/attachment-extensions/public', [AttachmentExtensionController::class, 'publicIndex']);
|
||||||
|
Route::post('/attachment-extensions', [AttachmentExtensionController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/attachments', [AttachmentController::class, 'index']);
|
||||||
|
Route::post('/attachments', [AttachmentController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
|
||||||
|
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download']);
|
||||||
|
Route::get('/attachments/{attachment}/thumbnail', [AttachmentController::class, 'thumbnail']);
|
||||||
|
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/forums', [ForumController::class, 'index']);
|
Route::get('/forums', [ForumController::class, 'index']);
|
||||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||||
@@ -37,8 +90,12 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
|
|||||||
Route::get('/threads', [ThreadController::class, 'index']);
|
Route::get('/threads', [ThreadController::class, 'index']);
|
||||||
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
||||||
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
|
||||||
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/posts', [PostController::class, 'index']);
|
Route::get('/posts', [PostController::class, 'index']);
|
||||||
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum');
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\InstallerController;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::view('/', 'app');
|
Route::get('/install', [InstallerController::class, 'show']);
|
||||||
|
Route::post('/install', [InstallerController::class, 'store'])
|
||||||
|
->withoutMiddleware([VerifyCsrfToken::class]);
|
||||||
|
|
||||||
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
Route::get('/', function () {
|
||||||
|
if (!file_exists(base_path('.env'))) {
|
||||||
|
return redirect('/install');
|
||||||
|
}
|
||||||
|
return view('app');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/login', function () {
|
||||||
|
if (!file_exists(base_path('.env'))) {
|
||||||
|
return redirect('/install');
|
||||||
|
}
|
||||||
|
return view('app');
|
||||||
|
})->name('login');
|
||||||
|
|
||||||
|
Route::get('/reset-password', function () {
|
||||||
|
if (!file_exists(base_path('.env'))) {
|
||||||
|
return redirect('/install');
|
||||||
|
}
|
||||||
|
return view('app');
|
||||||
|
})->name('password.reset');
|
||||||
|
|
||||||
|
Route::get('/{any}', function () {
|
||||||
|
if (!file_exists(base_path('.env'))) {
|
||||||
|
return redirect('/install');
|
||||||
|
}
|
||||||
|
return view('app');
|
||||||
|
})->where('any', '^(?!api).*$');
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 389 B |
|
Before Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 2.1 KiB |