Add ranks and ACP user enhancements

This commit is contained in:
Micha
2026-01-14 00:15:56 +01:00
parent 98094459e3
commit fd29b928d8
21 changed files with 1272 additions and 26 deletions

View File

@@ -6,6 +6,11 @@
- Added SPA-friendly verification and password reset/update endpoints. - Added SPA-friendly verification and password reset/update endpoints.
- Added user avatars (upload + display) and a basic profile page/API. - Added user avatars (upload + display) and a basic profile page/API.
- Seeded a Micha test user with verified email. - Seeded a Micha test user with verified email.
- Added rank management with badge text/image options and ACP UI controls.
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
- Added ACP users search and improved sorting indicators.
- Added thread sidebar fields for posts count, registration date, and topic header.
- Linked header logo to the portal and fixed ACP breadcrumbs.
## 2026-01-11 ## 2026-01-11
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout. - Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.

View File

@@ -12,7 +12,9 @@ class PostController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Post::query()->withoutTrashed()->with('user'); $query = Post::query()->withoutTrashed()->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
$threadParam = $request->query('thread'); $threadParam = $request->query('thread');
if (is_string($threadParam)) { if (is_string($threadParam)) {
@@ -46,7 +48,9 @@ class PostController extends Controller
'body' => $data['body'], 'body' => $data['body'],
]); ]);
$post->loadMissing('user'); $post->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
return response()->json($this->serializePost($post), 201); return response()->json($this->serializePost($post), 201);
} }
@@ -88,6 +92,14 @@ class PostController extends Controller
'user_avatar_url' => $post->user?->avatar_path 'user_avatar_url' => $post->user?->avatar_path
? Storage::url($post->user->avatar_path) ? Storage::url($post->user->avatar_path)
: null, : null,
'user_posts_count' => $post->user?->posts_count,
'user_created_at' => $post->user?->created_at?->toIso8601String(),
'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,
'created_at' => $post->created_at?->toIso8601String(), 'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_at?->toIso8601String(), 'updated_at' => $post->updated_at?->toIso8601String(),
]; ];

View File

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

View File

@@ -12,7 +12,9 @@ class ThreadController extends Controller
{ {
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Thread::query()->withoutTrashed()->with('user'); $query = Thread::query()->withoutTrashed()->with([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
$forumParam = $request->query('forum'); $forumParam = $request->query('forum');
if (is_string($forumParam)) { if (is_string($forumParam)) {
@@ -32,7 +34,9 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse public function show(Thread $thread): JsonResponse
{ {
$thread->loadMissing('user'); $thread->loadMissing([
'user' => fn ($query) => $query->withCount('posts')->with('rank'),
]);
return response()->json($this->serializeThread($thread)); return response()->json($this->serializeThread($thread));
} }
@@ -99,6 +103,14 @@ class ThreadController extends Controller
'user_avatar_url' => $thread->user?->avatar_path 'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path) ? Storage::url($thread->user->avatar_path)
: null, : null,
'user_posts_count' => $thread->user?->posts_count,
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'created_at' => $thread->created_at?->toIso8601String(), 'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(), 'updated_at' => $thread->updated_at?->toIso8601String(),
]; ];

View File

@@ -6,13 +6,15 @@ use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserController extends Controller class UserController extends Controller
{ {
public function index(): JsonResponse public function index(): JsonResponse
{ {
$users = User::query() $users = User::query()
->with('roles') ->with(['roles', 'rank'])
->orderBy('id') ->orderBy('id')
->get() ->get()
->map(fn (User $user) => [ ->map(fn (User $user) => [
@@ -20,6 +22,10 @@ class UserController extends Controller
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'roles' => $user->roles->pluck('name')->values(), 'roles' => $user->roles->pluck('name')->values(),
]); ]);
@@ -39,6 +45,10 @@ class UserController extends Controller
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'roles' => $user->roles()->pluck('name')->values(), 'roles' => $user->roles()->pluck('name')->values(),
]); ]);
} }
@@ -49,10 +59,94 @@ class UserController extends Controller
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'avatar_url' => $this->resolveAvatarUrl($user), 'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'created_at' => $user->created_at?->toIso8601String(), 'created_at' => $user->created_at?->toIso8601String(),
]); ]);
} }
public function updateRank(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'rank_id' => ['nullable', 'exists:ranks,id'],
]);
$user->rank_id = $data['rank_id'] ?? null;
$user->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
]);
}
public function update(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($user->id),
],
'rank_id' => ['nullable', 'exists:ranks,id'],
]);
$nameCanonical = Str::lower(trim($data['name']));
$nameConflict = User::query()
->where('id', '!=', $user->id)
->where('name_canonical', $nameCanonical)
->exists();
if ($nameConflict) {
return response()->json(['message' => 'Name already exists.'], 422);
}
if ($data['email'] !== $user->email) {
$user->email_verified_at = null;
}
$user->forceFill([
'name' => $data['name'],
'name_canonical' => $nameCanonical,
'email' => $data['email'],
'rank_id' => $data['rank_id'] ?? null,
])->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
] : null,
'roles' => $user->roles()->pluck('name')->values(),
]);
}
private function resolveAvatarUrl(User $user): ?string private function resolveAvatarUrl(User $user): ?string
{ {
if (!$user->avatar_path) { if (!$user->avatar_path) {

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

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

View File

@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\DatabaseNotification;
@@ -66,6 +67,7 @@ class User extends Authenticatable implements MustVerifyEmail
'name', 'name',
'name_canonical', 'name_canonical',
'avatar_path', 'avatar_path',
'rank_id',
'email', 'email',
'password', 'password',
]; ];
@@ -97,4 +99,14 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function rank()
{
return $this->belongsTo(Rank::class);
}
} }

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ranks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ranks');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('rank_id')->nullable()->after('avatar_path')->constrained('ranks')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('rank_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->string('badge_type')->default('text')->after('name');
$table->string('badge_text')->nullable()->after('badge_type');
$table->string('badge_image_path')->nullable()->after('badge_text');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn(['badge_type', 'badge_text', 'badge_image_path']);
});
}
};

View File

@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
{ {
$this->call([ $this->call([
RoleSeeder::class, RoleSeeder::class,
RankSeeder::class,
UserSeeder::class, UserSeeder::class,
ForumSeeder::class, ForumSeeder::class,
ThreadSeeder::class, ThreadSeeder::class,

View File

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

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\Rank;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
@@ -17,12 +18,15 @@ class UserSeeder extends Seeder
{ {
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first(); $adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first(); $userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
$operatorRank = Rank::where('name', 'Operator')->first();
$memberRank = Rank::where('name', 'Member')->first();
$admin = User::updateOrCreate( $admin = User::updateOrCreate(
attributes: ['email' => 'tracer@24unix.net'], attributes: ['email' => 'tracer@24unix.net'],
values : [ values : [
'name' => 'tracer', 'name' => 'tracer',
'name_canonical' => Str::lower('tracer'), 'name_canonical' => Str::lower('tracer'),
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
'password' => Hash::make(value: 'password'), 'password' => Hash::make(value: 'password'),
'email_verified_at' => now(), 'email_verified_at' => now(),
] ]
@@ -33,6 +37,7 @@ class UserSeeder extends Seeder
values : [ values : [
'name' => 'Micha', 'name' => 'Micha',
'name_canonical' => Str::lower('Micha'), 'name_canonical' => Str::lower('Micha'),
'rank_id' => $memberRank?->id,
'password' => Hash::make(value: 'password'), 'password' => Hash::make(value: 'password'),
'email_verified_at' => now(), 'email_verified_at' => now(),
] ]

View File

@@ -105,6 +105,15 @@ function PortalHeader({
} }
} }
if (location.pathname.startsWith('/acp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.link_acp'), to: '/acp', current: true },
])
return
}
setCrumbs([{ ...base[0] }, { ...base[1], current: true }]) setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
} }
@@ -119,12 +128,14 @@ function PortalHeader({
<Container fluid className="pt-2 pb-2 bb-portal-shell"> <Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner"> <div className="bb-portal-banner">
<div className="bb-portal-brand"> <div className="bb-portal-brand">
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
{logoUrl && ( {logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" /> <img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)} )}
{(showHeaderName || !logoUrl) && ( {(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</div> <div className="bb-portal-logo">{forumName || '24unix.net'}</div>
)} )}
</Link>
</div> </div>
<div className="bb-portal-search"> <div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled /> <input type="text" placeholder={t('portal.search_placeholder')} disabled />

View File

@@ -228,6 +228,53 @@ export async function listUsers() {
return getCollection('/users') return getCollection('/users')
} }
export async function listRanks() {
return getCollection('/ranks')
}
export async function updateUserRank(userId, rankId) {
return apiFetch(`/users/${userId}/rank`, {
method: 'PATCH',
body: JSON.stringify({ rank_id: rankId }),
})
}
export async function createRank(payload) {
return apiFetch('/ranks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRank(rankId, payload) {
return apiFetch(`/ranks/${rankId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRank(rankId) {
return apiFetch(`/ranks/${rankId}`, {
method: 'DELETE',
})
}
export async function uploadRankBadgeImage(rankId, file) {
const body = new FormData()
body.append('file', file)
return apiFetch(`/ranks/${rankId}/badge-image`, {
method: 'POST',
body,
})
}
export async function updateUser(userId, payload) {
return apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function createThread({ title, body, forumId }) { export async function createThread({ title, body, forumId }) {
return apiFetch('/threads', { return apiFetch('/threads', {
method: 'POST', method: 'POST',

View File

@@ -231,6 +231,10 @@ a {
overflow: hidden; overflow: hidden;
} }
.bb-post-avatar i {
font-size: 4.4rem;
}
.bb-post-avatar img { .bb-post-avatar img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -263,6 +267,17 @@ a {
border: 1px solid rgba(0, 0, 0, 0.25); border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
width: fit-content; width: fit-content;
overflow: hidden;
}
.bb-post-author-badge img {
width: auto;
height: 22px;
display: block;
}
.bb-post-author-badge span {
white-space: nowrap;
} }
.bb-post-author-meta { .bb-post-author-meta {
@@ -317,6 +332,12 @@ a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
flex-wrap: wrap;
}
.bb-post-topic {
font-weight: 600;
color: var(--bb-accent, #f29b3f);
} }
.bb-post-actions { .bb-post-actions {
@@ -847,6 +868,13 @@ a {
font-weight: 700; font-weight: 700;
} }
.bb-portal-logo-link {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
.bb-portal-logo-image { .bb-portal-logo-image {
width: auto; width: auto;
height: auto; height: auto;
@@ -1665,6 +1693,101 @@ a {
justify-content: flex-end; justify-content: flex-end;
} }
.bb-rank-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.bb-rank-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(18, 23, 33, 0.8);
}
.bb-rank-main {
display: flex;
align-items: center;
gap: 0.6rem;
}
.bb-rank-main img {
height: 22px;
width: auto;
}
.bb-rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.5rem;
border-radius: 6px;
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
color: #7b1f2a;
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.bb-rank-badge-preview {
padding: 0.4rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 0.5rem;
}
.bb-rank-badge-preview img {
height: 28px;
width: auto;
display: block;
}
.bb-rank-actions {
display: inline-flex;
gap: 0.5rem;
}
.bb-user-search {
max-width: 320px;
}
.bb-sort-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
}
.bb-sort-label i {
font-size: 0.9rem;
color: var(--bb-ink-muted);
}
.rdt_TableCol_Sortable svg {
display: none;
}
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ {
display: none !important;
}
.bb-sort-hidden {
display: none;
}
.rdt_TableCol_Sortable .__rdt_custom_sort_icon__ i {
font-size: 0.9rem;
}
.bb-drag-handle { .bb-drag-handle {
font-size: 1.2rem; font-size: 1.2rem;
line-height: 1; line-height: 1;

View File

@@ -8,10 +8,17 @@ import {
deleteForum, deleteForum,
fetchSettings, fetchSettings,
listAllForums, listAllForums,
listRanks,
listUsers, listUsers,
reorderForums, reorderForums,
saveSetting, saveSetting,
saveSettings, saveSettings,
createRank,
deleteRank,
updateUserRank,
updateRank,
updateUser,
uploadRankBadgeImage,
uploadFavicon, uploadFavicon,
uploadLogo, uploadLogo,
updateForum, updateForum,
@@ -28,10 +35,33 @@ export default function Acp({ isAdmin }) {
const pendingOrder = useRef(null) const pendingOrder = useRef(null)
const [createType, setCreateType] = useState(null) const [createType, setCreateType] = useState(null)
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [userSearch, setUserSearch] = useState('')
const [usersLoading, setUsersLoading] = useState(false) const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('') const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1) const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10) const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
const [ranks, setRanks] = useState([])
const [ranksLoading, setRanksLoading] = useState(false)
const [ranksError, setRanksError] = useState('')
const [rankUpdatingId, setRankUpdatingId] = useState(null)
const [rankFormName, setRankFormName] = useState('')
const [rankFormType, setRankFormType] = useState('text')
const [rankFormText, setRankFormText] = useState('')
const [rankFormImage, setRankFormImage] = useState(null)
const [rankSaving, setRankSaving] = useState(false)
const [showRankModal, setShowRankModal] = useState(false)
const [rankEdit, setRankEdit] = useState({
id: null,
name: '',
badgeType: 'text',
badgeText: '',
badgeImageUrl: '',
})
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '' })
const [userSaving, setUserSaving] = useState(false)
const [generalSaving, setGeneralSaving] = useState(false) const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false) const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('') const [generalError, setGeneralError] = useState('')
@@ -341,17 +371,93 @@ export default function Acp({ isAdmin }) {
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
const filteredUsers = useMemo(() => {
const term = userSearch.trim().toLowerCase()
if (!term) return users
return users.filter((user) =>
[user.name, user.email, user.rank?.name]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(term))
)
}, [users, userSearch])
const userColumns = useMemo( const userColumns = useMemo(
() => [ () => {
const iconFor = (id) => {
if (userSort.columnId !== id) {
return 'bi-arrow-down-up'
}
return userSort.direction === 'asc' ? 'bi-caret-up-fill' : 'bi-caret-down-fill'
}
return [
{ {
name: t('user.name'), id: 'name',
name: (
<span className="bb-sort-label">
{t('user.name')}
<i className={`bi ${iconFor('name')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.name, selector: (row) => row.name,
sortable: true, sortable: true,
sortFunction: (a, b) => (a.name || '').localeCompare(b.name || '', undefined, {
sensitivity: 'base',
}),
},
{
id: 'email',
name: (
<span className="bb-sort-label">
{t('user.email')}
<i className={`bi ${iconFor('email')}`} aria-hidden="true" />
</span>
),
selector: (row) => row.email,
sortable: true,
}, },
{ {
name: t('user.email'), id: 'rank',
selector: (row) => row.email, name: (
<span className="bb-sort-label">
{t('user.rank')}
<i className={`bi ${iconFor('rank')}`} aria-hidden="true" />
</span>
),
width: '220px',
sortable: true, sortable: true,
sortFunction: (a, b) =>
(a.rank?.name || '').localeCompare(b.rank?.name || ''),
cell: (row) => (
<Form.Select
size="sm"
value={row.rank?.id ?? ''}
disabled={ranksLoading || rankUpdatingId === row.id}
onChange={async (event) => {
const nextRankId = event.target.value ? Number(event.target.value) : null
setRankUpdatingId(row.id)
try {
const updated = await updateUserRank(row.id, nextRankId)
setUsers((prev) =>
prev.map((user) =>
user.id === row.id ? { ...user, rank: updated.rank } : user
)
)
} catch (err) {
setUsersError(err.message)
} finally {
setRankUpdatingId(null)
}
}}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
),
}, },
{ {
name: '', name: '',
@@ -370,7 +476,16 @@ export default function Acp({ isAdmin }) {
<Button <Button
variant="dark" variant="dark"
title={t('user.edit')} title={t('user.edit')}
onClick={() => console.log('edit user', row)} onClick={() => {
setUserForm({
id: row.id,
name: row.name,
email: row.email,
rankId: row.rank?.id ?? '',
})
setShowUserModal(true)
setUsersError('')
}}
> >
<i className="bi bi-pencil" aria-hidden="true" /> <i className="bi bi-pencil" aria-hidden="true" />
</Button> </Button>
@@ -385,8 +500,9 @@ export default function Acp({ isAdmin }) {
</div> </div>
), ),
}, },
], ]
[t] },
[t, ranks, ranksLoading, rankUpdatingId, userSort]
) )
const userTableStyles = useMemo( const userTableStyles = useMemo(
() => ({ () => ({
@@ -536,6 +652,57 @@ export default function Acp({ isAdmin }) {
} }
}, [isAdmin]) }, [isAdmin])
const refreshRanks = async () => {
setRanksLoading(true)
setRanksError('')
try {
const data = await listRanks()
setRanks(data)
} catch (err) {
setRanksError(err.message)
} finally {
setRanksLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshRanks()
}
}, [isAdmin])
const handleCreateRank = async (event) => {
event.preventDefault()
if (!rankFormName.trim()) return
if (rankFormType === 'image' && !rankFormImage) {
setRanksError(t('rank.badge_image_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const created = await createRank({
name: rankFormName.trim(),
badge_type: rankFormType,
badge_text: rankFormType === 'text' ? rankFormText.trim() || rankFormName.trim() : null,
})
let next = created
if (rankFormType === 'image' && rankFormImage) {
const updated = await uploadRankBadgeImage(created.id, rankFormImage)
next = { ...created, ...updated }
}
setRanks((prev) => [...prev, next].sort((a, b) => a.name.localeCompare(b.name)))
setRankFormName('')
setRankFormType('text')
setRankFormText('')
setRankFormImage(null)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}
const getParentId = (forum) => { const getParentId = (forum) => {
if (!forum.parent) return null if (!forum.parent) return null
if (typeof forum.parent === 'string') { if (typeof forum.parent === 'string') {
@@ -1298,17 +1465,23 @@ export default function Acp({ isAdmin }) {
</Tab> </Tab>
<Tab eventKey="users" title={t('acp.users')}> <Tab eventKey="users" title={t('acp.users')}>
{usersError && <p className="text-danger">{usersError}</p>} {usersError && <p className="text-danger">{usersError}</p>}
{ranksError && <p className="text-danger">{ranksError}</p>}
{usersLoading && <p className="bb-muted">{t('acp.loading')}</p>} {usersLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!usersLoading && ( {!usersLoading && (
<DataTable <DataTable
columns={userColumns} columns={userColumns}
data={users} data={filteredUsers}
pagination pagination
striped striped
highlightOnHover={themeMode !== 'dark'} highlightOnHover={themeMode !== 'dark'}
dense dense
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'} theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
customStyles={userTableStyles} customStyles={userTableStyles}
sortIcon={<span className="bb-sort-hidden" aria-hidden="true" />}
defaultSortFieldId="name"
onSort={(column, direction) => {
setUserSort({ columnId: column.id, direction })
}}
paginationComponentOptions={{ paginationComponentOptions={{
rowsPerPageText: t('table.rows_per_page'), rowsPerPageText: t('table.rows_per_page'),
rangeSeparatorText: t('table.range_separator'), rangeSeparatorText: t('table.range_separator'),
@@ -1320,7 +1493,147 @@ export default function Acp({ isAdmin }) {
setUsersPage(1) setUsersPage(1)
}} }}
paginationComponent={UsersPagination} paginationComponent={UsersPagination}
subHeader
subHeaderComponent={
<Form.Control
className="bb-user-search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/> />
}
/>
)}
</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">
<Button
type="button"
className="bb-accent-button"
onClick={handleCreateRank}
disabled={rankSaving || !rankFormName.trim()}
>
{rankSaving ? t('form.saving') : t('rank.create')}
</Button>
</Col>
</Row>
{ranksLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!ranksLoading && ranks.length === 0 && (
<p className="bb-muted">{t('rank.empty')}</p>
)}
{!ranksLoading && ranks.length > 0 && (
<div className="bb-rank-list">
{ranks.map((rank) => (
<div key={rank.id} className="bb-rank-row">
<div className="bb-rank-main">
<span>{rank.name}</span>
{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"
onClick={() => {
setRankEdit({
id: rank.id,
name: rank.name,
badgeType: rank.badge_type || 'text',
badgeText: rank.badge_text || '',
badgeImageUrl: rank.badge_image_url || '',
})
setRankEditImage(null)
setShowRankModal(true)
setRanksError('')
}}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="dark"
onClick={async () => {
if (!window.confirm(t('rank.delete_confirm'))) return
setRankSaving(true)
setRanksError('')
try {
await deleteRank(rank.id)
setRanks((prev) => prev.filter((item) => item.id !== rank.id))
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)} )}
</Tab> </Tab>
</Tabs> </Tabs>
@@ -1410,6 +1723,221 @@ export default function Acp({ isAdmin }) {
</Form> </Form>
</Modal.Body> </Modal.Body>
</Modal> </Modal>
<Modal
show={showUserModal}
onHide={() => setShowUserModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('user.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{usersError && <p className="text-danger">{usersError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
setUserSaving(true)
setUsersError('')
try {
const payload = {
name: userForm.name,
email: userForm.email,
rank_id: userForm.rankId ? Number(userForm.rankId) : null,
}
const updated = await updateUser(userForm.id, payload)
setUsers((prev) =>
prev.map((user) =>
user.id === updated.id ? { ...user, ...updated } : user
)
)
setShowUserModal(false)
} catch (err) {
setUsersError(err.message)
} finally {
setUserSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('form.username')}</Form.Label>
<Form.Control
value={userForm.name}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={userForm.email}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, email: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('user.rank')}</Form.Label>
<Form.Select
value={userForm.rankId ?? ''}
onChange={(event) =>
setUserForm((prev) => ({ ...prev, rankId: event.target.value }))
}
disabled={ranksLoading}
>
<option value="">{t('user.rank_unassigned')}</option>
{ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowUserModal(false)}
disabled={userSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={userSaving}>
{userSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
<Modal
show={showRankModal}
onHide={() => setShowRankModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>{t('rank.edit_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{ranksError && <p className="text-danger">{ranksError}</p>}
<Form
onSubmit={async (event) => {
event.preventDefault()
if (!rankEdit.name.trim()) return
if (rankEdit.badgeType === 'text' && !rankEdit.badgeText.trim()) {
setRanksError(t('rank.badge_text_required'))
return
}
setRankSaving(true)
setRanksError('')
try {
const updated = await updateRank(rankEdit.id, {
name: rankEdit.name.trim(),
badge_type: rankEdit.badgeType,
badge_text:
rankEdit.badgeType === 'text'
? rankEdit.badgeText.trim() || rankEdit.name.trim()
: null,
})
let next = updated
if (rankEdit.badgeType === 'image' && rankEditImage) {
const upload = await uploadRankBadgeImage(rankEdit.id, rankEditImage)
next = { ...updated, ...upload }
}
setRanks((prev) =>
prev
.map((item) => (item.id === next.id ? { ...item, ...next } : item))
.sort((a, b) => a.name.localeCompare(b.name))
)
setShowRankModal(false)
} catch (err) {
setRanksError(err.message)
} finally {
setRankSaving(false)
}
}}
>
<Form.Group className="mb-3">
<Form.Label>{t('rank.name')}</Form.Label>
<Form.Control
value={rankEdit.name}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, name: event.target.value }))
}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_type')}</Form.Label>
<div className="d-flex gap-3">
<Form.Check
type="radio"
id="rank-edit-badge-text"
name="rankEditBadgeType"
label={t('rank.badge_text')}
checked={rankEdit.badgeType === 'text'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'text' }))
}
/>
<Form.Check
type="radio"
id="rank-edit-badge-image"
name="rankEditBadgeType"
label={t('rank.badge_image')}
checked={rankEdit.badgeType === 'image'}
onChange={() =>
setRankEdit((prev) => ({ ...prev, badgeType: 'image' }))
}
/>
</div>
</Form.Group>
{rankEdit.badgeType === 'text' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_text')}</Form.Label>
<Form.Control
value={rankEdit.badgeText}
onChange={(event) =>
setRankEdit((prev) => ({ ...prev, badgeText: event.target.value }))
}
placeholder={t('rank.badge_text_placeholder')}
required
/>
</Form.Group>
)}
{rankEdit.badgeType === 'image' && (
<Form.Group className="mb-3">
<Form.Label>{t('rank.badge_image')}</Form.Label>
{rankEdit.badgeImageUrl && !rankEditImage && (
<div className="bb-rank-badge-preview">
<img src={rankEdit.badgeImageUrl} alt="" />
</div>
)}
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
onChange={(event) => setRankEditImage(event.target.files?.[0] || null)}
/>
</Form.Group>
)}
<div className="d-flex justify-content-end gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => setShowRankModal(false)}
disabled={rankSaving}
>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={rankSaving}>
{rankSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
</Container> </Container>
) )
} }

View File

@@ -45,6 +45,15 @@ export default function ThreadView() {
} }
const replyCount = posts.length const replyCount = posts.length
const formatDate = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear())
return `${day}.${month}.${year}`
}
const allPosts = useMemo(() => { const allPosts = useMemo(() => {
if (!thread) return posts if (!thread) return posts
const rootPost = { const rootPost = {
@@ -53,6 +62,12 @@ export default function ThreadView() {
created_at: thread.created_at, created_at: thread.created_at,
user_name: thread.user_name, user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url, user_avatar_url: thread.user_avatar_url,
user_posts_count: thread.user_posts_count,
user_created_at: thread.user_created_at,
user_rank_name: thread.user_rank_name,
user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text,
user_rank_badge_url: thread.user_rank_badge_url,
isRoot: true, isRoot: true,
} }
return [rootPost, ...posts] return [rootPost, ...posts]
@@ -107,11 +122,17 @@ export default function ThreadView() {
</div> </div>
<div className="bb-posts"> <div className="bb-posts">
{allPosts.map((post) => { {allPosts.map((post, index) => {
const authorName = post.author?.username const authorName = post.author?.username
|| post.user_name || post.user_name
|| post.author_name || post.author_name
|| t('thread.anonymous') || t('thread.anonymous')
const topicLabel = thread?.title
? post.isRoot
? thread.title
: `${t('thread.reply_prefix')} ${thread.title}`
: ''
const postNumber = index + 1
return ( return (
<article className="bb-post-row" key={post.id}> <article className="bb-post-row" key={post.id}>
@@ -124,16 +145,30 @@ export default function ThreadView() {
)} )}
</div> </div>
<div className="bb-post-author-name">{authorName}</div> <div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">Operator</div> <div className="bb-post-author-role">
<div className="bb-post-author-badge">TEAM-RHF</div> {post.user_rank_name || ''}
</div>
{(post.user_rank_badge_text || post.user_rank_badge_url) && (
<div className="bb-post-author-badge">
{post.user_rank_badge_type === 'image' && post.user_rank_badge_url ? (
<img src={post.user_rank_badge_url} alt="" />
) : (
<span>{post.user_rank_badge_text}</span>
)}
</div>
)}
<div className="bb-post-author-meta"> <div className="bb-post-author-meta">
<div className="bb-post-author-stat"> <div className="bb-post-author-stat">
<span className="bb-post-author-label">Posts:</span> <span className="bb-post-author-label">{t('thread.posts')}:</span>
<span className="bb-post-author-value">63899</span> <span className="bb-post-author-value">
{post.user_posts_count ?? 0}
</span>
</div> </div>
<div className="bb-post-author-stat"> <div className="bb-post-author-stat">
<span className="bb-post-author-label">Registered:</span> <span className="bb-post-author-label">{t('thread.registered')}:</span>
<span className="bb-post-author-value">18.08.2004 18:50:03</span> <span className="bb-post-author-value">
{formatDate(post.user_created_at)}
</span>
</div> </div>
<div className="bb-post-author-stat"> <div className="bb-post-author-stat">
<span className="bb-post-author-label">Location:</span> <span className="bb-post-author-label">Location:</span>
@@ -158,6 +193,11 @@ export default function ThreadView() {
<div className="bb-post-content"> <div className="bb-post-content">
<div className="bb-post-header"> <div className="bb-post-header">
<div className="bb-post-header-meta"> <div className="bb-post-header-meta">
{topicLabel && (
<span className="bb-post-topic">
#{postNumber} {topicLabel}
</span>
)}
<span>{t('thread.by')} {authorName}</span> <span>{t('thread.by')} {authorName}</span>
{post.created_at && ( {post.created_at && (
<span>{post.created_at.slice(0, 10)}</span> <span>{post.created_at.slice(0, 10)}</span>

View File

@@ -44,6 +44,7 @@
"acp.show_header_name": "Forenname im Header anzeigen", "acp.show_header_name": "Forenname im Header anzeigen",
"acp.add_category": "Kategorie hinzufügen", "acp.add_category": "Kategorie hinzufügen",
"acp.add_forum": "Forum hinzufügen", "acp.add_forum": "Forum hinzufügen",
"acp.ranks": "Ränge",
"acp.forums_parent_root": "Wurzel (kein Parent)", "acp.forums_parent_root": "Wurzel (kein Parent)",
"acp.forums_tree": "Forenbaum", "acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ", "acp.forums_type": "Typ",
@@ -101,6 +102,22 @@
"user.id": "ID", "user.id": "ID",
"user.name": "Name", "user.name": "Name",
"user.email": "E-Mail", "user.email": "E-Mail",
"user.rank": "Rang",
"user.rank_unassigned": "Nicht zugewiesen",
"user.edit_title": "Benutzer bearbeiten",
"user.search": "Benutzer suchen...",
"rank.name": "Rangname",
"rank.name_placeholder": "z. B. Operator",
"rank.create": "Rang erstellen",
"rank.edit_title": "Rang bearbeiten",
"rank.badge_type": "Badge-Typ",
"rank.badge_text": "Text-Badge",
"rank.badge_image": "Bild-Badge",
"rank.badge_text_placeholder": "z. B. TEAM-RHF",
"rank.badge_text_required": "Badge-Text ist erforderlich.",
"rank.badge_image_required": "Badge-Bild ist erforderlich.",
"rank.delete_confirm": "Diesen Rang löschen?",
"rank.empty": "Noch keine Ränge vorhanden.",
"user.roles": "Rollen", "user.roles": "Rollen",
"user.actions": "Aktionen", "user.actions": "Aktionen",
"user.impersonate": "Imitieren", "user.impersonate": "Imitieren",
@@ -173,6 +190,9 @@
"thread.label": "Thread", "thread.label": "Thread",
"thread.loading": "Thread wird geladen...", "thread.loading": "Thread wird geladen...",
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.", "thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
"thread.posts": "Beiträge",
"thread.reply_prefix": "Aw:",
"thread.registered": "Registriert",
"thread.replies": "Antworten", "thread.replies": "Antworten",
"thread.views": "Zugriffe", "thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag", "thread.last_post": "Letzter Beitrag",

View File

@@ -44,6 +44,7 @@
"acp.show_header_name": "Display name in header", "acp.show_header_name": "Display name in header",
"acp.add_category": "Add category", "acp.add_category": "Add category",
"acp.add_forum": "Add forum", "acp.add_forum": "Add forum",
"acp.ranks": "Ranks",
"acp.forums_parent_root": "Root (no parent)", "acp.forums_parent_root": "Root (no parent)",
"acp.forums_tree": "Forum tree", "acp.forums_tree": "Forum tree",
"acp.forums_type": "Type", "acp.forums_type": "Type",
@@ -101,6 +102,22 @@
"user.id": "ID", "user.id": "ID",
"user.name": "Name", "user.name": "Name",
"user.email": "Email", "user.email": "Email",
"user.rank": "Rank",
"user.rank_unassigned": "Unassigned",
"user.edit_title": "Edit user",
"user.search": "Search users...",
"rank.name": "Rank name",
"rank.name_placeholder": "e.g. Operator",
"rank.create": "Create rank",
"rank.edit_title": "Edit rank",
"rank.badge_type": "Badge type",
"rank.badge_text": "Text badge",
"rank.badge_image": "Image badge",
"rank.badge_text_placeholder": "e.g. TEAM-RHF",
"rank.badge_text_required": "Badge text is required.",
"rank.badge_image_required": "Badge image is required.",
"rank.delete_confirm": "Delete this rank?",
"rank.empty": "No ranks created yet.",
"user.roles": "Roles", "user.roles": "Roles",
"user.actions": "Actions", "user.actions": "Actions",
"user.impersonate": "Impersonate", "user.impersonate": "Impersonate",
@@ -173,6 +190,9 @@
"thread.label": "Thread", "thread.label": "Thread",
"thread.loading": "Loading thread...", "thread.loading": "Loading thread...",
"thread.login_hint": "Log in to reply to this thread.", "thread.login_hint": "Log in to reply to this thread.",
"thread.posts": "Posts",
"thread.reply_prefix": "Re:",
"thread.registered": "Registered",
"thread.replies": "Replies", "thread.replies": "Replies",
"thread.views": "Views", "thread.views": "Views",
"thread.last_post": "Last post", "thread.last_post": "Last post",

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\UploadController;
use App\Http\Controllers\UserSettingController; use App\Http\Controllers\UserSettingController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController; use App\Http\Controllers\VersionController;
use App\Http\Controllers\RankController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
@@ -33,8 +34,15 @@ Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->midd
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum'); Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
Route::get('/i18n/{locale}', I18nController::class); Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum'); Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum'); Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->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('/ranks', [RankController::class, 'index'])->middleware('auth:sanctum');
Route::post('/ranks', [RankController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
Route::get('/forums', [ForumController::class, 'index']); Route::get('/forums', [ForumController::class, 'index']);
Route::get('/forums/{forum}', [ForumController::class, 'show']); Route::get('/forums/{forum}', [ForumController::class, 'show']);