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() {