diff --git a/app/Http/Controllers/PortalController.php b/app/Http/Controllers/PortalController.php
index cf3980d..7322f9b 100644
--- a/app/Http/Controllers/PortalController.php
+++ b/app/Http/Controllers/PortalController.php
@@ -99,6 +99,7 @@ class PortalController extends Controller
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
+ 'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1,
diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php
index 21c4224..f82488a 100644
--- a/app/Http/Controllers/ThreadController.php
+++ b/app/Http/Controllers/ThreadController.php
@@ -97,6 +97,36 @@ class ThreadController extends Controller
return response()->json(null, 204);
}
+ public function updateSolved(Request $request, Thread $thread): JsonResponse
+ {
+ $user = $request->user();
+ if (!$user) {
+ return response()->json(['message' => 'Unauthorized.'], 401);
+ }
+
+ $isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
+ if (!$isAdmin && $thread->user_id !== $user->id) {
+ return response()->json(['message' => 'Not authorized to update solved status.'], 403);
+ }
+
+ $data = $request->validate([
+ 'solved' => ['required', 'boolean'],
+ ]);
+
+ $thread->solved = $data['solved'];
+ $thread->save();
+ $thread->refresh();
+ $thread->loadMissing([
+ 'user' => fn ($query) => $query
+ ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
+ ->with(['rank', 'roles']),
+ 'latestPost.user.rank',
+ 'latestPost.user.roles',
+ ])->loadCount('posts');
+
+ return response()->json($this->serializeThread($thread));
+ }
+
private function parseIriId(?string $value): ?int
{
if (!$value) {
@@ -120,6 +150,7 @@ class ThreadController extends Controller
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
+ 'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1,
diff --git a/app/Models/Thread.php b/app/Models/Thread.php
index 2c78cea..755c8e9 100644
--- a/app/Models/Thread.php
+++ b/app/Models/Thread.php
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $user_id
* @property string $title
* @property string $body
+ * @property bool $solved
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forum $forum
@@ -41,6 +42,11 @@ class Thread extends Model
'user_id',
'title',
'body',
+ 'solved',
+ ];
+
+ protected $casts = [
+ 'solved' => 'bool',
];
public function forum(): BelongsTo
diff --git a/database/migrations/2026_01_24_120000_add_solved_to_threads_table.php b/database/migrations/2026_01_24_120000_add_solved_to_threads_table.php
new file mode 100644
index 0000000..655f60d
--- /dev/null
+++ b/database/migrations/2026_01_24_120000_add_solved_to_threads_table.php
@@ -0,0 +1,28 @@
+boolean('solved')->default(false)->after('body');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('threads', function (Blueprint $table) {
+ $table->dropColumn('solved');
+ });
+ }
+};
diff --git a/resources/js/api/client.js b/resources/js/api/client.js
index 8e84cbe..b63901a 100644
--- a/resources/js/api/client.js
+++ b/resources/js/api/client.js
@@ -246,6 +246,16 @@ export async function getThread(id) {
return apiFetch(`/threads/${id}`)
}
+export async function updateThreadSolved(threadId, solved) {
+ return apiFetch(`/threads/${threadId}/solved`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/merge-patch+json',
+ },
+ body: JSON.stringify({ solved }),
+ })
+}
+
export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`)
}
diff --git a/resources/js/components/PortalTopicRow.jsx b/resources/js/components/PortalTopicRow.jsx
index 563b9c8..c1394e3 100644
--- a/resources/js/components/PortalTopicRow.jsx
+++ b/resources/js/components/PortalTopicRow.jsx
@@ -39,6 +39,9 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
{thread.title}
+ {thread.solved && (
+
{t('thread.solved')}
+ )}
diff --git a/resources/js/index.css b/resources/js/index.css
index 299d88b..4946e58 100644
--- a/resources/js/index.css
+++ b/resources/js/index.css
@@ -105,6 +105,40 @@ a {
margin: 0;
font-size: 1.6rem;
color: var(--bb-accent, #f29b3f);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.bb-thread-solved-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.18rem 0.5rem;
+ border-radius: 999px;
+ background: var(--bb-accent, #f29b3f);
+ color: #0b0f17;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ margin-left: 0.45rem;
+}
+
+.bb-thread-solved-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--bb-accent, #f29b3f);
+ border-color: var(--bb-accent, #f29b3f);
+}
+
+.bb-thread-solved-toggle:hover,
+.bb-thread-solved-toggle:focus {
+ background: var(--bb-accent, #f29b3f);
+ border-color: var(--bb-accent, #f29b3f);
+ color: #0b0f17;
}
.bb-thread-meta {
diff --git a/resources/js/pages/ThreadView.jsx b/resources/js/pages/ThreadView.jsx
index f437f5d..fb42db8 100644
--- a/resources/js/pages/ThreadView.jsx
+++ b/resources/js/pages/ThreadView.jsx
@@ -1,19 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
-import { createPost, getThread, listPostsByThread } from '../api/client'
+import { createPost, getThread, listPostsByThread, updateThreadSolved } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ThreadView() {
const { id } = useParams()
- const { token, userId } = useAuth()
+ const { token, userId, isAdmin } = useAuth()
const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
+ const [solving, setSolving] = useState(false)
const { t } = useTranslation()
const replyRef = useRef(null)
@@ -93,6 +94,24 @@ export default function ThreadView() {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
+ const canToggleSolved = token
+ && thread
+ && (Number(thread.user_id) === Number(userId) || isAdmin)
+
+ const handleToggleSolved = async () => {
+ if (!thread || solving) return
+ setSolving(true)
+ setError('')
+ try {
+ const updated = await updateThreadSolved(thread.id, !thread.solved)
+ setThread(updated)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setSolving(false)
+ }
+ }
+
const totalPosts = allPosts.length
return (
@@ -102,7 +121,12 @@ export default function ThreadView() {
{thread && (
-
{thread.title}
+
+ {thread.title}
+ {thread.solved && (
+ {t('thread.solved')}
+ )}
+
{t('thread.by')}
@@ -120,6 +144,20 @@ export default function ThreadView() {
{t('form.post_reply')}
+ {canToggleSolved && (
+
+ )}
diff --git a/resources/lang/de.json b/resources/lang/de.json
index b9a4f20..73d0c8a 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -227,6 +227,9 @@
"thread.reply_prefix": "Aw:",
"thread.registered": "Registriert",
"thread.replies": "Antworten",
+ "thread.solved": "Gel\u00f6st",
+ "thread.mark_solved": "Als gel\u00f6st markieren",
+ "thread.mark_unsolved": "Als ungel\u00f6st markieren",
"thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag",
"thread.by": "von",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 7887255..9ae3537 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -227,6 +227,9 @@
"thread.reply_prefix": "Re:",
"thread.registered": "Registered",
"thread.replies": "Replies",
+ "thread.solved": "Solved",
+ "thread.mark_solved": "Mark solved",
+ "thread.mark_unsolved": "Mark unsolved",
"thread.views": "Views",
"thread.last_post": "Last post",
"thread.by": "by",
diff --git a/routes/api.php b/routes/api.php
index 21d976f..55e6f34 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -67,6 +67,7 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
+Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/posts', [PostController::class, 'index']);