Compare commits
18 Commits
fix-replie
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2409feb06f | |||
| e3dcf99362 | |||
| 357f6fb755 | |||
| 2281b80980 | |||
| f23363fdcc | |||
| c1814c0d47 | |||
| 7489a3903d | |||
| b967aa912b | |||
| 67ae9517f4 | |||
| 653905d5e2 | |||
| bc893b644d | |||
| 662e00bec1 | |||
| a96913bffa | |||
| 79ac0cdca5 | |||
| fe4b7ccd7c | |||
| fc9de4c9fd | |||
| 6b6f787351 | |||
| d4fb86633b |
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
|
||||
2
.gitignore
vendored
@@ -21,6 +21,8 @@
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
|
||||
4
ansible/ansible.cfg
Normal file
@@ -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
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,18 @@ class AuthController extends Controller
|
||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||
{
|
||||
$input = [
|
||||
'name' => $request->input('name') ?? $request->input('username'),
|
||||
'email' => $request->input('email'),
|
||||
'password' => $request->input('password') ?? $request->input('plainPassword'),
|
||||
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
|
||||
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
|
||||
'email' => $request->input(key: 'email'),
|
||||
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
|
||||
'password_confirmation' => $request->input(key: 'password_confirmation')
|
||||
?? $request->input(key: 'plainPassword'),
|
||||
];
|
||||
|
||||
$user = $creator->create($input);
|
||||
$user = $creator->create(input: $input);
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
|
||||
return response()->json([
|
||||
return response()->json(data: [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'message' => 'Verification email sent.',
|
||||
@@ -41,87 +42,87 @@ class AuthController extends Controller
|
||||
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->merge([
|
||||
'login' => $request->input('login', $request->input('email')),
|
||||
$request->merge(input: [
|
||||
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
|
||||
]);
|
||||
|
||||
$request->validate([
|
||||
$request->validate(rules: [
|
||||
'login' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$login = trim((string) $request->input('login'));
|
||||
$loginNormalized = Str::lower($login);
|
||||
$login = trim(string: (string) $request->input(key: 'login'));
|
||||
$loginNormalized = Str::lower(value: $login);
|
||||
$userQuery = User::query();
|
||||
|
||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||
$userQuery->whereRaw('lower(email) = ?', [$loginNormalized]);
|
||||
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
|
||||
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
|
||||
} else {
|
||||
$userQuery->where('name_canonical', $loginNormalized);
|
||||
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
|
||||
}
|
||||
|
||||
$user = $userQuery->first();
|
||||
|
||||
if (!$user || !Hash::check($request->input('password'), $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
if (!$user || !Hash::check(value: $request->input(key: 'password'), hashedValue: $user->password)) {
|
||||
throw ValidationException::withMessages(messages: [
|
||||
'login' => ['Invalid credentials.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$user->hasVerifiedEmail()) {
|
||||
return response()->json([
|
||||
return response()->json(data : [
|
||||
'message' => 'Email not verified.',
|
||||
], 403);
|
||||
], status: 403);
|
||||
}
|
||||
|
||||
$token = $user->createToken('api')->plainTextToken;
|
||||
$token = $user->createToken(name: 'api')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
return response()->json(data: [
|
||||
'token' => $token,
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
'roles' => $user->roles()->pluck(column: 'name')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
$user = User::findOrFail(id: $id);
|
||||
|
||||
if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
|
||||
abort(403);
|
||||
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
|
||||
abort(code: 403);
|
||||
}
|
||||
|
||||
if (!$user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
event(new Verified(user: $user));
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
return redirect(to: '/login');
|
||||
}
|
||||
|
||||
public function forgotPassword(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
$request->validate(rules: [
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
$request->only(keys: 'email')
|
||||
);
|
||||
|
||||
if ($status !== Password::RESET_LINK_SENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
throw ValidationException::withMessages(messages: [
|
||||
'email' => [__(key: $status)],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => __($status)]);
|
||||
return response()->json(data: ['message' => __(key: $status)]);
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
$request->validate(rules: [
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => $this->passwordRules(),
|
||||
@@ -130,51 +131,51 @@ class AuthController extends Controller
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'remember_token' => Str::random(60),
|
||||
$user->forceFill(attributes: [
|
||||
'password' => Hash::make(value: $password),
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
event(new PasswordReset(user: $user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status !== Password::PASSWORD_RESET) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
throw ValidationException::withMessages(messages: [
|
||||
'email' => [__(key: $status)],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => __($status)]);
|
||||
return response()->json(data: ['message' => __(key: $status)]);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
$request->validate(rules: [
|
||||
'current_password' => ['required'],
|
||||
'password' => $this->passwordRules(),
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user || !Hash::check($request->input('current_password'), $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
|
||||
throw ValidationException::withMessages(messages: [
|
||||
'current_password' => ['Invalid current password.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->input('password')),
|
||||
'remember_token' => Str::random(60),
|
||||
$user->forceFill(attributes: [
|
||||
'password' => Hash::make(value: $request->input(key: 'password')),
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
return response()->json(['message' => 'Password updated.']);
|
||||
return response()->json(data: ['message' => 'Password updated.']);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()?->currentAccessToken()?->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
return response()->json(data: null, status: 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -14,31 +15,31 @@ class ForumController extends Controller
|
||||
{
|
||||
$query = Forum::query()
|
||||
->withoutTrashed()
|
||||
->withCount(['threads', 'posts'])
|
||||
->withSum('threads', 'views_count');
|
||||
->withCount(relations: ['threads', 'posts'])
|
||||
->withSum(relation: 'threads', column: 'views_count');
|
||||
|
||||
$parentParam = $request->query('parent');
|
||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$parentParam = $request->query(key: 'parent');
|
||||
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||
if ($exists === false) {
|
||||
$query->whereNull('parent_id');
|
||||
$query->whereNull(columns: 'parent_id');
|
||||
} elseif ($exists === true) {
|
||||
$query->whereNotNull('parent_id');
|
||||
$query->whereNotNull(columns: 'parent_id');
|
||||
}
|
||||
} elseif (is_string($parentParam)) {
|
||||
$parentId = $this->parseIriId($parentParam);
|
||||
} elseif (is_string(value: $parentParam)) {
|
||||
$parentId = $this->parseIriId(value: $parentParam);
|
||||
if ($parentId !== null) {
|
||||
$query->where('parent_id', $parentId);
|
||||
$query->where(column: 'parent_id', operator: $parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->query('type'));
|
||||
if ($request->filled(key: 'type')) {
|
||||
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||
}
|
||||
|
||||
$forums = $query
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->orderBy(column: 'position')
|
||||
->orderBy(column: 'name')
|
||||
->get();
|
||||
|
||||
$forumIds = $forums->pluck('id')->all();
|
||||
@@ -211,11 +212,13 @@ class ForumController extends Controller
|
||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||
'position' => $forum->position,
|
||||
'threads_count' => $forum->threads_count ?? 0,
|
||||
'posts_count' => $forum->posts_count ?? 0,
|
||||
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
|
||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -234,7 +237,7 @@ class ForumController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -256,8 +259,28 @@ class ForumController extends Controller
|
||||
->where('threads.forum_id', $forumId)
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->orderByDesc(column: 'posts.created_at')
|
||||
->with(relations: ['user.rank', 'user.roles'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy(callback: 'name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,9 @@ class PortalController extends Controller
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query->withCount(['posts', 'threads'])->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])
|
||||
->latest('created_at')
|
||||
->limit(12)
|
||||
@@ -43,7 +44,8 @@ class PortalController extends Controller
|
||||
|
||||
$stats = [
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count()
|
||||
+ Thread::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
];
|
||||
|
||||
@@ -62,7 +64,9 @@ class PortalController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
@@ -77,11 +81,13 @@ class PortalController extends Controller
|
||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||
'position' => $forum->position,
|
||||
'threads_count' => $forum->threads_count ?? 0,
|
||||
'posts_count' => $forum->posts_count ?? 0,
|
||||
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
|
||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||
'last_post_user_id' => $lastPost?->user_id,
|
||||
'last_post_user_name' => $lastPost?->user?->name,
|
||||
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
|
||||
'created_at' => $forum->created_at?->toIso8601String(),
|
||||
'updated_at' => $forum->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -93,15 +99,16 @@ class PortalController extends Controller
|
||||
'id' => $thread->id,
|
||||
'title' => $thread->title,
|
||||
'body' => $thread->body,
|
||||
'solved' => (bool) $thread->solved,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
'posts_count' => $thread->posts_count ?? 0,
|
||||
'posts_count' => ($thread->posts_count ?? 0) + 1,
|
||||
'views_count' => $thread->views_count ?? 0,
|
||||
'user_name' => $thread->user?->name,
|
||||
'user_avatar_url' => $thread->user?->avatar_path
|
||||
? Storage::url($thread->user->avatar_path)
|
||||
: null,
|
||||
'user_posts_count' => $thread->user?->posts_count,
|
||||
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
|
||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||
'user_rank_name' => $thread->user?->rank?->name,
|
||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||
@@ -109,12 +116,18 @@ class PortalController extends Controller
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
@@ -133,7 +146,7 @@ class PortalController extends Controller
|
||||
->whereNull('posts.deleted_at')
|
||||
->whereNull('threads.deleted_at')
|
||||
->orderByDesc('posts.created_at')
|
||||
->with('user')
|
||||
->with(['user.rank', 'user.roles'])
|
||||
->get();
|
||||
|
||||
$byForum = [];
|
||||
@@ -146,4 +159,24 @@ class PortalController extends Controller
|
||||
|
||||
return $byForum;
|
||||
}
|
||||
|
||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ class PostController extends Controller
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Post::query()->withoutTrashed()->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
$threadParam = $request->query('thread');
|
||||
@@ -49,7 +51,9 @@ class PostController extends Controller
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post), 201);
|
||||
@@ -92,17 +96,41 @@ class PostController extends Controller
|
||||
'user_avatar_url' => $post->user?->avatar_path
|
||||
? Storage::url($post->user->avatar_path)
|
||||
: null,
|
||||
'user_posts_count' => $post->user?->posts_count,
|
||||
'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0),
|
||||
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $post->user?->location,
|
||||
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $post->user?->rank?->name,
|
||||
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||
? Storage::url($post->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $post->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||
'created_at' => $post->created_at?->toIso8601String(),
|
||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class RankController extends Controller
|
||||
private function ensureAdmin(Request $request): ?JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(data: ['message' => 'Forbidden'], status: 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -29,6 +29,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
@@ -45,19 +46,24 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
|
||||
$rank = Rank::create([
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $data['color'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -65,6 +71,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => null,
|
||||
], 201);
|
||||
}
|
||||
@@ -77,16 +84,21 @@ class RankController extends Controller
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||
'badge_type' => ['nullable', 'in:text,image'],
|
||||
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||
]);
|
||||
|
||||
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||
$badgeText = $badgeType === 'text'
|
||||
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||
: null;
|
||||
if ($badgeType === 'none') {
|
||||
$badgeText = null;
|
||||
}
|
||||
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
|
||||
|
||||
if ($badgeType === 'text' && $rank->badge_image_path) {
|
||||
if ($badgeType !== 'image' && $rank->badge_image_path) {
|
||||
Storage::disk('public')->delete($rank->badge_image_path);
|
||||
$rank->badge_image_path = null;
|
||||
}
|
||||
@@ -95,6 +107,7 @@ class RankController extends Controller
|
||||
'name' => $data['name'],
|
||||
'badge_type' => $badgeType,
|
||||
'badge_text' => $badgeText,
|
||||
'color' => $color,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -102,6 +115,7 @@ class RankController extends Controller
|
||||
'name' => $rank->name,
|
||||
'badge_type' => $rank->badge_type,
|
||||
'badge_text' => $rank->badge_text,
|
||||
'color' => $rank->color,
|
||||
'badge_image_url' => $rank->badge_image_path
|
||||
? Storage::url($rank->badge_image_path)
|
||||
: null,
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ class StatsController extends Controller
|
||||
{
|
||||
return response()->json([
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count()
|
||||
+ Thread::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
@@ -15,9 +16,13 @@ class ThreadController extends Controller
|
||||
$query = Thread::query()
|
||||
->withoutTrashed()
|
||||
->withCount('posts')
|
||||
->withMax('posts', 'created_at')
|
||||
->with([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
]);
|
||||
|
||||
$forumParam = $request->query('forum');
|
||||
@@ -29,7 +34,7 @@ class ThreadController extends Controller
|
||||
}
|
||||
|
||||
$threads = $query
|
||||
->latest('created_at')
|
||||
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||
->get()
|
||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||
|
||||
@@ -41,8 +46,11 @@ class ThreadController extends Controller
|
||||
$thread->increment('views_count');
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
@@ -70,8 +78,11 @@ class ThreadController extends Controller
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
|
||||
'latestPost.user',
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread), 201);
|
||||
@@ -86,6 +97,36 @@ class ThreadController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function updateSolved(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin && $thread->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'solved' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$thread->solved = $data['solved'];
|
||||
$thread->save();
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
private function parseIriId(?string $value): ?int
|
||||
{
|
||||
if (!$value) {
|
||||
@@ -109,31 +150,60 @@ class ThreadController extends Controller
|
||||
'id' => $thread->id,
|
||||
'title' => $thread->title,
|
||||
'body' => $thread->body,
|
||||
'solved' => (bool) $thread->solved,
|
||||
'forum' => "/api/forums/{$thread->forum_id}",
|
||||
'user_id' => $thread->user_id,
|
||||
'posts_count' => $thread->posts_count ?? 0,
|
||||
'posts_count' => ($thread->posts_count ?? 0) + 1,
|
||||
'views_count' => $thread->views_count ?? 0,
|
||||
'user_name' => $thread->user?->name,
|
||||
'user_avatar_url' => $thread->user?->avatar_path
|
||||
? Storage::url($thread->user->avatar_path)
|
||||
: null,
|
||||
'user_posts_count' => $thread->user?->posts_count,
|
||||
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
|
||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||
'user_location' => $thread->user?->location,
|
||||
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
||||
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
|
||||
'user_rank_name' => $thread->user?->rank?->name,
|
||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
|
||||
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
|
||||
? Storage::url($thread->user->rank->badge_image_path)
|
||||
: null,
|
||||
'user_rank_color' => $thread->user?->rank?->color,
|
||||
'user_group_color' => $this->resolveGroupColor($thread->user),
|
||||
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
|
||||
?? $thread->created_at?->toIso8601String(),
|
||||
'last_post_id' => $thread->latestPost?->id,
|
||||
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
|
||||
'last_post_user_name' => $thread->latestPost?->user?->name
|
||||
?? $thread->user?->name,
|
||||
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
|
||||
?? $thread->user?->rank?->color,
|
||||
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
|
||||
?? $this->resolveGroupColor($thread->user),
|
||||
'created_at' => $thread->created_at?->toIso8601String(),
|
||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
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,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,7 +27,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
]);
|
||||
|
||||
@@ -50,7 +53,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -65,7 +70,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'created_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
@@ -101,7 +108,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -112,6 +121,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
@@ -127,7 +139,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -137,6 +151,9 @@ class UserController extends Controller
|
||||
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
if ($this->isFounder($user) && !$this->isFounder($actor)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
@@ -148,8 +165,18 @@ class UserController extends Controller
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'rank_id' => ['nullable', 'exists:ranks,id'],
|
||||
'roles' => ['nullable', 'array'],
|
||||
'roles.*' => ['string', 'exists:roles,name'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
|
||||
$requested = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name));
|
||||
if ($requested->contains('ROLE_FOUNDER')) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$nameCanonical = Str::lower(trim($data['name']));
|
||||
$nameConflict = User::query()
|
||||
->where('id', '!=', $user->id)
|
||||
@@ -171,6 +198,19 @@ class UserController extends Controller
|
||||
'rank_id' => $data['rank_id'] ?? null,
|
||||
])->save();
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$roleNames = collect($data['roles'] ?? [])
|
||||
->map(fn ($name) => $this->normalizeRoleName($name))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$roleIds = Role::query()
|
||||
->whereIn('name', $roleNames)
|
||||
->pluck('id')
|
||||
->all();
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
$user->loadMissing('rank');
|
||||
|
||||
return response()->json([
|
||||
@@ -181,7 +221,9 @@ class UserController extends Controller
|
||||
'rank' => $user->rank ? [
|
||||
'id' => $user->rank->id,
|
||||
'name' => $user->rank->name,
|
||||
'color' => $user->rank->color,
|
||||
] : null,
|
||||
'group_color' => $this->resolveGroupColor($user),
|
||||
'roles' => $user->roles()->pluck('name')->values(),
|
||||
]);
|
||||
}
|
||||
@@ -194,4 +236,42 @@ class UserController extends Controller
|
||||
|
||||
return Storage::url($user->avatar_path);
|
||||
}
|
||||
|
||||
private function resolveGroupColor(User $user): ?string
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
$roles = $user->roles;
|
||||
if (!$roles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($roles->sortBy('name') as $role) {
|
||||
if (!empty($role->color)) {
|
||||
return $role->color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeRoleName(string $value): string
|
||||
{
|
||||
$raw = strtoupper(trim($value));
|
||||
$raw = preg_replace('/\s+/', '_', $raw);
|
||||
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||
$raw = preg_replace('/_+/', '_', $raw);
|
||||
$raw = trim($raw, '_');
|
||||
if ($raw === '') {
|
||||
return 'ROLE_';
|
||||
}
|
||||
if (str_starts_with($raw, 'ROLE_')) {
|
||||
return $raw;
|
||||
}
|
||||
return "ROLE_{$raw}";
|
||||
}
|
||||
|
||||
private function isFounder(User $user): bool
|
||||
{
|
||||
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -45,4 +46,9 @@ class Post extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function thanks(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class Rank extends Model
|
||||
'badge_type',
|
||||
'badge_text',
|
||||
'badge_image_path',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
|
||||
@@ -25,6 +25,7 @@ class Role extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int|null $user_id
|
||||
* @property string $title
|
||||
* @property string $body
|
||||
* @property bool $solved
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Forum $forum
|
||||
@@ -41,6 +42,11 @@ class Thread extends Model
|
||||
'user_id',
|
||||
'title',
|
||||
'body',
|
||||
'solved',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'solved' => 'bool',
|
||||
];
|
||||
|
||||
public function forum(): BelongsTo
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||
@@ -106,6 +107,21 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function threads(): HasMany
|
||||
{
|
||||
return $this->hasMany(Thread::class);
|
||||
}
|
||||
|
||||
public function thanksGiven(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostThank::class);
|
||||
}
|
||||
|
||||
public function thanksReceived(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
|
||||
}
|
||||
|
||||
public function rank()
|
||||
{
|
||||
return $this->belongsTo(Rank::class);
|
||||
|
||||
@@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withCommands([
|
||||
__DIR__.'/../app/Console/Commands',
|
||||
])
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Allow the installer to run without a .env file.
|
||||
if (!file_exists(__DIR__.'/../.env')) {
|
||||
$tempKey = 'base64:'.base64_encode(random_bytes(32));
|
||||
$_ENV['APP_KEY'] = $tempKey;
|
||||
$_SERVER['APP_KEY'] = $tempKey;
|
||||
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||
$_SERVER['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = ':memory:';
|
||||
$_SERVER['DB_DATABASE'] = ':memory:';
|
||||
$_ENV['SESSION_DRIVER'] = 'array';
|
||||
$_SERVER['SESSION_DRIVER'] = 'array';
|
||||
$_ENV['SESSION_DOMAIN'] = null;
|
||||
$_SERVER['SESSION_DOMAIN'] = null;
|
||||
$_ENV['SESSION_SECURE_COOKIE'] = false;
|
||||
$_SERVER['SESSION_SECURE_COOKIE'] = false;
|
||||
}
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
@@ -218,11 +218,11 @@ function PortalHeader({
|
||||
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
|
||||
{index > 0 && <span className="bb-portal-sep">›</span>}
|
||||
{crumb.current ? (
|
||||
<span className="bb-portal-current">
|
||||
<Link to={crumb.to} className="bb-portal-current bb-portal-link">
|
||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
|
||||
{crumb.label}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={crumb.to} className="bb-portal-link">
|
||||
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
|
||||
@@ -426,7 +426,7 @@ function AppShell() {
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="bb-shell">
|
||||
<div className="bb-shell" id="top">
|
||||
<PortalHeader
|
||||
isAuthenticated={!!token}
|
||||
forumName={settings.forumName}
|
||||
|
||||
@@ -97,6 +97,14 @@ export async function getUserProfile(id) {
|
||||
return apiFetch(`/user/profile/${id}`)
|
||||
}
|
||||
|
||||
export async function listUserThanksGiven(id) {
|
||||
return apiFetch(`/user/${id}/thanks/given`)
|
||||
}
|
||||
|
||||
export async function listUserThanksReceived(id) {
|
||||
return apiFetch(`/user/${id}/thanks/received`)
|
||||
}
|
||||
|
||||
export async function fetchVersion() {
|
||||
return apiFetch('/version')
|
||||
}
|
||||
@@ -238,6 +246,16 @@ export async function getThread(id) {
|
||||
return apiFetch(`/threads/${id}`)
|
||||
}
|
||||
|
||||
export async function updateThreadSolved(threadId, solved) {
|
||||
return apiFetch(`/threads/${threadId}/solved`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify({ solved }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPostsByThread(threadId) {
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
}
|
||||
@@ -250,6 +268,30 @@ export async function listRanks() {
|
||||
return getCollection('/ranks')
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
return getCollection('/roles')
|
||||
}
|
||||
|
||||
export async function createRole(payload) {
|
||||
return apiFetch('/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateRole(roleId, payload) {
|
||||
return apiFetch(`/roles/${roleId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId) {
|
||||
return apiFetch(`/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateUserRank(userId, rankId) {
|
||||
return apiFetch(`/users/${userId}/rank`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -6,6 +6,14 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
const authorName = thread.user_name || t('thread.anonymous')
|
||||
const lastAuthorName = thread.last_post_user_name || authorName
|
||||
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
|
||||
const authorLinkColor = thread.user_rank_color || thread.user_group_color
|
||||
const authorLinkStyle = authorLinkColor
|
||||
? { '--bb-user-link-color': authorLinkColor }
|
||||
: undefined
|
||||
const lastAuthorLinkColor = thread.last_post_user_rank_color || thread.last_post_user_group_color
|
||||
const lastAuthorLinkStyle = lastAuthorLinkColor
|
||||
? { '--bb-user-link-color': lastAuthorLinkColor }
|
||||
: undefined
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—'
|
||||
@@ -20,6 +28,8 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const repliesCount = Math.max((thread.posts_count ?? 0) - 1, 0)
|
||||
|
||||
return (
|
||||
<div className="bb-portal-topic-row">
|
||||
<div className="bb-portal-topic-main">
|
||||
@@ -29,12 +39,19 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
<div>
|
||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||
{thread.title}
|
||||
{thread.solved && (
|
||||
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
|
||||
)}
|
||||
</Link>
|
||||
<div className="bb-portal-topic-meta">
|
||||
<div className="bb-portal-topic-meta-line">
|
||||
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
|
||||
{thread.user_id ? (
|
||||
<Link to={`/profile/${thread.user_id}`} className="bb-portal-topic-author">
|
||||
<Link
|
||||
to={`/profile/${thread.user_id}`}
|
||||
className="bb-portal-topic-author"
|
||||
style={authorLinkStyle}
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -60,14 +77,18 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell">{repliesCount}</div>
|
||||
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
||||
<div className="bb-portal-last">
|
||||
<span className="bb-portal-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{thread.last_post_user_id ? (
|
||||
<Link to={`/profile/${thread.last_post_user_id}`} className="bb-portal-last-user">
|
||||
<Link
|
||||
to={`/profile/${thread.last_post_user_id}`}
|
||||
className="bb-portal-last-user"
|
||||
style={lastAuthorLinkStyle}
|
||||
>
|
||||
{lastAuthorName}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -105,6 +105,40 @@ a {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bb-thread-solved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--bb-accent, #f29b3f);
|
||||
color: #0b0f17;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0.45rem;
|
||||
}
|
||||
|
||||
.bb-thread-solved-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-thread-solved-toggle:hover,
|
||||
.bb-thread-solved-toggle:focus {
|
||||
background: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
color: #0b0f17;
|
||||
}
|
||||
|
||||
.bb-thread-meta {
|
||||
@@ -360,18 +394,41 @@ a {
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-post-footer .bb-post-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.bb-post-action:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
border-color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-post-action--round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bb-post-content {
|
||||
position: relative;
|
||||
padding-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.bb-post-body {
|
||||
white-space: pre-wrap;
|
||||
color: var(--bb-ink);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bb-post-footer {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bb-thread-reply {
|
||||
border: 1px solid var(--bb-border);
|
||||
border-radius: 16px;
|
||||
@@ -1194,12 +1251,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-board-last-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-board-last-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1361,6 +1418,45 @@ a {
|
||||
color: var(--bb-ink);
|
||||
}
|
||||
|
||||
.bb-profile-thanks {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bb-profile-thanks-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.6rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bb-profile-thanks-item {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-profile-thanks-item a {
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-profile-thanks-item a:hover {
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bb-profile-thanks-meta {
|
||||
color: var(--bb-ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bb-profile-thanks-date {
|
||||
color: var(--bb-ink-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bb-portal-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -1474,12 +1570,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-topic-author {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-topic-author:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1516,12 +1612,12 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-last-user {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-portal-last-user:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1572,11 +1668,11 @@ a {
|
||||
}
|
||||
|
||||
.bb-portal-user-name-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
}
|
||||
|
||||
.bb-portal-user-name-link:hover {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-user-link-color, var(--bb-accent, #f29b3f));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1830,6 +1926,15 @@ a {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bb-rank-color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bb-rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1863,6 +1968,146 @@ a {
|
||||
.bb-rank-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-multiselect {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bb-multiselect__control {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(14, 18, 27, 0.6);
|
||||
color: var(--bb-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bb-multiselect__control:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bb-multiselect__value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-multiselect__placeholder {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-multiselect__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bb-multiselect__chip-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bb-multiselect__chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bb-multiselect__caret {
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-multiselect__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(12, 16, 24, 0.95);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bb-multiselect__search {
|
||||
padding: 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bb-multiselect__search input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(16, 20, 28, 0.8);
|
||||
color: var(--bb-ink);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.bb-multiselect__options {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bb-multiselect__option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bb-ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bb-multiselect__option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bb-btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bb-multiselect__option:hover,
|
||||
.bb-multiselect__option.is-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bb-multiselect__option-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bb-multiselect__empty {
|
||||
padding: 0.75rem;
|
||||
color: var(--bb-ink-muted);
|
||||
}
|
||||
|
||||
.bb-user-search {
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab,
|
||||
import DataTable, { createTheme } from 'react-data-table-component'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
createForum,
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listRoles,
|
||||
listUsers,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
@@ -18,6 +20,9 @@ import {
|
||||
updateUserRank,
|
||||
updateRank,
|
||||
updateUser,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
uploadRankBadgeImage,
|
||||
uploadFavicon,
|
||||
uploadLogo,
|
||||
@@ -26,6 +31,8 @@ import {
|
||||
|
||||
export default function Acp({ isAdmin }) {
|
||||
const { t } = useTranslation()
|
||||
const { roles: authRoles } = useAuth()
|
||||
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||||
const [forums, setForums] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -48,8 +55,10 @@ export default function Acp({ isAdmin }) {
|
||||
const [rankFormName, setRankFormName] = useState('')
|
||||
const [rankFormType, setRankFormType] = useState('text')
|
||||
const [rankFormText, setRankFormText] = useState('')
|
||||
const [rankFormColor, setRankFormColor] = useState('')
|
||||
const [rankFormImage, setRankFormImage] = useState(null)
|
||||
const [rankSaving, setRankSaving] = useState(false)
|
||||
const [showRankCreate, setShowRankCreate] = useState(false)
|
||||
const [showRankModal, setShowRankModal] = useState(false)
|
||||
const [rankEdit, setRankEdit] = useState({
|
||||
id: null,
|
||||
@@ -57,10 +66,29 @@ export default function Acp({ isAdmin }) {
|
||||
badgeType: 'text',
|
||||
badgeText: '',
|
||||
badgeImageUrl: '',
|
||||
color: '',
|
||||
})
|
||||
const [rankEditImage, setRankEditImage] = useState(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
|
||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
|
||||
const [roleQuery, setRoleQuery] = useState('')
|
||||
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
|
||||
const roleMenuRef = useRef(null)
|
||||
const [roles, setRoles] = useState([])
|
||||
const [rolesLoading, setRolesLoading] = useState(false)
|
||||
const [rolesError, setRolesError] = useState('')
|
||||
const [roleFormName, setRoleFormName] = useState('')
|
||||
const [roleFormColor, setRoleFormColor] = useState('')
|
||||
const [roleSaving, setRoleSaving] = useState(false)
|
||||
const [showRoleCreate, setShowRoleCreate] = useState(false)
|
||||
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||
const [roleEdit, setRoleEdit] = useState({
|
||||
id: null,
|
||||
name: '',
|
||||
originalName: '',
|
||||
color: '',
|
||||
isCore: false,
|
||||
})
|
||||
const [userSaving, setUserSaving] = useState(false)
|
||||
const [generalSaving, setGeneralSaving] = useState(false)
|
||||
const [generalUploading, setGeneralUploading] = useState(false)
|
||||
@@ -473,15 +501,22 @@ export default function Acp({ isAdmin }) {
|
||||
>
|
||||
<i className="bi bi-person-badge" aria-hidden="true" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const editLocked = (row.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
|
||||
return (
|
||||
<Button
|
||||
variant="dark"
|
||||
title={t('user.edit')}
|
||||
title={editLocked ? t('user.founder_locked') : t('user.edit')}
|
||||
aria-disabled={editLocked}
|
||||
className={editLocked ? 'bb-btn-disabled' : undefined}
|
||||
onClick={() => {
|
||||
if (editLocked) return
|
||||
setUserForm({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
rankId: row.rank?.id ?? '',
|
||||
roles: row.roles || [],
|
||||
})
|
||||
setShowUserModal(true)
|
||||
setUsersError('')
|
||||
@@ -489,6 +524,8 @@ export default function Acp({ isAdmin }) {
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
<Button
|
||||
variant="dark"
|
||||
title={t('user.delete')}
|
||||
@@ -652,6 +689,39 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roleMenuOpen) return
|
||||
const handleClick = (event) => {
|
||||
if (!roleMenuRef.current) return
|
||||
if (!roleMenuRef.current.contains(event.target)) {
|
||||
setRoleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [roleMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
let active = true
|
||||
const loadRoles = async () => {
|
||||
setRolesLoading(true)
|
||||
setRolesError('')
|
||||
try {
|
||||
const data = await listRoles()
|
||||
if (active) setRoles(data)
|
||||
} catch (err) {
|
||||
if (active) setRolesError(err.message)
|
||||
} finally {
|
||||
if (active) setRolesLoading(false)
|
||||
}
|
||||
}
|
||||
loadRoles()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshRanks = async () => {
|
||||
setRanksLoading(true)
|
||||
setRanksError('')
|
||||
@@ -672,7 +742,9 @@ export default function Acp({ isAdmin }) {
|
||||
}, [isAdmin])
|
||||
|
||||
const handleCreateRank = async (event) => {
|
||||
if (event?.preventDefault) {
|
||||
event.preventDefault()
|
||||
}
|
||||
if (!rankFormName.trim()) return
|
||||
if (rankFormType === 'image' && !rankFormImage) {
|
||||
setRanksError(t('rank.badge_image_required'))
|
||||
@@ -685,6 +757,7 @@ export default function Acp({ isAdmin }) {
|
||||
name: rankFormName.trim(),
|
||||
badge_type: rankFormType,
|
||||
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
|
||||
color: rankFormColor.trim() || null,
|
||||
})
|
||||
let next = created
|
||||
if (rankFormType === 'image' && rankFormImage) {
|
||||
@@ -695,7 +768,9 @@ export default function Acp({ isAdmin }) {
|
||||
setRankFormName('')
|
||||
setRankFormType('text')
|
||||
setRankFormText('')
|
||||
setRankFormColor('')
|
||||
setRankFormImage(null)
|
||||
setShowRankCreate(false)
|
||||
} catch (err) {
|
||||
setRanksError(err.message)
|
||||
} finally {
|
||||
@@ -703,6 +778,46 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}
|
||||
|
||||
const isCoreRole = (name) => name === 'ROLE_ADMIN' || name === 'ROLE_USER' || name === 'ROLE_FOUNDER'
|
||||
|
||||
const formatRoleLabel = (name) => {
|
||||
if (!name) return ''
|
||||
const withoutPrefix = name.startsWith('ROLE_') ? name.slice(5) : name
|
||||
return withoutPrefix
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
const query = roleQuery.trim().toLowerCase()
|
||||
if (!query) return roles
|
||||
return roles.filter((role) =>
|
||||
formatRoleLabel(role.name).toLowerCase().includes(query)
|
||||
)
|
||||
}, [roles, roleQuery])
|
||||
|
||||
const handleCreateRole = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!roleFormName.trim()) return
|
||||
setRoleSaving(true)
|
||||
setRolesError('')
|
||||
try {
|
||||
const created = await createRole({
|
||||
name: roleFormName.trim(),
|
||||
color: roleFormColor.trim() || null,
|
||||
})
|
||||
setRoles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
setRoleFormName('')
|
||||
setRoleFormColor('')
|
||||
setShowRoleCreate(false)
|
||||
} catch (err) {
|
||||
setRolesError(err.message)
|
||||
} finally {
|
||||
setRoleSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getParentId = (forum) => {
|
||||
if (!forum.parent) return null
|
||||
if (typeof forum.parent === 'string') {
|
||||
@@ -1505,76 +1620,108 @@ export default function Acp({ isAdmin }) {
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Row className="g-3 align-items-end mb-3">
|
||||
<Col md={6}>
|
||||
<Form onSubmit={handleCreateRank}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormName}
|
||||
onChange={(event) => setRankFormName(event.target.value)}
|
||||
placeholder={t('rank.name_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-text"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankFormType === 'text'}
|
||||
onChange={() => setRankFormType('text')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-image"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankFormType === 'image'}
|
||||
onChange={() => setRankFormType('image')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankFormType === 'text' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormText}
|
||||
onChange={(event) => setRankFormText(event.target.value)}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankFormType === 'image' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form>
|
||||
</Col>
|
||||
<Col md="auto">
|
||||
<Tab eventKey="groups" title={t('acp.groups')}>
|
||||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="bb-accent-button"
|
||||
onClick={handleCreateRank}
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
onClick={() => setShowRoleCreate(true)}
|
||||
>
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
{t('group.create')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
{rolesLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!rolesLoading && roles.length === 0 && (
|
||||
<p className="bb-muted">{t('group.empty')}</p>
|
||||
)}
|
||||
{!rolesLoading && roles.length > 0 && (
|
||||
<div className="bb-rank-list">
|
||||
{roles.map((role) => {
|
||||
const coreRole = isCoreRole(role.name)
|
||||
return (
|
||||
<div key={role.id} className="bb-rank-row">
|
||||
<div className="bb-rank-main">
|
||||
<span className="d-flex align-items-center gap-2">
|
||||
{role.color && (
|
||||
<span
|
||||
className="bb-rank-color"
|
||||
style={{ backgroundColor: role.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{formatRoleLabel(role.name)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
title={coreRole ? t('group.core_locked') : t('group.edit')}
|
||||
onClick={() => {
|
||||
setRoleEdit({
|
||||
id: role.id,
|
||||
name: formatRoleLabel(role.name),
|
||||
originalName: role.name,
|
||||
color: role.color || '',
|
||||
isCore: coreRole,
|
||||
})
|
||||
setShowRoleModal(true)
|
||||
setRolesError('')
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
disabled={coreRole}
|
||||
title={coreRole ? t('group.core_locked') : t('group.delete')}
|
||||
onClick={async () => {
|
||||
if (coreRole) return
|
||||
if (!window.confirm(t('group.delete_confirm'))) return
|
||||
setRoleSaving(true)
|
||||
setRolesError('')
|
||||
try {
|
||||
await deleteRole(role.id)
|
||||
setRoles((prev) =>
|
||||
prev.filter((item) => item.id !== role.id)
|
||||
)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => ({
|
||||
...user,
|
||||
roles: (user.roles || []).filter(
|
||||
(name) => name !== role.name
|
||||
),
|
||||
}))
|
||||
)
|
||||
} catch (err) {
|
||||
setRolesError(err.message)
|
||||
} finally {
|
||||
setRoleSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-trash" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="ranks" title={t('acp.ranks')}>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="bb-accent-button"
|
||||
onClick={() => setShowRankCreate(true)}
|
||||
>
|
||||
{t('rank.create')}
|
||||
</Button>
|
||||
</div>
|
||||
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!ranksLoading && ranks.length === 0 && (
|
||||
<p className="bb-muted">{t('rank.empty')}</p>
|
||||
@@ -1584,15 +1731,24 @@ export default function Acp({ isAdmin }) {
|
||||
{ranks.map((rank) => (
|
||||
<div key={rank.id} className="bb-rank-row">
|
||||
<div className="bb-rank-main">
|
||||
<span className="d-flex align-items-center gap-2">
|
||||
{rank.color && (
|
||||
<span
|
||||
className="bb-rank-color"
|
||||
style={{ backgroundColor: rank.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{rank.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
{rank.badge_type === 'image' && rank.badge_image_url && (
|
||||
<img src={rank.badge_image_url} alt="" />
|
||||
)}
|
||||
{rank.badge_type !== 'image' && rank.badge_text && (
|
||||
<span className="bb-rank-badge">{rank.badge_text}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-rank-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
@@ -1603,6 +1759,7 @@ export default function Acp({ isAdmin }) {
|
||||
badgeType: rank.badge_type || 'text',
|
||||
badgeText: rank.badge_text || '',
|
||||
badgeImageUrl: rank.badge_image_url || '',
|
||||
color: rank.color || '',
|
||||
})
|
||||
setRankEditImage(null)
|
||||
setShowRankModal(true)
|
||||
@@ -1736,6 +1893,10 @@ export default function Acp({ isAdmin }) {
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder) {
|
||||
setUsersError(t('user.founder_locked'))
|
||||
return
|
||||
}
|
||||
setUserSaving(true)
|
||||
setUsersError('')
|
||||
try {
|
||||
@@ -1744,6 +1905,9 @@ export default function Acp({ isAdmin }) {
|
||||
email: userForm.email,
|
||||
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
|
||||
}
|
||||
if (roles.length) {
|
||||
payload.roles = userForm.roles || []
|
||||
}
|
||||
const updated = await updateUser(userForm.id, payload)
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
@@ -1758,6 +1922,9 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder && (
|
||||
<p className="text-danger">{t('user.founder_locked')}</p>
|
||||
)}
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
@@ -1766,6 +1933,7 @@ export default function Acp({ isAdmin }) {
|
||||
setUserForm((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
required
|
||||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
@@ -1777,6 +1945,7 @@ export default function Acp({ isAdmin }) {
|
||||
setUserForm((prev) => ({ ...prev, email: event.target.value }))
|
||||
}
|
||||
required
|
||||
disabled={(userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
@@ -1786,7 +1955,7 @@ export default function Acp({ isAdmin }) {
|
||||
onChange={(event) =>
|
||||
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
|
||||
}
|
||||
disabled={ranksLoading}
|
||||
disabled={ranksLoading || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||||
>
|
||||
<option value="">{t('user.rank_unassigned')}</option>
|
||||
{ranks.map((rank) => (
|
||||
@@ -1796,6 +1965,120 @@ export default function Acp({ isAdmin }) {
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('user.roles')}</Form.Label>
|
||||
<div className="bb-multiselect" ref={roleMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="bb-multiselect__control"
|
||||
onClick={() => setRoleMenuOpen((prev) => !prev)}
|
||||
disabled={!roles.length || ((userForm.roles || []).includes('ROLE_FOUNDER') && !canManageFounder)}
|
||||
>
|
||||
<span className="bb-multiselect__value">
|
||||
{(userForm.roles || []).length === 0 && (
|
||||
<span className="bb-multiselect__placeholder">
|
||||
{t('user.roles')}
|
||||
</span>
|
||||
)}
|
||||
{(userForm.roles || []).map((roleName) => {
|
||||
const role = roles.find((item) => item.name === roleName)
|
||||
return (
|
||||
<span key={roleName} className="bb-multiselect__chip">
|
||||
{role?.color && (
|
||||
<span
|
||||
className="bb-multiselect__chip-color"
|
||||
style={{ backgroundColor: role.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{formatRoleLabel(roleName)}
|
||||
{!(roleName === 'ROLE_FOUNDER' && !canManageFounder) && (
|
||||
<span
|
||||
className="bb-multiselect__chip-remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setUserForm((prev) => ({
|
||||
...prev,
|
||||
roles: (prev.roles || []).filter(
|
||||
(name) => name !== roleName
|
||||
),
|
||||
}))
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className="bb-multiselect__caret" aria-hidden="true">
|
||||
<i className="bi bi-chevron-down" />
|
||||
</span>
|
||||
</button>
|
||||
{roleMenuOpen && (
|
||||
<div className="bb-multiselect__menu">
|
||||
<div className="bb-multiselect__search">
|
||||
<input
|
||||
type="text"
|
||||
value={roleQuery}
|
||||
onChange={(event) => setRoleQuery(event.target.value)}
|
||||
placeholder={t('user.search')}
|
||||
/>
|
||||
</div>
|
||||
<div className="bb-multiselect__options">
|
||||
{filteredRoles.length === 0 && (
|
||||
<div className="bb-multiselect__empty">
|
||||
{t('rank.empty')}
|
||||
</div>
|
||||
)}
|
||||
{filteredRoles.map((role) => {
|
||||
const isSelected = (userForm.roles || []).includes(role.name)
|
||||
const isFounderRole = role.name === 'ROLE_FOUNDER'
|
||||
const isLocked = isFounderRole && !canManageFounder
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={role.id}
|
||||
className={`bb-multiselect__option ${isSelected ? 'is-selected' : ''}`}
|
||||
onClick={() =>
|
||||
setUserForm((prev) => {
|
||||
if (isLocked) {
|
||||
return prev
|
||||
}
|
||||
const next = new Set(prev.roles || [])
|
||||
if (next.has(role.name)) {
|
||||
next.delete(role.name)
|
||||
} else {
|
||||
next.add(role.name)
|
||||
}
|
||||
return { ...prev, roles: Array.from(next) }
|
||||
})
|
||||
}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<span className="bb-multiselect__option-main">
|
||||
{role.color && (
|
||||
<span
|
||||
className="bb-multiselect__chip-color"
|
||||
style={{ backgroundColor: role.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{formatRoleLabel(role.name)}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<i className="bi bi-check-lg" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1812,6 +2095,145 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRoleModal}
|
||||
onHide={() => setShowRoleModal(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('group.edit_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||
<Form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (!roleEdit.name.trim()) return
|
||||
setRoleSaving(true)
|
||||
setRolesError('')
|
||||
try {
|
||||
const updated = await updateRole(roleEdit.id, {
|
||||
name: roleEdit.name.trim(),
|
||||
color: roleEdit.color.trim() || null,
|
||||
})
|
||||
setRoles((prev) =>
|
||||
prev
|
||||
.map((item) => (item.id === updated.id ? updated : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
if (roleEdit.originalName && roleEdit.originalName !== updated.name) {
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => ({
|
||||
...user,
|
||||
roles: (user.roles || []).map((name) =>
|
||||
name === roleEdit.originalName ? updated.name : name
|
||||
),
|
||||
}))
|
||||
)
|
||||
}
|
||||
setShowRoleModal(false)
|
||||
} catch (err) {
|
||||
setRolesError(err.message)
|
||||
} finally {
|
||||
setRoleSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('group.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={roleEdit.name}
|
||||
onChange={(event) =>
|
||||
setRoleEdit((prev) => ({ ...prev, name: event.target.value }))
|
||||
}
|
||||
disabled={roleEdit.isCore}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('group.color')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
value={roleEdit.color}
|
||||
onChange={(event) =>
|
||||
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||
}
|
||||
placeholder={t('group.color_placeholder')}
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={roleEdit.color || '#f29b3f'}
|
||||
onChange={(event) =>
|
||||
setRoleEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowRoleModal(false)}
|
||||
disabled={roleSaving}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" disabled={roleSaving}>
|
||||
{roleSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRoleCreate}
|
||||
onHide={() => setShowRoleCreate(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('group.create_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{rolesError && <p className="text-danger">{rolesError}</p>}
|
||||
<Form onSubmit={handleCreateRole}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('group.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={roleFormName}
|
||||
onChange={(event) => setRoleFormName(event.target.value)}
|
||||
placeholder={t('group.name_placeholder')}
|
||||
disabled={roleSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('group.color')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
value={roleFormColor}
|
||||
onChange={(event) => setRoleFormColor(event.target.value)}
|
||||
placeholder={t('group.color_placeholder')}
|
||||
disabled={roleSaving}
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={roleFormColor || '#f29b3f'}
|
||||
onChange={(event) => setRoleFormColor(event.target.value)}
|
||||
disabled={roleSaving}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bb-accent-button"
|
||||
disabled={roleSaving || !roleFormName.trim()}
|
||||
>
|
||||
{roleSaving ? t('form.saving') : t('group.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankModal}
|
||||
onHide={() => setShowRankModal(false)}
|
||||
@@ -1840,6 +2262,7 @@ export default function Acp({ isAdmin }) {
|
||||
rankEdit.badgeType === 'text'
|
||||
? rankEdit.badgeText.trim() || rankEdit.name.trim()
|
||||
: null,
|
||||
color: rankEdit.color.trim() || null,
|
||||
})
|
||||
let next = updated
|
||||
if (rankEdit.badgeType === 'image' && rankEditImage) {
|
||||
@@ -1869,9 +2292,54 @@ export default function Acp({ isAdmin }) {
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.color')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
id="rank-edit-color-default"
|
||||
label={t('rank.color_default')}
|
||||
checked={!rankEdit.color}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({
|
||||
...prev,
|
||||
color: event.target.checked ? '' : prev.color || '#f29b3f',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
value={rankEdit.color}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||
}
|
||||
placeholder={t('rank.color_placeholder')}
|
||||
disabled={!rankEdit.color}
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={rankEdit.color || '#f29b3f'}
|
||||
onChange={(event) =>
|
||||
setRankEdit((prev) => ({ ...prev, color: event.target.value }))
|
||||
}
|
||||
disabled={!rankEdit.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3">
|
||||
<div className="d-flex gap-3 flex-wrap">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-none"
|
||||
name="rankEditBadgeType"
|
||||
label={t('rank.badge_none')}
|
||||
checked={rankEdit.badgeType === 'none'}
|
||||
onChange={() =>
|
||||
setRankEdit((prev) => ({ ...prev, badgeType: 'none' }))
|
||||
}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-edit-badge-text"
|
||||
@@ -1938,6 +2406,118 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankCreate}
|
||||
onHide={() => setShowRankCreate(false)}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('rank.create_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{ranksError && <p className="text-danger">{ranksError}</p>}
|
||||
<Form onSubmit={handleCreateRank}>
|
||||
<Form.Group>
|
||||
<Form.Label>{t('rank.name')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormName}
|
||||
onChange={(event) => setRankFormName(event.target.value)}
|
||||
placeholder={t('rank.name_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.color')}</Form.Label>
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
id="rank-create-color-default"
|
||||
label={t('rank.color_default')}
|
||||
checked={!rankFormColor}
|
||||
onChange={(event) =>
|
||||
setRankFormColor(event.target.checked ? '' : '#f29b3f')
|
||||
}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
value={rankFormColor}
|
||||
onChange={(event) => setRankFormColor(event.target.value)}
|
||||
placeholder={t('rank.color_placeholder')}
|
||||
disabled={rankSaving || !rankFormColor}
|
||||
/>
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={rankFormColor || '#f29b3f'}
|
||||
onChange={(event) => setRankFormColor(event.target.value)}
|
||||
disabled={rankSaving || !rankFormColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_type')}</Form.Label>
|
||||
<div className="d-flex gap-3 flex-wrap">
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-none"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_none')}
|
||||
checked={rankFormType === 'none'}
|
||||
onChange={() => setRankFormType('none')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-text"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_text')}
|
||||
checked={rankFormType === 'text'}
|
||||
onChange={() => setRankFormType('text')}
|
||||
/>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id="rank-badge-image"
|
||||
name="rankBadgeType"
|
||||
label={t('rank.badge_image')}
|
||||
checked={rankFormType === 'image'}
|
||||
onChange={() => setRankFormType('image')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
{rankFormType === 'text' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_text')}</Form.Label>
|
||||
<Form.Control
|
||||
value={rankFormText}
|
||||
onChange={(event) => setRankFormText(event.target.value)}
|
||||
placeholder={t('rank.badge_text_placeholder')}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
{rankFormType === 'image' && (
|
||||
<Form.Group className="mt-3">
|
||||
<Form.Label>{t('rank.badge_image')}</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
|
||||
onChange={(event) => setRankFormImage(event.target.files?.[0] || null)}
|
||||
disabled={rankSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<div className="d-flex justify-content-end mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bb-accent-button"
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
>
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,48 @@ export default function BoardIndex() {
|
||||
nodes.forEach((node) => sortNodes(node.children))
|
||||
}
|
||||
|
||||
const aggregateNodes = (node) => {
|
||||
if (!node.children?.length) {
|
||||
return {
|
||||
threads: node.threads_count ?? 0,
|
||||
views: node.views_count ?? 0,
|
||||
posts: node.posts_count ?? 0,
|
||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
||||
}
|
||||
}
|
||||
|
||||
let threads = node.threads_count ?? 0
|
||||
let views = node.views_count ?? 0
|
||||
let posts = node.posts_count ?? 0
|
||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const agg = aggregateNodes(child)
|
||||
threads += agg.threads
|
||||
views += agg.views
|
||||
posts += agg.posts
|
||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
||||
last = agg.last
|
||||
}
|
||||
})
|
||||
|
||||
node.threads_count = threads
|
||||
node.views_count = views
|
||||
node.posts_count = posts
|
||||
if (last) {
|
||||
const source = last.node
|
||||
node.last_post_at = source.last_post_at
|
||||
node.last_post_user_id = source.last_post_user_id
|
||||
node.last_post_user_name = source.last_post_user_name
|
||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
||||
node.last_post_user_group_color = source.last_post_user_group_color
|
||||
}
|
||||
|
||||
return { threads, views, posts, last }
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
roots.forEach((root) => aggregateNodes(root))
|
||||
|
||||
return roots
|
||||
}, [forums])
|
||||
@@ -138,7 +179,18 @@ export default function BoardIndex() {
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
<Link
|
||||
to={`/profile/${node.last_post_user_id}`}
|
||||
className="bb-board-last-link"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
import { createThread, getForum, listAllForums, listThreadsByForum } from '../api/client'
|
||||
import PortalTopicRow from '../components/PortalTopicRow'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -44,7 +44,18 @@ export default function ForumView() {
|
||||
<span className="bb-board-last-by">
|
||||
{t('thread.by')}{' '}
|
||||
{node.last_post_user_id ? (
|
||||
<Link to={`/profile/${node.last_post_user_id}`} className="bb-board-last-link">
|
||||
<Link
|
||||
to={`/profile/${node.last_post_user_id}`}
|
||||
className="bb-board-last-link"
|
||||
style={
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
node.last_post_user_rank_color || node.last_post_user_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{node.last_post_user_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -60,6 +71,77 @@ export default function ForumView() {
|
||||
</div>
|
||||
))
|
||||
|
||||
const getParentId = (node) => {
|
||||
if (!node.parent) return null
|
||||
if (typeof node.parent === 'string') {
|
||||
return node.parent.split('/').pop()
|
||||
}
|
||||
return node.parent.id ?? null
|
||||
}
|
||||
|
||||
const buildForumTree = (allForums) => {
|
||||
const map = new Map()
|
||||
const roots = []
|
||||
|
||||
allForums.forEach((item) => {
|
||||
map.set(String(item.id), { ...item, children: [] })
|
||||
})
|
||||
|
||||
allForums.forEach((item) => {
|
||||
const parentId = getParentId(item)
|
||||
const node = map.get(String(item.id))
|
||||
if (parentId && map.has(String(parentId))) {
|
||||
map.get(String(parentId)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
const aggregateNodes = (node) => {
|
||||
if (!node.children?.length) {
|
||||
return {
|
||||
threads: node.threads_count ?? 0,
|
||||
views: node.views_count ?? 0,
|
||||
posts: node.posts_count ?? 0,
|
||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
||||
}
|
||||
}
|
||||
|
||||
let threads = node.threads_count ?? 0
|
||||
let views = node.views_count ?? 0
|
||||
let posts = node.posts_count ?? 0
|
||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const agg = aggregateNodes(child)
|
||||
threads += agg.threads
|
||||
views += agg.views
|
||||
posts += agg.posts
|
||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
||||
last = agg.last
|
||||
}
|
||||
})
|
||||
|
||||
node.threads_count = threads
|
||||
node.views_count = views
|
||||
node.posts_count = posts
|
||||
if (last) {
|
||||
const source = last.node
|
||||
node.last_post_at = source.last_post_at
|
||||
node.last_post_user_id = source.last_post_user_id
|
||||
node.last_post_user_name = source.last_post_user_name
|
||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
||||
node.last_post_user_group_color = source.last_post_user_group_color
|
||||
}
|
||||
|
||||
return { threads, views, posts, last }
|
||||
}
|
||||
|
||||
roots.forEach((root) => aggregateNodes(root))
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -70,9 +152,11 @@ export default function ForumView() {
|
||||
const forumData = await getForum(id)
|
||||
if (!active) return
|
||||
setForum(forumData)
|
||||
const childData = await listForumsByParent(id)
|
||||
const allForums = await listAllForums()
|
||||
if (!active) return
|
||||
setChildren(childData)
|
||||
const treeMap = buildForumTree(allForums)
|
||||
const currentNode = treeMap.get(String(forumData.id))
|
||||
setChildren(currentNode?.children ?? [])
|
||||
if (forumData.type === 'forum') {
|
||||
const threadData = await listThreadsByForum(id)
|
||||
if (!active) return
|
||||
|
||||
@@ -91,7 +91,48 @@ export default function Home() {
|
||||
nodes.forEach((node) => sortNodes(node.children))
|
||||
}
|
||||
|
||||
const aggregateNodes = (node) => {
|
||||
if (!node.children?.length) {
|
||||
return {
|
||||
threads: node.threads_count ?? 0,
|
||||
views: node.views_count ?? 0,
|
||||
posts: node.posts_count ?? 0,
|
||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
||||
}
|
||||
}
|
||||
|
||||
let threads = node.threads_count ?? 0
|
||||
let views = node.views_count ?? 0
|
||||
let posts = node.posts_count ?? 0
|
||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const agg = aggregateNodes(child)
|
||||
threads += agg.threads
|
||||
views += agg.views
|
||||
posts += agg.posts
|
||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
||||
last = agg.last
|
||||
}
|
||||
})
|
||||
|
||||
node.threads_count = threads
|
||||
node.views_count = views
|
||||
node.posts_count = posts
|
||||
if (last) {
|
||||
const source = last.node
|
||||
node.last_post_at = source.last_post_at
|
||||
node.last_post_user_id = source.last_post_user_id
|
||||
node.last_post_user_name = source.last_post_user_name
|
||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
||||
node.last_post_user_group_color = source.last_post_user_group_color
|
||||
}
|
||||
|
||||
return { threads, views, posts, last }
|
||||
}
|
||||
|
||||
sortNodes(roots)
|
||||
roots.forEach((root) => aggregateNodes(root))
|
||||
|
||||
return roots
|
||||
}, [forums])
|
||||
@@ -226,7 +267,15 @@ export default function Home() {
|
||||
</Link>
|
||||
<div className="bb-portal-user-name">
|
||||
{profile?.id ? (
|
||||
<Link to={`/profile/${profile.id}`} className="bb-portal-user-name-link">
|
||||
<Link
|
||||
to={`/profile/${profile.id}`}
|
||||
className="bb-portal-user-name-link"
|
||||
style={
|
||||
profile?.rank?.color || profile?.group_color
|
||||
? { '--bb-user-link-color': profile.rank?.color || profile.group_color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{profile?.name || email || 'User'}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
|
||||
import { Container } from 'react-bootstrap'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUserProfile } from '../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUserProfile, listUserThanksGiven, listUserThanksReceived } from '../api/client'
|
||||
|
||||
export default function Profile() {
|
||||
const { id } = useParams()
|
||||
@@ -10,23 +11,32 @@ export default function Profile() {
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [thanksGiven, setThanksGiven] = useState([])
|
||||
const [thanksReceived, setThanksReceived] = useState([])
|
||||
const [loadingThanks, setLoadingThanks] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
getUserProfile(id)
|
||||
.then((data) => {
|
||||
Promise.all([getUserProfile(id), listUserThanksGiven(id), listUserThanksReceived(id)])
|
||||
.then(([profileData, givenData, receivedData]) => {
|
||||
if (!active) return
|
||||
setProfile(data)
|
||||
setProfile(profileData)
|
||||
setThanksGiven(Array.isArray(givenData) ? givenData : [])
|
||||
setThanksReceived(Array.isArray(receivedData) ? receivedData : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return
|
||||
setError(err.message)
|
||||
setThanksGiven([])
|
||||
setThanksReceived([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
setLoadingThanks(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -34,6 +44,19 @@ export default function Profile() {
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear())
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="py-5 bb-portal-shell">
|
||||
<div className="bb-portal-card">
|
||||
@@ -59,6 +82,96 @@ export default function Profile() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile && (
|
||||
<div className="bb-profile-thanks mt-4">
|
||||
<div className="bb-profile-section">
|
||||
<div className="bb-portal-card-title">{t('profile.thanks_given')}</div>
|
||||
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
|
||||
{!loadingThanks && thanksGiven.length === 0 && (
|
||||
<p className="bb-muted">{t('profile.thanks_empty')}</p>
|
||||
)}
|
||||
{!loadingThanks && thanksGiven.length > 0 && (
|
||||
<ul className="bb-profile-thanks-list">
|
||||
{thanksGiven.map((item) => (
|
||||
<li key={item.id} className="bb-profile-thanks-item">
|
||||
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
|
||||
{item.thread_title || t('thread.label')}
|
||||
</Link>
|
||||
{item.post_author_id ? (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_for')}{' '}
|
||||
<Link
|
||||
to={`/profile/${item.post_author_id}`}
|
||||
style={
|
||||
item.post_author_rank_color || item.post_author_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
item.post_author_rank_color || item.post_author_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.post_author_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_for')} {item.post_author_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="bb-profile-thanks-date">
|
||||
{formatDateTime(item.thanked_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-profile-section">
|
||||
<div className="bb-portal-card-title">{t('profile.thanks_received')}</div>
|
||||
{loadingThanks && <p className="bb-muted">{t('profile.loading')}</p>}
|
||||
{!loadingThanks && thanksReceived.length === 0 && (
|
||||
<p className="bb-muted">{t('profile.thanks_empty')}</p>
|
||||
)}
|
||||
{!loadingThanks && thanksReceived.length > 0 && (
|
||||
<ul className="bb-profile-thanks-list">
|
||||
{thanksReceived.map((item) => (
|
||||
<li key={item.id} className="bb-profile-thanks-item">
|
||||
<Link to={`/thread/${item.thread_id}#post-${item.post_id}`}>
|
||||
{item.thread_title || t('thread.label')}
|
||||
</Link>
|
||||
{item.thanker_id ? (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_by')}{' '}
|
||||
<Link
|
||||
to={`/profile/${item.thanker_id}`}
|
||||
style={
|
||||
item.thanker_rank_color || item.thanker_group_color
|
||||
? {
|
||||
'--bb-user-link-color':
|
||||
item.thanker_rank_color || item.thanker_group_color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.thanker_name || t('thread.anonymous')}
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bb-profile-thanks-meta">
|
||||
{t('profile.thanks_by')} {item.thanker_name || t('thread.anonymous')}
|
||||
</span>
|
||||
)}
|
||||
<span className="bb-profile-thanks-date">
|
||||
{formatDateTime(item.thanked_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Container, Form } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { createPost, getThread, listPostsByThread, updateThreadSolved } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThreadView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const { token, userId, isAdmin } = useAuth()
|
||||
const [thread, setThread] = useState(null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [solving, setSolving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const replyRef = useRef(null)
|
||||
|
||||
@@ -56,7 +57,7 @@ export default function ThreadView() {
|
||||
}
|
||||
}
|
||||
|
||||
const replyCount = posts.length
|
||||
// const replyCount = posts.length
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
@@ -72,11 +73,14 @@ export default function ThreadView() {
|
||||
id: `thread-${thread.id}`,
|
||||
body: thread.body,
|
||||
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,
|
||||
@@ -90,6 +94,24 @@ export default function ThreadView() {
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const canToggleSolved = 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 totalPosts = allPosts.length
|
||||
|
||||
return (
|
||||
@@ -99,7 +121,12 @@ export default function ThreadView() {
|
||||
{thread && (
|
||||
<div className="bb-thread">
|
||||
<div className="bb-thread-titlebar">
|
||||
<h1 className="bb-thread-title">{thread.title}</h1>
|
||||
<h1 className="bb-thread-title">
|
||||
{thread.title}
|
||||
{thread.solved && (
|
||||
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
|
||||
)}
|
||||
</h1>
|
||||
<div className="bb-thread-meta">
|
||||
<span>{t('thread.by')}</span>
|
||||
<span className="bb-thread-author">
|
||||
@@ -117,6 +144,20 @@ export default function ThreadView() {
|
||||
<i className="bi bi-reply-fill" aria-hidden="true" />
|
||||
<span>{t('form.post_reply')}</span>
|
||||
</Button>
|
||||
{canToggleSolved && (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="bb-thread-solved-toggle"
|
||||
onClick={handleToggleSolved}
|
||||
disabled={solving}
|
||||
>
|
||||
<i
|
||||
className={`bi ${thread.solved ? 'bi-check-circle-fill' : 'bi-check-circle'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{thread.solved ? t('thread.mark_unsolved') : t('thread.mark_solved')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
|
||||
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -140,6 +181,17 @@ export default function ThreadView() {
|
||||
|| post.user_name
|
||||
|| post.author_name
|
||||
|| t('thread.anonymous')
|
||||
const currentUserId = Number(userId)
|
||||
const postUserId = Number(post.user_id)
|
||||
const canThank = Number.isFinite(currentUserId)
|
||||
&& Number.isFinite(postUserId)
|
||||
&& currentUserId !== postUserId
|
||||
console.log('canThank check', {
|
||||
postId: post.id,
|
||||
postUserId,
|
||||
currentUserId,
|
||||
canThank,
|
||||
})
|
||||
const topicLabel = thread?.title
|
||||
? post.isRoot
|
||||
? thread.title
|
||||
@@ -190,12 +242,16 @@ export default function ThreadView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks given:</span>
|
||||
<span className="bb-post-author-value">7</span>
|
||||
<span className="bb-post-author-label">{t('thread.thanks_given')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_given_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat">
|
||||
<span className="bb-post-author-label">Thanks received:</span>
|
||||
<span className="bb-post-author-value">5</span>
|
||||
<span className="bb-post-author-label">{t('thread.thanks_received')}:</span>
|
||||
<span className="bb-post-author-value">
|
||||
{post.user_thanks_received_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bb-post-author-stat bb-post-author-contact">
|
||||
<span className="bb-post-author-label">Contact:</span>
|
||||
@@ -234,12 +290,21 @@ export default function ThreadView() {
|
||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||
<i className="bi bi-quote" aria-hidden="true" />
|
||||
</button>
|
||||
<a href="/" className="bb-post-action" aria-label={t('portal.portal')}>
|
||||
<i className="bi bi-house-door" aria-hidden="true" />
|
||||
</a>
|
||||
{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">{post.body}</div>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"acp.add_category": "Kategorie hinzufügen",
|
||||
"acp.add_forum": "Forum hinzufügen",
|
||||
"acp.ranks": "Ränge",
|
||||
"acp.groups": "Gruppen",
|
||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||
"acp.forums_tree": "Forenbaum",
|
||||
"acp.forums_type": "Typ",
|
||||
@@ -108,11 +109,16 @@
|
||||
"rank.name": "Rangname",
|
||||
"rank.name_placeholder": "z. B. Operator",
|
||||
"rank.create": "Rang erstellen",
|
||||
"rank.create_title": "Rang erstellen",
|
||||
"rank.edit_title": "Rang bearbeiten",
|
||||
"rank.badge_type": "Badge-Typ",
|
||||
"rank.badge_text": "Text-Badge",
|
||||
"rank.badge_image": "Bild-Badge",
|
||||
"rank.badge_none": "Kein Badge",
|
||||
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
|
||||
"rank.color": "Rangfarbe",
|
||||
"rank.color_placeholder": "z. B. #f29b3f",
|
||||
"rank.color_default": "Standardfarbe verwenden",
|
||||
"rank.badge_text_required": "Badge-Text ist erforderlich.",
|
||||
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
|
||||
"rank.delete_confirm": "Diesen Rang löschen?",
|
||||
@@ -122,6 +128,19 @@
|
||||
"user.impersonate": "Imitieren",
|
||||
"user.edit": "Bearbeiten",
|
||||
"user.delete": "Löschen",
|
||||
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
|
||||
"group.create": "Gruppe erstellen",
|
||||
"group.create_title": "Gruppe erstellen",
|
||||
"group.edit_title": "Gruppe bearbeiten",
|
||||
"group.name": "Gruppenname",
|
||||
"group.name_placeholder": "z. B. Gründer",
|
||||
"group.color": "Gruppenfarbe",
|
||||
"group.color_placeholder": "z. B. #f29b3f",
|
||||
"group.delete_confirm": "Diese Gruppe löschen?",
|
||||
"group.empty": "Noch keine Gruppen vorhanden.",
|
||||
"group.edit": "Bearbeiten",
|
||||
"group.delete": "Löschen",
|
||||
"group.core_locked": "Kern-Gruppen können nicht geändert werden.",
|
||||
"table.rows_per_page": "Zeilen pro Seite:",
|
||||
"table.range_separator": "von",
|
||||
"home.browse": "Foren durchsuchen",
|
||||
@@ -175,6 +194,11 @@
|
||||
"profile.title": "Profil",
|
||||
"profile.loading": "Profil wird geladen...",
|
||||
"profile.registered": "Registriert:",
|
||||
"profile.thanks_given": "Hat sich bedankt",
|
||||
"profile.thanks_received": "Dank erhalten",
|
||||
"profile.thanks_empty": "Noch keine Danksagungen.",
|
||||
"profile.thanks_by": "Dank von",
|
||||
"profile.thanks_for": "Dank für",
|
||||
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
||||
"ucp.profile": "Profil",
|
||||
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
|
||||
@@ -197,9 +221,15 @@
|
||||
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
|
||||
"thread.posts": "Beiträge",
|
||||
"thread.location": "Wohnort",
|
||||
"thread.thanks_given": "Hat sich bedankt",
|
||||
"thread.thanks_received": "Dank erhalten",
|
||||
"thread.thanks": "Danke",
|
||||
"thread.reply_prefix": "Aw:",
|
||||
"thread.registered": "Registriert",
|
||||
"thread.replies": "Antworten",
|
||||
"thread.solved": "Gel\u00f6st",
|
||||
"thread.mark_solved": "Als gel\u00f6st markieren",
|
||||
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
|
||||
"thread.views": "Zugriffe",
|
||||
"thread.last_post": "Letzter Beitrag",
|
||||
"thread.by": "von",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"acp.add_category": "Add category",
|
||||
"acp.add_forum": "Add forum",
|
||||
"acp.ranks": "Ranks",
|
||||
"acp.groups": "Groups",
|
||||
"acp.forums_parent_root": "Root (no parent)",
|
||||
"acp.forums_tree": "Forum tree",
|
||||
"acp.forums_type": "Type",
|
||||
@@ -108,11 +109,16 @@
|
||||
"rank.name": "Rank name",
|
||||
"rank.name_placeholder": "e.g. Operator",
|
||||
"rank.create": "Create rank",
|
||||
"rank.create_title": "Create rank",
|
||||
"rank.edit_title": "Edit rank",
|
||||
"rank.badge_type": "Badge type",
|
||||
"rank.badge_text": "Text badge",
|
||||
"rank.badge_image": "Image badge",
|
||||
"rank.badge_none": "No badge",
|
||||
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
|
||||
"rank.color": "Rank color",
|
||||
"rank.color_placeholder": "e.g. #f29b3f",
|
||||
"rank.color_default": "Use default color",
|
||||
"rank.badge_text_required": "Badge text is required.",
|
||||
"rank.badge_image_required": "Badge image is required.",
|
||||
"rank.delete_confirm": "Delete this rank?",
|
||||
@@ -122,6 +128,19 @@
|
||||
"user.impersonate": "Impersonate",
|
||||
"user.edit": "Edit",
|
||||
"user.delete": "Delete",
|
||||
"user.founder_locked": "Only founders can edit or assign the Founder role.",
|
||||
"group.create": "Create group",
|
||||
"group.create_title": "Create group",
|
||||
"group.edit_title": "Edit group",
|
||||
"group.name": "Group name",
|
||||
"group.name_placeholder": "e.g. Founder",
|
||||
"group.color": "Group color",
|
||||
"group.color_placeholder": "e.g. #f29b3f",
|
||||
"group.delete_confirm": "Delete this group?",
|
||||
"group.empty": "No groups created yet.",
|
||||
"group.edit": "Edit",
|
||||
"group.delete": "Delete",
|
||||
"group.core_locked": "Core groups cannot be changed.",
|
||||
"table.rows_per_page": "Rows per page:",
|
||||
"table.range_separator": "of",
|
||||
"home.browse": "Browse forums",
|
||||
@@ -175,6 +194,11 @@
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "Loading profile...",
|
||||
"profile.registered": "Registered:",
|
||||
"profile.thanks_given": "Thanks given",
|
||||
"profile.thanks_received": "Thanks received",
|
||||
"profile.thanks_empty": "No thanks yet.",
|
||||
"profile.thanks_by": "Thanks by",
|
||||
"profile.thanks_for": "Thanks for",
|
||||
"ucp.intro": "Manage your basic preferences for the forum.",
|
||||
"ucp.profile": "Profile",
|
||||
"ucp.profile_hint": "Update the avatar shown next to your posts.",
|
||||
@@ -197,9 +221,15 @@
|
||||
"thread.login_hint": "Log in to reply to this thread.",
|
||||
"thread.posts": "Posts",
|
||||
"thread.location": "Location",
|
||||
"thread.thanks_given": "Thanks given",
|
||||
"thread.thanks_received": "Thanks received",
|
||||
"thread.thanks": "Thank",
|
||||
"thread.reply_prefix": "Re:",
|
||||
"thread.registered": "Registered",
|
||||
"thread.replies": "Replies",
|
||||
"thread.solved": "Solved",
|
||||
"thread.mark_solved": "Mark solved",
|
||||
"thread.mark_unsolved": "Mark unsolved",
|
||||
"thread.views": "Views",
|
||||
"thread.last_post": "Last post",
|
||||
"thread.by": "by",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@vite(['resources/js/main.jsx'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="top"></div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</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>
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\ForumController;
|
||||
use App\Http\Controllers\I18nController;
|
||||
use App\Http\Controllers\PortalController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\PostThankController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Http\Controllers\ThreadController;
|
||||
@@ -13,6 +14,7 @@ use App\Http\Controllers\UserSettingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VersionController;
|
||||
use App\Http\Controllers\RankController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
@@ -43,6 +45,12 @@ 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');
|
||||
@@ -59,8 +67,11 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
|
||||
Route::get('/threads', [ThreadController::class, 'index']);
|
||||
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
||||
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
|
||||
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
|
||||
Route::get('/posts', [PostController::class, 'index']);
|
||||
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'app');
|
||||
Route::view('/login', 'app')->name('login');
|
||||
Route::view('/reset-password', 'app')->name('password.reset');
|
||||
Route::get('/install', [InstallerController::class, 'show']);
|
||||
Route::post('/install', [InstallerController::class, 'store'])
|
||||
->withoutMiddleware([VerifyCsrfToken::class]);
|
||||
|
||||
Route::view('/{any}', 'app')->where('any', '^(?!api).*$');
|
||||
Route::get('/', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
});
|
||||
|
||||
Route::get('/login', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->name('login');
|
||||
|
||||
Route::get('/reset-password', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->name('password.reset');
|
||||
|
||||
Route::get('/{any}', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
}
|
||||
return view('app');
|
||||
})->where('any', '^(?!api).*$');
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
|
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 |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SpeedBB</title>
|
||||
<?php echo app('Illuminate\Foundation\Vite')->reactRefresh(); ?>
|
||||
<?php echo app('Illuminate\Foundation\Vite')(['resources/js/main.jsx']); ?>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<?php /**PATH /home/users/tracer/www/forum.lab.24unix.net/speedBB/resources/views/app.blade.php ENDPATH**/ ?>
|
||||