UI: portal/header refinements, board index UX, and user settings

This commit is contained in:
Micha
2026-01-01 19:54:02 +01:00
parent f83748cc76
commit 8604cdf95d
26 changed files with 2065 additions and 227 deletions

View File

@@ -39,3 +39,17 @@
- Updated ACP forum tools with accent-tinted buttons and larger action spacing.
- Defaulted the ACP forum tree to collapsed on page load.
- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation.
## 2025-12-30
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
- Added thread seeding for forum test data.
- Enforced category-only roots for forums (API validation, UI, and database constraint).
- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu.
- Replaced the home page with a portal-style layout and latest posts list.
- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles.
- Persisted board index collapse state per user via user_settings (DB + API + client cache).
- Added category view rendering for subcategories in ForumView.
- Updated thread list UI (icons, spacing) and New topic button styling in ForumView.
- Added ACP per-category quick-create buttons for child categories and forums.
- Removed the legacy navbar and cleaned up related styling.

View File

@@ -11,7 +11,7 @@ class ForumController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Forum::query();
$query = Forum::query()->withoutTrashed();
$parentParam = $request->query('parent');
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
@@ -57,6 +57,10 @@ class ForumController extends Controller
$parentId = $this->parseIriId($data['parent'] ?? null);
if ($data['type'] === 'forum' && !$parentId) {
return response()->json(['message' => 'Forums must belong to a category.'], 422);
}
if ($parentId) {
$parent = Forum::findOrFail($parentId);
if ($parent->type !== 'category') {
@@ -87,6 +91,12 @@ class ForumController extends Controller
]);
$parentId = $this->parseIriId($data['parent'] ?? null);
$nextType = $data['type'] ?? $forum->type;
$nextParentId = array_key_exists('parent', $data) ? $parentId : $forum->parent_id;
if ($nextType === 'forum' && !$nextParentId) {
return response()->json(['message' => 'Forums must belong to a category.'], 422);
}
if (array_key_exists('parent', $data)) {
if ($parentId) {
@@ -115,8 +125,10 @@ class ForumController extends Controller
return response()->json($this->serializeForum($forum));
}
public function destroy(Forum $forum): JsonResponse
public function destroy(Request $request, Forum $forum): JsonResponse
{
$forum->deleted_by = $request->user()?->id;
$forum->save();
$forum->delete();
return response()->json(null, 204);

View File

@@ -11,7 +11,7 @@ class PostController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Post::query();
$query = Post::query()->withoutTrashed();
$threadParam = $request->query('thread');
if (is_string($threadParam)) {
@@ -48,8 +48,10 @@ class PostController extends Controller
return response()->json($this->serializePost($post), 201);
}
public function destroy(Post $post): JsonResponse
public function destroy(Request $request, Post $post): JsonResponse
{
$post->deleted_by = $request->user()?->id;
$post->save();
$post->delete();
return response()->json(null, 204);

View File

@@ -11,7 +11,7 @@ class ThreadController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Thread::query();
$query = Thread::query()->withoutTrashed()->with('user');
$forumParam = $request->query('forum');
if (is_string($forumParam)) {
@@ -31,6 +31,7 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse
{
$thread->loadMissing('user');
return response()->json($this->serializeThread($thread));
}
@@ -59,8 +60,10 @@ class ThreadController extends Controller
return response()->json($this->serializeThread($thread), 201);
}
public function destroy(Thread $thread): JsonResponse
public function destroy(Request $request, Thread $thread): JsonResponse
{
$thread->deleted_by = $request->user()?->id;
$thread->save();
$thread->delete();
return response()->json(null, 204);
@@ -91,6 +94,7 @@ class ThreadController extends Controller
'body' => $thread->body,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'user_name' => $thread->user?->name,
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
];

View File

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

View File

@@ -5,6 +5,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;
/**
* @property int $id
@@ -35,6 +36,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class Forum extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'description',

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
@@ -27,6 +28,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class Post extends Model
{
use SoftDeletes;
protected $fillable = [
'thread_id',
'user_id',

View File

@@ -5,6 +5,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;
/**
* @property int $id
@@ -32,6 +33,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class Thread extends Model
{
use SoftDeletes;
protected $fillable = [
'forum_id',
'user_id',

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('forums', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_forums_deleted_meta');
});
Schema::table('threads', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_threads_deleted_meta');
});
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->index(['deleted_at', 'deleted_by'], 'idx_posts_deleted_meta');
});
}
public function down(): void
{
Schema::table('forums', function (Blueprint $table) {
$table->dropIndex('idx_forums_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
Schema::table('threads', function (Blueprint $table) {
$table->dropIndex('idx_threads_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
Schema::table('posts', function (Blueprint $table) {
$table->dropIndex('idx_posts_deleted_meta');
$table->dropConstrainedForeignId('deleted_by');
$table->dropSoftDeletes();
});
}
};

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder
RoleSeeder::class,
UserSeeder::class,
ForumSeeder::class,
ThreadSeeder::class,
]);
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Container, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home'
import ForumView from './pages/ForumView'
@@ -8,96 +8,197 @@ import ThreadView from './pages/ThreadView'
import Login from './pages/Login'
import Register from './pages/Register'
import Acp from './pages/Acp'
import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
import { useTranslation } from 'react-i18next'
import { fetchSetting, fetchVersion } from './api/client'
import { fetchSetting, fetchVersion, getForum, getThread } from './api/client'
function Navigation({ theme, onThemeChange }) {
const { token, email, logout, isAdmin } = useAuth()
const { t, i18n } = useTranslation()
function PortalHeader({ userMenu, isAuthenticated }) {
const { t } = useTranslation()
const location = useLocation()
const [crumbs, setCrumbs] = useState([])
const handleLanguageChange = (locale) => {
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
useEffect(() => {
let active = true
const handleThemeChange = (value) => {
onThemeChange(value)
localStorage.setItem('speedbb_theme', value)
}
const parseForumId = (parent) => {
if (!parent) return null
if (typeof parent === 'string') {
const parts = parent.split('/')
return parts[parts.length - 1] || null
}
if (typeof parent === 'object' && parent.id) {
return parent.id
}
return null
}
const buildForumChain = async (forum) => {
const chain = []
let cursor = forum
while (cursor) {
chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` })
const parentId = parseForumId(cursor.parent)
if (!parentId) break
cursor = await getForum(parentId)
}
return chain
}
const buildCrumbs = async () => {
const base = [
{ label: t('portal.portal'), to: '/' },
{ label: t('portal.board_index'), to: '/forums' },
]
if (location.pathname === '/') {
setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
return
}
if (location.pathname === '/forums') {
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
return
}
if (location.pathname.startsWith('/forum/')) {
const forumId = location.pathname.split('/')[2]
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
setCrumbs([...base, ...chain.map((crumb, idx) => ({
...crumb,
current: idx === chain.length - 1,
}))])
return
}
}
if (location.pathname.startsWith('/thread/')) {
const threadId = location.pathname.split('/')[2]
if (threadId) {
const thread = await getThread(threadId)
const forumId = thread?.forum?.split('/').pop()
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
const chainWithCurrent = chain.map((crumb, index) => ({
...crumb,
current: index === chain.length - 1,
}))
setCrumbs([...base, ...chainWithCurrent])
return
}
}
}
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
}
buildCrumbs()
return () => {
active = false
}
}, [location.pathname, t])
return (
<Navbar expand="lg" className="bb-nav">
<Container>
<Navbar.Brand as={Link} to="/" className="fw-semibold">
{t('app.brand')}
</Navbar.Brand>
{isAdmin && (
<Nav className="me-auto">
<Nav.Link as={Link} to="/acp">
{t('nav.acp')}
</Nav.Link>
</Nav>
)}
<Navbar.Toggle aria-controls="bb-nav" />
<Navbar.Collapse id="bb-nav">
<Nav className="ms-auto align-items-lg-center gap-2">
{!token && (
<Container className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
<div className="bb-portal-logo">24unix.net</div>
<div className="bb-portal-tagline">{t('portal.tagline')}</div>
</div>
<div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
<span className="bb-portal-search-icon">
<i className="bi bi-search" aria-hidden="true" />
</span>
</div>
</div>
<div className="bb-portal-bars">
<div className="bb-portal-bar bb-portal-bar--top">
<div className="bb-portal-bar-left">
<span className="bb-portal-bar-title">
<i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')}
</span>
<div className="bb-portal-bar-links">
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
</div>
</div>
<div
className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`}
>
{isAuthenticated ? (
<>
<Nav.Link as={Link} to="/login">
{t('nav.login')}
</Nav.Link>
<Nav.Link as={Link} to="/register">
{t('nav.register')}
</Nav.Link>
<span>
<i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')}
</span>
<span>
<i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')}
</span>
{userMenu}
</>
) : (
<>
<Link to="/register" className="bb-portal-user-link">
<i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')}
</Link>
<Link to="/login" className="bb-portal-user-link">
<i className="bi bi-power" aria-hidden="true" /> {t('nav.login')}
</Link>
</>
)}
{token && (
<>
<span className="bb-chip">{email}</span>
<Nav.Link onClick={logout}>{t('nav.logout')}</Nav.Link>
</>
)}
<NavDropdown title={t('nav.language')} align="end">
<NavDropdown.Item onClick={() => handleLanguageChange('en')}>
English
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleLanguageChange('de')}>
Deutsch
</NavDropdown.Item>
</NavDropdown>
<NavDropdown title={t('nav.theme')} align="end">
<NavDropdown.Item onClick={() => handleThemeChange('auto')} active={theme === 'auto'}>
{t('nav.theme_auto')}
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleThemeChange('light')} active={theme === 'light'}>
{t('nav.theme_light')}
</NavDropdown.Item>
<NavDropdown.Item onClick={() => handleThemeChange('dark')} active={theme === 'dark'}>
{t('nav.theme_dark')}
</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
</div>
</div>
<div className="bb-portal-bar bb-portal-bar--bottom">
<div className="bb-portal-breadcrumb">
{crumbs.map((crumb, index) => (
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>}
{crumb.current ? (
<span className="bb-portal-current">
{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 to={crumb.to} className="bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label}
</Link>
)}
</span>
))}
</div>
</div>
</div>
</Container>
)
}
function AppShell() {
const { t } = useTranslation()
const { isAdmin } = useAuth()
const [loadMs, setLoadMs] = useState(null)
const { token, email, logout, isAdmin } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
useEffect(() => {
const [entry] = performance.getEntriesByType('navigation')
if (entry?.duration) {
setLoadMs(Math.round(entry.duration))
return
}
setLoadMs(Math.round(performance.now()))
}, [])
const [accentOverride, setAccentOverride] = useState(
() => localStorage.getItem('speedbb_accent') || ''
)
useEffect(() => {
fetchVersion()
@@ -108,12 +209,21 @@ function AppShell() {
useEffect(() => {
fetchSetting('accent_color')
.then((setting) => {
if (setting?.value) {
if (setting?.value && !accentOverride) {
document.documentElement.style.setProperty('--bb-accent', setting.value)
}
})
.catch(() => {})
}, [])
}, [accentOverride])
useEffect(() => {
if (accentOverride) {
document.documentElement.style.setProperty('--bb-accent', accentOverride)
localStorage.setItem('speedbb_accent', accentOverride)
} else {
localStorage.removeItem('speedbb_accent')
}
}, [accentOverride])
useEffect(() => {
const root = document.documentElement
@@ -144,14 +254,53 @@ function AppShell() {
return (
<div className="bb-shell">
<Navigation theme={theme} onThemeChange={setTheme} />
<PortalHeader
isAuthenticated={!!token}
userMenu={
token ? (
<NavDropdown
title={
<span className="bb-user-menu">
<span className="bb-user-menu__name">{email}</span>
<i className="bi bi-caret-down-fill" aria-hidden="true" />
</span>
}
align="end"
className="bb-user-menu__dropdown"
>
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={logout}>
<i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')}
</NavDropdown.Item>
</NavDropdown>
) : null
}
/>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"
element={
<Ucp
theme={theme}
setTheme={setTheme}
accentOverride={accentOverride}
setAccentOverride={setAccentOverride}
/>
}
/>
</Routes>
<footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3">
@@ -165,12 +314,6 @@ function AppShell() {
<span className="bb-version-label">)</span>
</span>
)}
{loadMs !== null && (
<span className="bb-load-time">
<span className="bb-load-label">Page load time</span>{' '}
<span className="bb-load-value">{loadMs}ms</span>
</span>
)}
</div>
</footer>
</div>

View File

@@ -79,6 +79,18 @@ export async function fetchSetting(key) {
return data[0] || null
}
export async function fetchUserSetting(key) {
const data = await getCollection(`/user-settings?key=${encodeURIComponent(key)}&pagination=false`)
return data[0] || null
}
export async function saveUserSetting(key, value) {
return apiFetch('/user-settings', {
method: 'POST',
body: JSON.stringify({ key, value }),
})
}
export async function listForumsByParent(parentId) {
return getCollection(`/forums?parent=/api/forums/${parentId}`)
}
@@ -134,6 +146,10 @@ export async function listThreadsByForum(forumId) {
return getCollection(`/threads?forum=/api/forums/${forumId}`)
}
export async function listThreads() {
return getCollection('/threads')
}
export async function getThread(id) {
return apiFetch(`/threads/${id}`)
}

View File

@@ -37,12 +37,6 @@ a {
flex-direction: column;
}
.bb-nav {
backdrop-filter: blur(10px);
background: rgba(247, 242, 234, 0.9);
border-bottom: 1px solid var(--bb-border);
}
.bb-hero {
background: linear-gradient(135deg, rgba(21, 122, 110, 0.08), rgba(228, 166, 52, 0.1));
border: 1px solid var(--bb-border);
@@ -123,10 +117,6 @@ a {
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
}
[data-bs-theme="dark"] .bb-nav {
background: rgba(15, 18, 26, 0.9);
}
[data-bs-theme="dark"] .bb-hero {
background: linear-gradient(135deg, rgba(21, 122, 110, 0.12), rgba(228, 166, 52, 0.08));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
@@ -245,16 +235,825 @@ a {
color: #0f1218;
}
.bb-load-time {
.bb-topic-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0.8rem;
border-radius: 10px;
background: linear-gradient(180deg, rgba(16, 20, 29, 0.9), rgba(12, 15, 22, 0.9));
border: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-topic-toolbar__left,
.bb-topic-toolbar__right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.bb-topic-action {
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.bb-load-label {
color: inherit;
.bb-topic-count {
color: var(--bb-ink-muted);
font-size: 0.9rem;
}
.bb-load-value {
color: #fff;
.bb-topic-pagination {
display: inline-flex;
gap: 0.35rem;
}
.bb-topic-pagination .btn {
border-radius: 6px;
font-weight: 600;
}
.bb-topic-pagination .btn.is-active {
background: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
color: #0f1218;
opacity: 1;
}
.bb-topic-table {
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 18, 26, 0.75);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
.bb-topic-header,
.bb-topic-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 180px;
gap: 0.75rem;
align-items: center;
}
.bb-topic-header {
padding: 0.7rem 1rem;
background: rgba(255, 255, 255, 0.03);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bb-ink-muted);
}
.bb-topic-row {
padding: 0.9rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-topic-cell--replies,
.bb-topic-cell--views,
.bb-topic-cell--last {
text-align: center;
color: var(--bb-ink-muted);
font-weight: 600;
font-size: 0.9rem;
}
.bb-topic-title a {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-topic-title {
display: flex;
align-items: center;
gap: 0.6rem;
}
.bb-topic-icon {
width: 38px;
height: 38px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
color: var(--bb-ink-muted);
font-size: 1.05rem;
}
.bb-topic-title a:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-topic-meta {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--bb-ink-muted);
margin-top: 0.15rem;
}
.bb-topic-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.bb-topic-meta i {
color: var(--bb-ink-muted);
}
.bb-topic-author {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-topic-date {
color: var(--bb-ink-muted);
}
.bb-topic-meta .badge {
background: rgba(255, 255, 255, 0.08);
color: var(--bb-ink);
font-weight: 600;
}
.bb-topic-snippet {
margin-top: 0.35rem;
color: var(--bb-ink-muted);
font-size: 0.9rem;
}
.bb-topic-empty {
padding: 1rem;
text-align: center;
color: var(--bb-ink-muted);
}
.bb-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
font-size: 0.95rem;
padding: 0.45rem 0.75rem;
border-radius: 10px;
background: rgba(15, 18, 26, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-breadcrumb__item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.bb-breadcrumb__sep {
color: var(--bb-ink-muted);
}
.bb-breadcrumb__link {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-breadcrumb__link:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-breadcrumb__current {
color: var(--bb-ink);
font-weight: 600;
}
.bb-portal-shell {
max-width: 1400px;
}
.bb-portal-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.2rem 1.6rem;
border-radius: 14px;
background: linear-gradient(145deg, rgba(22, 28, 40, 0.9), rgba(12, 15, 22, 0.9));
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}
.bb-portal-brand {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bb-portal-logo {
font-size: 1.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bb-accent, #f29b3f);
font-weight: 700;
}
.bb-portal-tagline {
color: var(--bb-ink-muted);
font-size: 0.95rem;
}
.bb-portal-search {
position: relative;
width: 260px;
}
.bb-portal-search input {
width: 100%;
padding: 0.4rem 2rem 0.4rem 0.7rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--bb-ink);
font-size: 0.9rem;
}
.bb-portal-search input::placeholder {
color: var(--bb-ink-muted);
}
.bb-portal-search-icon {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
color: var(--bb-ink-muted);
}
.bb-portal-bars {
margin-top: 0.8rem;
display: flex;
flex-direction: column;
gap: 0;
}
.bb-portal-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.45rem 0.9rem;
border-radius: 10px;
background: rgba(20, 25, 36, 0.85);
border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.9rem;
}
.bb-portal-bar--top {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 0;
}
.bb-portal-bar--bottom {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.bb-portal-bar-title {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.75rem;
}
.bb-portal-bar-title i,
.bb-portal-bar-links i {
color: var(--bb-accent, #f29b3f);
margin-right: 0.35rem;
}
.bb-portal-bar-left {
display: inline-flex;
align-items: center;
gap: 0.8rem;
}
.bb-portal-bar-links,
.bb-portal-user-links {
display: inline-flex;
gap: 0.8rem;
color: var(--bb-ink-muted);
}
.bb-portal-bar-links span {
color: var(--bb-accent, #f29b3f);
}
.bb-portal-user-links--guest {
color: var(--bb-accent, #f29b3f);
}
.bb-portal-user-link {
color: var(--bb-accent, #f29b3f);
text-decoration: none;
font-weight: 600;
}
.bb-portal-user-link i {
color: var(--bb-accent, #f29b3f);
margin-right: 0.35rem;
}
.bb-portal-user-link:hover {
color: var(--bb-accent, #f29b3f);
text-decoration: none;
}
.bb-portal-user-links i {
color: var(--bb-accent, #f29b3f);
margin-right: 0.35rem;
}
.bb-portal-user-links span {
color: var(--bb-accent, #f29b3f);
}
.bb-user-menu {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-user-menu__avatar {
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bb-ink);
font-size: 0.85rem;
text-transform: uppercase;
}
.bb-user-menu__name {
color: var(--bb-accent, #f29b3f);
}
.bb-user-menu__dropdown .dropdown-menu {
background: rgba(24, 29, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.bb-user-menu__dropdown .dropdown-toggle::after {
display: none;
}
.bb-user-menu__dropdown .dropdown-item {
color: var(--bb-accent, #f29b3f);
display: flex;
align-items: center;
gap: 0.5rem;
}
.bb-user-menu__dropdown .dropdown-item:hover,
.bb-user-menu__dropdown .dropdown-item:focus {
background: rgba(255, 255, 255, 0.08);
color: var(--bb-accent, #f29b3f);
}
.bb-user-menu__dropdown .dropdown-divider {
border-color: rgba(255, 255, 255, 0.12);
}
.bb-portal-breadcrumb {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.bb-portal-link {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-portal-link i {
margin-right: 0.35rem;
font-size: 0.95rem;
}
.bb-portal-current i {
margin-right: 0.35rem;
font-size: 0.95rem;
}
.bb-portal-sep {
color: var(--bb-ink-muted);
margin-right: 0.35rem;
}
.bb-portal-current {
color: var(--bb-ink);
font-weight: 600;
}
.bb-portal-breadcrumb .bb-portal-current {
color: var(--bb-accent, #f29b3f);
}
.bb-portal-layout {
margin-top: 0.6rem;
display: grid;
grid-template-columns: 220px minmax(0, 1fr) 220px;
gap: 1.2rem;
}
.bb-board-index {
display: grid;
gap: 1.2rem;
}
.bb-board-section {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(18, 23, 33, 0.9);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
}
.bb-board-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.75rem;
color: var(--bb-accent, #f29b3f);
}
.bb-board-section__controls {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.bb-board-section__title {
color: var(--bb-accent, #f29b3f);
font-weight: 700;
}
.bb-board-section__cols {
display: grid;
grid-template-columns: 90px 90px 180px;
gap: 0.6rem;
text-align: center;
min-width: 360px;
}
.bb-board-toggle {
border: 0;
background: none;
padding: 0;
color: var(--bb-accent, #f29b3f);
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bb-board-toggle:hover {
color: var(--bb-accent, #f29b3f);
}
.bb-board-section__body {
display: grid;
}
.bb-board-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 180px;
gap: 0.6rem;
align-items: center;
padding: 0.85rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.bb-board-cell--topics,
.bb-board-cell--posts,
.bb-board-cell--last {
text-align: center;
color: var(--bb-ink-muted);
font-weight: 600;
font-size: 0.9rem;
}
.bb-board-title {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.bb-board-icon {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bb-ink-muted);
font-size: 1rem;
}
.bb-board-link {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-board-link:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-board-desc {
color: var(--bb-ink-muted);
font-size: 0.9rem;
margin-top: 0.2rem;
}
.bb-board-subforums {
margin-top: 0.35rem;
font-size: 0.8rem;
color: var(--bb-ink-muted);
}
.bb-board-subforum-link {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-board-subforum-link:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-board-empty {
padding: 1rem;
text-align: center;
color: var(--bb-ink-muted);
}
@media (max-width: 991px) {
.bb-board-section__cols,
.bb-board-row {
grid-template-columns: minmax(0, 1fr) 70px 70px 140px;
}
}
@media (max-width: 767px) {
.bb-board-section__header {
flex-direction: column;
align-items: flex-start;
}
.bb-board-section__cols,
.bb-board-row {
grid-template-columns: minmax(0, 1fr);
}
.bb-board-cell--topics,
.bb-board-cell--posts,
.bb-board-cell--last {
text-align: left;
margin-top: 0.3rem;
}
}
.bb-portal-column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bb-portal-card {
padding: 1rem;
border-radius: 12px;
background: rgba(18, 23, 33, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
}
.bb-portal-card-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bb-accent, #f29b3f);
font-weight: 700;
margin-bottom: 0.8rem;
}
.bb-portal-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.4rem;
color: var(--bb-ink-muted);
}
.bb-portal-list li {
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.bb-portal-list li:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--bb-ink);
}
.bb-portal-stat {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
color: var(--bb-ink-muted);
padding: 0.2rem 0;
}
.bb-portal-stat strong {
color: var(--bb-ink);
}
.bb-portal-topic-table {
display: grid;
gap: 0.4rem;
}
.bb-portal-topic-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 160px;
gap: 0.6rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bb-ink-muted);
}
.bb-portal-topic-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 90px 160px;
gap: 0.6rem;
align-items: center;
padding: 0.6rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.bb-portal-topic-main {
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.bb-portal-topic-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bb-ink-muted);
}
.bb-portal-topic-title {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-portal-topic-meta {
margin-top: 0.2rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--bb-ink-muted);
}
.bb-portal-topic-forum {
color: var(--bb-ink-muted);
}
.bb-portal-topic-forum-link {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-portal-topic-forum-link:hover {
color: var(--bb-accent, #f29b3f);
text-decoration: none;
}
.bb-portal-topic-cell {
text-align: center;
color: var(--bb-ink-muted);
font-weight: 600;
}
.bb-portal-user-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.8rem;
}
.bb-portal-user-avatar {
width: 72px;
height: 72px;
border-radius: 12px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.04));
border: 1px solid rgba(255, 255, 255, 0.15);
}
.bb-portal-user-name {
font-weight: 600;
}
.bb-portal-user-role {
font-size: 0.75rem;
color: var(--bb-accent, #f29b3f);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.bb-portal-card--ad {
text-align: center;
}
.bb-portal-ad-box {
margin-top: 0.6rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
padding: 3rem 1rem;
color: var(--bb-ink-muted);
font-weight: 600;
}
@media (max-width: 1200px) {
.bb-portal-layout {
grid-template-columns: 200px minmax(0, 1fr) 200px;
}
}
@media (max-width: 991px) {
.bb-portal-layout {
grid-template-columns: 1fr;
}
.bb-portal-banner {
flex-direction: column;
align-items: flex-start;
}
.bb-portal-search {
width: 100%;
}
}
@media (max-width: 991px) {
.bb-topic-header,
.bb-topic-row {
grid-template-columns: minmax(0, 1fr) 70px 70px 140px;
}
}
@media (max-width: 767px) {
.bb-topic-toolbar {
flex-direction: column;
align-items: stretch;
}
.bb-topic-header,
.bb-topic-row {
grid-template-columns: minmax(0, 1fr);
}
.bb-topic-cell--replies,
.bb-topic-cell--views,
.bb-topic-cell--last {
text-align: left;
margin-top: 0.4rem;
}
}
.bb-user-actions {

View File

@@ -12,6 +12,7 @@ export default function Acp({ isAdmin }) {
const [selectedId, setSelectedId] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [overId, setOverId] = useState(null)
const pendingOrder = useRef(null)
const [createType, setCreateType] = useState(null)
const [users, setUsers] = useState([])
const [usersLoading, setUsersLoading] = useState(false)
@@ -232,7 +233,7 @@ export default function Acp({ isAdmin }) {
setError('')
try {
const data = await listAllForums()
setForums(data)
setForums(data.filter((forum) => !forum.deleted_at))
} catch (err) {
setError(err.message)
} finally {
@@ -394,6 +395,18 @@ export default function Acp({ isAdmin }) {
})
}
const handleStartCreateChild = (type, parentId) => {
setSelectedId(null)
setShowModal(true)
setCreateType(type)
setForm({
name: '',
description: '',
type,
parentId: parentId ? String(parentId) : '',
})
}
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
@@ -402,6 +415,10 @@ export default function Acp({ isAdmin }) {
setError(t('acp.forums_name_required'))
return
}
if (form.type === 'forum' && !form.parentId) {
setError(t('acp.forums_parent_required'))
return
}
try {
if (selectedId) {
await updateForum(selectedId, {
@@ -448,6 +465,11 @@ export default function Acp({ isAdmin }) {
}
const handleDragEnd = () => {
if (pendingOrder.current) {
const { parentId, ordered } = pendingOrder.current
pendingOrder.current = null
reorderForums(parentId, ordered).catch((err) => setError(err.message))
}
setDraggingId(null)
setOverId(null)
}
@@ -504,6 +526,7 @@ export default function Acp({ isAdmin }) {
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
setOverId(String(targetId))
applyLocalOrder(parentId, ordered)
pendingOrder.current = { parentId, ordered }
}
const handleDragEnter = (forumId) => {
@@ -550,6 +573,7 @@ export default function Acp({ isAdmin }) {
}
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
pendingOrder.current = null
try {
await reorderForums(parentId, ordered)
@@ -622,6 +646,24 @@ export default function Acp({ isAdmin }) {
<i className="bi bi-arrow-down-up" aria-hidden="true" />
</span>
<ButtonGroup size="sm" className="bb-action-group">
{node.type === 'category' && (
<>
<Button
variant="dark"
onClick={() => handleStartCreateChild('category', node.id)}
title={t('acp.add_category')}
>
<i className="bi bi-folder-plus" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleStartCreateChild('forum', node.id)}
title={t('acp.add_forum')}
>
<i className="bi bi-chat-left-text" aria-hidden="true" />
</Button>
</>
)}
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
@@ -793,7 +835,9 @@ export default function Acp({ isAdmin }) {
value={form.parentId}
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
>
<option value="">{t('acp.forums_parent_root')}</option>
<option value="" disabled={form.type === 'forum'}>
{t('acp.forums_parent_root')}
</option>
{categoryOptions
.filter((option) => String(option.id) !== String(selectedId))
.map((option) => (

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
import { useAuth } from '../context/AuthContext'
@@ -13,11 +13,36 @@ export default function ForumView() {
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const { t } = useTranslation()
const renderChildRows = (nodes) =>
nodes.map((node) => (
<div className="bb-board-row" key={node.id}>
<div className="bb-board-cell bb-board-cell--title">
<div className="bb-board-title">
<span className="bb-board-icon" aria-hidden="true">
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
</span>
<div>
<Link to={`/forum/${node.id}`} className="bb-board-link">
{node.name}
</Link>
<div className="bb-board-desc">{node.description || t('forum.no_description')}</div>
</div>
</div>
</div>
<div className="bb-board-cell bb-board-cell--topics"></div>
<div className="bb-board-cell bb-board-cell--posts"></div>
<div className="bb-board-cell bb-board-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span>
</div>
</div>
))
useEffect(() => {
let active = true
@@ -62,6 +87,7 @@ export default function ForumView() {
setBody('')
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
@@ -75,106 +101,152 @@ export default function ForumView() {
{error && <p className="text-danger">{error}</p>}
{forum && (
<>
<div className="bb-hero mb-4">
<p className="bb-chip">
{forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')}
</p>
<h2 className="mt-3">{forum.name}</h2>
<p className="bb-muted mb-0">
{forum.description || t('forum.no_description')}
</p>
</div>
<Row className="g-4">
<Col lg={7}>
<h4 className="bb-section-title mb-3">{t('forum.children')}</h4>
{children.length === 0 && (
<p className="bb-muted">{t('forum.empty_children')}</p>
<Col lg={12}>
{forum.type !== 'forum' && (
<div className="bb-board-index">
<section className="bb-board-section">
<header className="bb-board-section__header">
<span className="bb-board-section__title">{forum.name}</span>
<div className="bb-board-section__cols">
<span>{t('portal.topic')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
</header>
<div className="bb-board-section__body">
{children.length > 0 ? (
renderChildRows(children)
) : (
<div className="bb-board-empty">{t('forum.empty_children')}</div>
)}
</div>
</section>
</div>
)}
{children.map((child) => (
<Card className="bb-card mb-3" key={child.id}>
<Card.Body>
<Card.Title>{child.name}</Card.Title>
<Card.Text className="bb-muted">
{child.description || t('forum.no_description')}
</Card.Text>
<Link to={`/forum/${child.id}`} className="stretched-link">
{t('forum.open')}
</Link>
</Card.Body>
</Card>
))}
{forum.type === 'forum' && (
<>
<h4 className="bb-section-title mb-3 mt-4">{t('forum.threads')}</h4>
{threads.length === 0 && (
<p className="bb-muted">{t('forum.empty_threads')}</p>
)}
{threads.map((thread) => (
<Card className="bb-card mb-3" key={thread.id}>
<Card.Body>
<Card.Title>{thread.title}</Card.Title>
<Card.Text className="bb-muted">
{thread.body.length > 160
? `${thread.body.slice(0, 160)}...`
: thread.body}
</Card.Text>
<Link to={`/thread/${thread.id}`} className="stretched-link">
{t('thread.view')}
</Link>
</Card.Body>
</Card>
))}
<div className="bb-topic-toolbar mt-4 mb-2">
<div className="bb-topic-toolbar__left">
<Button
variant="dark"
className="bb-topic-action bb-accent-button"
onClick={() => setShowModal(true)}
disabled={!token || saving}
>
<i className="bi bi-pencil me-2" aria-hidden="true" />
{t('forum.start_thread')}
</Button>
</div>
<div className="bb-topic-toolbar__right">
<span className="bb-topic-count">
{threads.length} {t('forum.threads').toLowerCase()}
</span>
<div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled>
</Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
1
</Button>
<Button size="sm" variant="outline-secondary" disabled>
</Button>
</div>
</div>
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-topic-table">
<div className="bb-topic-header">
<div className="bb-topic-cell bb-topic-cell--title">{t('forum.threads')}</div>
<div className="bb-topic-cell bb-topic-cell--replies">{t('thread.replies')}</div>
<div className="bb-topic-cell bb-topic-cell--views">{t('thread.views')}</div>
<div className="bb-topic-cell bb-topic-cell--last">{t('thread.last_post')}</div>
</div>
{threads.length === 0 && (
<div className="bb-topic-empty">{t('forum.empty_threads')}</div>
)}
{threads.map((thread) => (
<div className="bb-topic-row" key={thread.id}>
<div className="bb-topic-cell bb-topic-cell--title">
<div className="bb-topic-title">
<span className="bb-topic-icon" aria-hidden="true">
<i className="bi bi-chat-left" />
</span>
<div className="bb-topic-text">
<Link to={`/thread/${thread.id}`}>{thread.title}</Link>
<div className="bb-topic-meta">
<i className="bi bi-paperclip" aria-hidden="true" />
<span>{t('thread.by')}</span>
<span className="bb-topic-author">
{thread.user_name || t('thread.anonymous')}
</span>
{thread.created_at && (
<span className="bb-topic-date">
{thread.created_at.slice(0, 10)}
</span>
)}
</div>
</div>
</div>
</div>
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
<div className="bb-topic-cell bb-topic-cell--views"></div>
<div className="bb-topic-cell bb-topic-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span>
</div>
</div>
))}
</div>
</>
)}
</Col>
<Col lg={5}>
<h4 className="bb-section-title mb-3">{t('forum.start_thread')}</h4>
<div className="bb-form">
{forum.type !== 'forum' && (
<p className="bb-muted mb-3">{t('forum.only_forums')}</p>
)}
{forum.type === 'forum' && !token && (
<p className="bb-muted mb-3">{t('forum.login_hint')}</p>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving || forum.type !== 'forum'}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving || forum.type !== 'forum'}
required
/>
</Form.Group>
<Button
type="submit"
variant="dark"
disabled={!token || saving || forum.type !== 'forum'}
>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</Form>
</div>
</Col>
</Row>
</>
)}
{forum?.type === 'forum' && (
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
placeholder={t('form.thread_title_placeholder')}
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Form>
</Modal.Body>
</Modal>
)}
</Container>
)
}

View File

@@ -1,20 +1,29 @@
import { useEffect, useMemo, useState } from 'react'
import { Container } from 'react-bootstrap'
import { Badge, Container } from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { listAllForums } from '../api/client'
import { listAllForums, listThreads } from '../api/client'
import { useTranslation } from 'react-i18next'
export default function Home() {
const [forums, setForums] = useState([])
const [threads, setThreads] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [loadingForums, setLoadingForums] = useState(true)
const [loadingThreads, setLoadingThreads] = useState(true)
const { t } = useTranslation()
useEffect(() => {
listAllForums()
.then(setForums)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
.finally(() => setLoadingForums(false))
}, [])
useEffect(() => {
listThreads()
.then(setThreads)
.catch((err) => setError(err.message))
.finally(() => setLoadingThreads(false))
}, [])
const getParentId = (forum) => {
@@ -56,6 +65,33 @@ export default function Home() {
return roots
}, [forums])
const forumMap = useMemo(() => {
const map = new Map()
forums.forEach((forum) => {
map.set(String(forum.id), forum)
})
return map
}, [forums])
const recentThreads = useMemo(() => {
return [...threads]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 12)
}, [threads])
const resolveForumName = (thread) => {
if (!thread?.forum) return t('portal.unknown_forum')
const parts = thread.forum.split('/')
const id = parts[parts.length - 1]
return forumMap.get(String(id))?.name || t('portal.unknown_forum')
}
const resolveForumId = (thread) => {
if (!thread?.forum) return null
const parts = thread.forum.split('/')
return parts[parts.length - 1] || null
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
@@ -82,22 +118,111 @@ export default function Home() {
))
return (
<Container className="py-5">
<div className="bb-hero mb-4">
<p className="bb-chip">{t('app.brand')}</p>
<h1 className="mt-3">{t('home.hero_title')}</h1>
<p className="bb-muted mb-0">
{t('home.hero_body')}
</p>
</div>
<Container className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.menu')}</div>
<ul className="bb-portal-list">
<li>{t('portal.menu_news')}</li>
<li>{t('portal.menu_gallery')}</li>
<li>{t('portal.menu_calendar')}</li>
<li>{t('portal.menu_rules')}</li>
</ul>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.stats')}</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_threads')}</span>
<strong>{threads.length}</strong>
</div>
<div className="bb-portal-stat">
<span>{t('portal.stat_forums')}</span>
<strong>{forums.length}</strong>
</div>
</div>
</aside>
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (
<p className="bb-muted">{t('home.empty')}</p>
)}
{forumTree.length > 0 && <div className="mt-2">{renderTree(forumTree)}</div>}
<main className="bb-portal-column bb-portal-column--center">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.latest_posts')}</div>
{loadingThreads && <p className="bb-muted">{t('home.loading')}</p>}
{!loadingThreads && recentThreads.length === 0 && (
<p className="bb-muted">{t('portal.empty_posts')}</p>
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
<span>{t('thread.last_post')}</span>
</div>
{recentThreads.map((thread) => (
<div className="bb-portal-topic-row" key={thread.id}>
<div className="bb-portal-topic-main">
<span className="bb-portal-topic-icon" aria-hidden="true">
<i className="bi bi-chat-left-text" />
</span>
<div>
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
{thread.title}
</Link>
<div className="bb-portal-topic-meta">
<span>{t('thread.by')}</span>
<Badge bg="secondary">
{thread.user_name || t('thread.anonymous')}
</Badge>
<span className="bb-portal-topic-forum">
{t('portal.forum_label')}{' '}
{resolveForumId(thread) ? (
<Link
to={`/forum/${resolveForumId(thread)}`}
className="bb-portal-topic-forum-link"
>
{resolveForumName(thread)}
</Link>
) : (
resolveForumName(thread)
)}
</span>
</div>
</div>
</div>
<div className="bb-portal-topic-cell">0</div>
<div className="bb-portal-topic-cell"></div>
<div className="bb-portal-topic-cell">
{thread.created_at?.slice(0, 10) || '—'}
</div>
</div>
))}
</div>
)}
</div>
</main>
<aside className="bb-portal-column bb-portal-column--right">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_menu')}</div>
<div className="bb-portal-user-card">
<div className="bb-portal-user-avatar" />
<div className="bb-portal-user-name">tracer</div>
<div className="bb-portal-user-role">Operator</div>
</div>
<ul className="bb-portal-list">
<li>{t('portal.user_new_posts')}</li>
<li>{t('portal.user_unread')}</li>
<li>{t('portal.user_control_panel')}</li>
<li>{t('portal.user_logout')}</li>
</ul>
</div>
<div className="bb-portal-card bb-portal-card--ad">
<div className="bb-portal-card-title">{t('portal.advertisement')}</div>
<div className="bb-portal-ad-box">example.com</div>
</div>
</aside>
</div>
{error && <p className="text-danger mt-3">{error}</p>}
</Container>
)
}

View File

@@ -0,0 +1,71 @@
import { Container, Form, Row, Col } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation()
const accentMode = accentOverride ? 'custom' : 'system'
const handleLanguageChange = (event) => {
const locale = event.target.value
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
return (
<Container className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>
<Row className="g-3">
<Col xs={12}>
<Form.Group>
<Form.Label>{t('nav.language')}</Form.Label>
<Form.Select value={i18n.language} onChange={handleLanguageChange}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('nav.theme')}</Form.Label>
<Form.Select value={theme} onChange={(event) => setTheme(event.target.value)}>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label>{t('ucp.accent_override')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Select
value={accentMode}
onChange={(event) => {
const mode = event.target.value
if (mode === 'system') {
setAccentOverride('')
} else if (!accentOverride) {
setAccentOverride('#f29b3f')
}
}}
>
<option value="system">{t('ucp.system_default')}</option>
<option value="custom">{t('ucp.custom_color')}</option>
</Form.Select>
<Form.Control
type="color"
value={accentOverride || '#f29b3f'}
onChange={(event) => setAccentOverride(event.target.value)}
disabled={accentMode !== 'custom'}
/>
</div>
<Form.Text className="bb-muted">{t('ucp.accent_override_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
</Container>
)
}

View File

@@ -25,6 +25,9 @@
"acp.forums_hint": "Kategorien und Foren in einer Baumansicht verwalten.",
"acp.forums_name_required": "Bitte zuerst einen Namen eingeben.",
"acp.forums_parent": œbergeordnete Kategorie",
"acp.forums_parent_required": "Foren brauchen eine übergeordnete Kategorie.",
"acp.add_category": "Kategorie hinzufügen",
"acp.add_forum": "Forum hinzufügen",
"acp.forums_parent_root": "Wurzel (kein Parent)",
"acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ",
@@ -39,7 +42,6 @@
"acp.save": "Speichern",
"acp.title": "Administrationsbereich",
"acp.users": "Benutzer",
"app.brand": "speedBB",
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
"auth.login_title": "Anmelden",
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
@@ -71,7 +73,9 @@
"forum.no_description": "Noch keine Beschreibung vorhanden.",
"forum.only_forums": "Threads können nur in Foren erstellt werden.",
"forum.open": "Forum öffnen",
"forum.start_thread": "Thread starten",
"forum.collapse_category": "Kategorie einklappen",
"forum.expand_category": "Kategorie ausklappen",
"forum.start_thread": "Neues Thema",
"forum.threads": "Threads",
"forum.type_category": "Kategorie",
"forum.type_forum": "Forum",
@@ -90,16 +94,49 @@
"home.hero_body": "Entdecke Diskussionen, stelle Fragen und teile Ideen in Kategorien und Foren.",
"home.hero_title": "Foren",
"home.loading": "Foren werden geladen...",
"nav.acp": "ACP",
"nav.forums": "Foren",
"nav.language": "Sprache",
"nav.login": "Anmelden",
"nav.logout": "Abmelden",
"nav.register": "Registrieren",
"nav.theme": "Design",
"nav.theme_auto": "Auto",
"nav.theme_dark": "Dunkel",
"nav.theme_light": "Hell",
"portal.portal": "Portal",
"portal.tagline": "Demo forum",
"portal.search_placeholder": "Suche...",
"portal.quick_links": "Quicklinks",
"portal.link_faq": "FAQ",
"portal.link_acp": "ACP",
"portal.link_mcp": "MCP",
"portal.board_index": "Foren-Übersicht",
"portal.notifications": "Benachrichtigungen",
"portal.messages": "Private Nachrichten",
"portal.menu": "Menü",
"portal.menu_news": "News",
"portal.menu_gallery": "Galerie",
"portal.menu_calendar": "Kalender",
"portal.menu_rules": "Forenregeln",
"portal.stats": "Statistik",
"portal.stat_threads": "Themen",
"portal.stat_forums": "Foren",
"portal.latest_posts": "Aktuelle Beiträge",
"portal.empty_posts": "Noch keine Beiträge.",
"portal.topic": "Themen",
"portal.forum_label": "Forum:",
"portal.unknown_forum": "Unbekannt",
"portal.user_menu": "Benutzer-Menü",
"portal.user_new_posts": "Neue Beiträge",
"portal.user_unread": "Ungelesene Beiträge",
"portal.user_control_panel": "Benutzerkontrollzentrum",
"portal.user_profile": "Profil",
"portal.user_logout": "Logout",
"portal.advertisement": "Werbung",
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
"ucp.system_default": "Systemstandard",
"ucp.accent_override": "Akzentfarbe überschreiben",
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
"ucp.custom_color": "Eigene Farbe",
"thread.anonymous": "Anonym",
"thread.back_to_category": "Zurück zum Forum",
"thread.category": "Forum:",
@@ -108,6 +145,10 @@
"thread.loading": "Thread wird geladen...",
"thread.login_hint": "Melde dich an, um auf diesen Thread zu antworten.",
"thread.replies": "Antworten",
"thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag",
"thread.by": "von",
"thread.no_replies": "Noch keine Antworten.",
"thread.reply": "Antworten",
"thread.view": "Thread ansehen"
}

View File

@@ -25,6 +25,9 @@
"acp.forums_hint": "Manage categories and forums from a tree view.",
"acp.forums_name_required": "Please enter a name before saving.",
"acp.forums_parent": "Parent category",
"acp.forums_parent_required": "Forums must have a parent category.",
"acp.add_category": "Add category",
"acp.add_forum": "Add forum",
"acp.forums_parent_root": "Root (no parent)",
"acp.forums_tree": "Forum tree",
"acp.forums_type": "Type",
@@ -39,7 +42,6 @@
"acp.save": "Save",
"acp.title": "Admin control panel",
"acp.users": "Users",
"app.brand": "speedBB",
"auth.login_hint": "Access your account to start new threads and reply.",
"auth.login_title": "Log in",
"auth.register_hint": "Register with an email and a unique username.",
@@ -71,7 +73,9 @@
"forum.no_description": "No description added yet.",
"forum.only_forums": "Threads can only be created in forums.",
"forum.open": "Open forum",
"forum.start_thread": "Start a thread",
"forum.collapse_category": "Collapse category",
"forum.expand_category": "Expand category",
"forum.start_thread": "New topic",
"forum.threads": "Threads",
"forum.type_category": "Category",
"forum.type_forum": "Forum",
@@ -90,16 +94,49 @@
"home.hero_body": "Explore conversations, ask questions, and share ideas across categories and forums.",
"home.hero_title": "Forums",
"home.loading": "Loading forums...",
"nav.acp": "ACP",
"nav.forums": "Forums",
"nav.language": "Language",
"nav.login": "Login",
"nav.logout": "Logout",
"nav.register": "Register",
"nav.theme": "Theme",
"nav.theme_auto": "Auto",
"nav.theme_dark": "Dark",
"nav.theme_light": "Light",
"portal.portal": "Portal",
"portal.tagline": "Demo forum",
"portal.search_placeholder": "Search...",
"portal.quick_links": "Quick links",
"portal.link_faq": "FAQ",
"portal.link_acp": "ACP",
"portal.link_mcp": "MCP",
"portal.board_index": "Board index",
"portal.notifications": "Notifications",
"portal.messages": "Private messages",
"portal.menu": "Menu",
"portal.menu_news": "News",
"portal.menu_gallery": "Gallery",
"portal.menu_calendar": "Calendar",
"portal.menu_rules": "Forum rules",
"portal.stats": "Statistics",
"portal.stat_threads": "Threads",
"portal.stat_forums": "Forums",
"portal.latest_posts": "Latest posts",
"portal.empty_posts": "No posts yet.",
"portal.topic": "Topics",
"portal.forum_label": "Forum:",
"portal.unknown_forum": "Unknown",
"portal.user_menu": "User menu",
"portal.user_new_posts": "New posts",
"portal.user_unread": "Unread posts",
"portal.user_control_panel": "User Control Panel",
"portal.user_profile": "Profile",
"portal.user_logout": "Logout",
"portal.advertisement": "Advertisement",
"ucp.intro": "Manage your basic preferences for the forum.",
"ucp.system_default": "System default",
"ucp.accent_override": "Accent color override",
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
"ucp.custom_color": "Custom color",
"thread.anonymous": "Anonymous",
"thread.back_to_category": "Back to forum",
"thread.category": "Forum:",
@@ -108,6 +145,10 @@
"thread.loading": "Loading thread...",
"thread.login_hint": "Log in to reply to this thread.",
"thread.replies": "Replies",
"thread.views": "Views",
"thread.last_post": "Last post",
"thread.by": "by",
"thread.no_replies": "No replies yet.",
"thread.reply": "Reply",
"thread.view": "View thread"
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\I18nController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SettingController;
use App\Http\Controllers\ThreadController;
use App\Http\Controllers\UserSettingController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController;
use Illuminate\Support\Facades\Route;
@@ -16,6 +17,8 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
Route::get('/version', VersionController::class);
Route::get('/settings', [SettingController::class, 'index']);
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');