feat: add solved threads
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 26s

This commit is contained in:
2026-01-24 14:11:55 +01:00
parent e3dcf99362
commit 2409feb06f
11 changed files with 161 additions and 3 deletions

View File

@@ -99,6 +99,7 @@ class PortalController extends Controller
'id' => $thread->id, 'id' => $thread->id,
'title' => $thread->title, 'title' => $thread->title,
'body' => $thread->body, 'body' => $thread->body,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}", 'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id, 'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1, 'posts_count' => ($thread->posts_count ?? 0) + 1,

View File

@@ -97,6 +97,36 @@ class ThreadController extends Controller
return response()->json(null, 204); return response()->json(null, 204);
} }
public function updateSolved(Request $request, Thread $thread): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $thread->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
}
$data = $request->validate([
'solved' => ['required', 'boolean'],
]);
$thread->solved = $data['solved'];
$thread->save();
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
private function parseIriId(?string $value): ?int private function parseIriId(?string $value): ?int
{ {
if (!$value) { if (!$value) {
@@ -120,6 +150,7 @@ class ThreadController extends Controller
'id' => $thread->id, 'id' => $thread->id,
'title' => $thread->title, 'title' => $thread->title,
'body' => $thread->body, 'body' => $thread->body,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}", 'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id, 'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1, 'posts_count' => ($thread->posts_count ?? 0) + 1,

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $user_id * @property int|null $user_id
* @property string $title * @property string $title
* @property string $body * @property string $body
* @property bool $solved
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forum $forum * @property-read \App\Models\Forum $forum
@@ -41,6 +42,11 @@ class Thread extends Model
'user_id', 'user_id',
'title', 'title',
'body', 'body',
'solved',
];
protected $casts = [
'solved' => 'bool',
]; ];
public function forum(): BelongsTo public function forum(): BelongsTo

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('threads', function (Blueprint $table) {
$table->boolean('solved')->default(false)->after('body');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('solved');
});
}
};

View File

@@ -246,6 +246,16 @@ export async function getThread(id) {
return apiFetch(`/threads/${id}`) return apiFetch(`/threads/${id}`)
} }
export async function updateThreadSolved(threadId, solved) {
return apiFetch(`/threads/${threadId}/solved`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({ solved }),
})
}
export async function listPostsByThread(threadId) { export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`) return getCollection(`/posts?thread=/api/threads/${threadId}`)
} }

View File

@@ -39,6 +39,9 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
<div> <div>
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title"> <Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
{thread.title} {thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</Link> </Link>
<div className="bb-portal-topic-meta"> <div className="bb-portal-topic-meta">
<div className="bb-portal-topic-meta-line"> <div className="bb-portal-topic-meta-line">

View File

@@ -105,6 +105,40 @@ a {
margin: 0; margin: 0;
font-size: 1.6rem; font-size: 1.6rem;
color: var(--bb-accent, #f29b3f); 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 { .bb-thread-meta {

View File

@@ -1,19 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap' import { Button, Container, Form } from 'react-bootstrap'
import { useParams } from 'react-router-dom' 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 { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function ThreadView() { export default function ThreadView() {
const { id } = useParams() const { id } = useParams()
const { token, userId } = useAuth() const { token, userId, isAdmin } = useAuth()
const [thread, setThread] = useState(null) const [thread, setThread] = useState(null)
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [solving, setSolving] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const replyRef = useRef(null) const replyRef = useRef(null)
@@ -93,6 +94,24 @@ export default function ThreadView() {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) 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 const totalPosts = allPosts.length
return ( return (
@@ -102,7 +121,12 @@ export default function ThreadView() {
{thread && ( {thread && (
<div className="bb-thread"> <div className="bb-thread">
<div className="bb-thread-titlebar"> <div className="bb-thread-titlebar">
<h1 className="bb-thread-title">{thread.title}</h1> <h1 className="bb-thread-title">
{thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</h1>
<div className="bb-thread-meta"> <div className="bb-thread-meta">
<span>{t('thread.by')}</span> <span>{t('thread.by')}</span>
<span className="bb-thread-author"> <span className="bb-thread-author">
@@ -120,6 +144,20 @@ export default function ThreadView() {
<i className="bi bi-reply-fill" aria-hidden="true" /> <i className="bi bi-reply-fill" aria-hidden="true" />
<span>{t('form.post_reply')}</span> <span>{t('form.post_reply')}</span>
</Button> </Button>
{canToggleSolved && (
<Button
variant="outline-secondary"
className="bb-thread-solved-toggle"
onClick={handleToggleSolved}
disabled={solving}
>
<i
className={`bi ${thread.solved ? 'bi-check-circle-fill' : 'bi-check-circle'}`}
aria-hidden="true"
/>
<span>{thread.solved ? t('thread.mark_unsolved') : t('thread.mark_solved')}</span>
</Button>
)}
<button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}> <button type="button" className="bb-thread-icon-button" aria-label={t('thread.reply')}>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" /> <i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button> </button>

View File

@@ -227,6 +227,9 @@
"thread.reply_prefix": "Aw:", "thread.reply_prefix": "Aw:",
"thread.registered": "Registriert", "thread.registered": "Registriert",
"thread.replies": "Antworten", "thread.replies": "Antworten",
"thread.solved": "Gel\u00f6st",
"thread.mark_solved": "Als gel\u00f6st markieren",
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
"thread.views": "Zugriffe", "thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag", "thread.last_post": "Letzter Beitrag",
"thread.by": "von", "thread.by": "von",

View File

@@ -227,6 +227,9 @@
"thread.reply_prefix": "Re:", "thread.reply_prefix": "Re:",
"thread.registered": "Registered", "thread.registered": "Registered",
"thread.replies": "Replies", "thread.replies": "Replies",
"thread.solved": "Solved",
"thread.mark_solved": "Mark solved",
"thread.mark_unsolved": "Mark unsolved",
"thread.views": "Views", "thread.views": "Views",
"thread.last_post": "Last post", "thread.last_post": "Last post",
"thread.by": "by", "thread.by": "by",

View File

@@ -67,6 +67,7 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
Route::get('/threads', [ThreadController::class, 'index']); Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{thread}', [ThreadController::class, 'show']); Route::get('/threads/{thread}', [ThreadController::class, 'show']);
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum'); Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum'); Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/posts', [PostController::class, 'index']); Route::get('/posts', [PostController::class, 'index']);