Compare commits
16 Commits
2409feb06f
...
f83b16dc2e
| Author | SHA1 | Date | |
|---|---|---|---|
| f83b16dc2e | |||
| 100c3dc403 | |||
| 96c455c78b | |||
| 5233432f15 | |||
| c4b0bf3b19 | |||
| 16ffaba285 | |||
|
|
c84ac5694f | ||
|
|
79855e793e | ||
|
|
01b8dd1930 | ||
|
|
371a2eb29b | ||
|
|
8e029d2a94 | ||
|
|
534c38d58a | ||
|
|
1b3056f078 | ||
|
|
761a89d241 | ||
|
|
4c2468952c | ||
|
|
073c81012b |
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
class VersionFetch extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'version:fetch';
|
|
||||||
|
|
||||||
protected $description = 'Update the build number based on the git commit count of master.';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$version = Setting::where('key', 'version')->value('value');
|
|
||||||
$build = $this->resolveBuildCount();
|
|
||||||
|
|
||||||
if ($version === null) {
|
|
||||||
$this->error('Unable to determine version from settings.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($build === null) {
|
|
||||||
$this->error('Unable to determine build number from git.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
Setting::updateOrCreate(
|
|
||||||
['key' => 'build'],
|
|
||||||
['value' => (string) $build],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$this->syncComposerMetadata($version, $build)) {
|
|
||||||
$this->error('Failed to sync version/build to composer.json.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Build number updated to {$build}.");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveBuildCount(): ?int
|
|
||||||
{
|
|
||||||
$commands = [
|
|
||||||
['git', 'rev-list', '--count', 'master'],
|
|
||||||
['git', 'rev-list', '--count', 'HEAD'],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($commands as $command) {
|
|
||||||
$process = new Process($command, base_path());
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if ($process->isSuccessful()) {
|
|
||||||
$output = trim($process->getOutput());
|
|
||||||
if (is_numeric($output)) {
|
|
||||||
return (int) $output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncComposerMetadata(string $version, int $build): bool
|
|
||||||
{
|
|
||||||
$composerPath = base_path('composer.json');
|
|
||||||
|
|
||||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = file_get_contents($composerPath);
|
|
||||||
if ($raw === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
|
||||||
if (!is_array($data)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['version'] = $version;
|
|
||||||
$data['build'] = (string) $build;
|
|
||||||
|
|
||||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
if ($encoded === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$encoded .= "\n";
|
|
||||||
|
|
||||||
return file_put_contents($composerPath, $encoded) !== false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -99,7 +99,6 @@ 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,
|
||||||
|
|||||||
@@ -97,36 +97,6 @@ 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) {
|
||||||
@@ -150,7 +120,6 @@ 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,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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
|
||||||
@@ -42,11 +41,6 @@ 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
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withCommands([
|
|
||||||
__DIR__.'/../app/Console/Commands',
|
|
||||||
])
|
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -246,16 +246,6 @@ 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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ 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">
|
||||||
|
|||||||
@@ -105,40 +105,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
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, updateThreadSolved } from '../api/client'
|
import { createPost, getThread, listPostsByThread } 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, isAdmin } = useAuth()
|
const { token, userId } = 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)
|
||||||
|
|
||||||
@@ -94,24 +93,6 @@ 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 (
|
||||||
@@ -121,12 +102,7 @@ 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">
|
<h1 className="bb-thread-title">{thread.title}</h1>
|
||||||
{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">
|
||||||
@@ -144,20 +120,6 @@ 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>
|
||||||
|
|||||||
@@ -227,9 +227,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -227,9 +227,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ 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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user