Make admin content list draggable for reordering

This commit is contained in:
DariusIII
2026-03-21 09:56:32 +01:00
parent 14aaaf8a14
commit 0bd71e1961
9 changed files with 632 additions and 134 deletions
@@ -11,6 +11,8 @@ use Illuminate\Foundation\Application;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class AdminContentController extends BasePageController
@@ -24,12 +26,23 @@ class AdminContentController extends BasePageController
{
$this->setAdminPrefs();
$contentList = Content::query()
->orderByRaw('contenttype, COALESCE(ordinal, 1000000)')
->get();
$contentList = Content::query()->ordered()->get();
$contentGroups = $contentList
->groupBy('contenttype')
->sortKeys()
->map(function ($items, $contentType) {
/** @var Collection<int, Content> $items */
return [
'contenttype' => (int) $contentType,
'label' => $items->first()?->getContentTypeLabel() ?? 'Unknown',
'items' => $items,
];
})
->values();
$this->viewData = array_merge($this->viewData, [
'contentlist' => $contentList,
'contentGroups' => $contentGroups,
'meta_title' => 'Content List',
'title' => 'Content List',
]);
@@ -154,6 +167,63 @@ class AdminContentController extends BasePageController
], 404);
}
/**
* Persist a new global content order.
*/
public function reorder(Request $request): mixed
{
$validated = $request->validate([
'contenttype' => ['required', 'integer'],
'ordered_ids' => ['required', 'array', 'min:1'],
'ordered_ids.*' => ['integer', 'distinct', 'exists:content,id'],
]);
$contentType = (int) $validated['contenttype'];
$orderedIds = array_map('intval', $validated['ordered_ids']);
$expectedIds = Content::query()
->where('contenttype', $contentType)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
if (
count($orderedIds) !== count($expectedIds)
|| array_diff($orderedIds, $expectedIds) !== []
|| array_diff($expectedIds, $orderedIds) !== []
) {
return response()->json([
'success' => false,
'message' => 'Reorder payload must include every content row for the selected group.',
], 422);
}
$timestamp = now();
$ordinals = [];
DB::transaction(function () use ($contentType, $orderedIds, $timestamp, &$ordinals): void {
foreach ($orderedIds as $index => $contentId) {
$ordinal = $index + 1;
Content::query()
->whereKey($contentId)
->where('contenttype', $contentType)
->update([
'ordinal' => $ordinal,
'updated_at' => $timestamp,
]);
$ordinals[$contentId] = $ordinal;
}
});
return response()->json([
'success' => true,
'message' => 'Content order updated successfully',
'contenttype' => $contentType,
'ordinals' => $ordinals,
]);
}
/**
* Delete content by ID.
*/
@@ -200,11 +270,6 @@ class AdminContentController extends BasePageController
// Normalize URL
$data = $this->normalizeContentUrl($data);
// If ordinal is 1, increment all existing ordinals
if (($data['ordinal'] ?? 0) === 1) {
Content::query()->where('ordinal', '>', 0)->increment('ordinal');
}
return Content::query()->insertGetId([
'role' => $data['role'] ?? Content::ROLE_EVERYONE,
'title' => $data['title'] ?? '',
@@ -214,7 +279,7 @@ class AdminContentController extends BasePageController
'metakeywords' => $data['metakeywords'] ?? '',
'contenttype' => $data['contenttype'] ?? Content::TYPE_USEFUL,
'status' => $data['status'] ?? Content::STATUS_ENABLED,
'ordinal' => $data['ordinal'] ?? 0,
'ordinal' => $this->nextBottomOrdinal((int) ($data['contenttype'] ?? Content::TYPE_USEFUL)),
'created_at' => now(),
'updated_at' => now(),
]);
@@ -230,8 +295,14 @@ class AdminContentController extends BasePageController
// Normalize URL
$data = $this->normalizeContentUrl($data);
$content = Content::query()->findOrFail($data['id']);
$updatedContentType = (int) ($data['contenttype'] ?? $content->contenttype);
$ordinal = $content->contenttype === $updatedContentType
? $content->ordinal
: $this->nextBottomOrdinal($updatedContentType);
return Content::query()
->where('id', $data['id'])
->whereKey($content->id)
->update([
'role' => $data['role'] ?? Content::ROLE_EVERYONE,
'title' => $data['title'] ?? '',
@@ -239,9 +310,9 @@ class AdminContentController extends BasePageController
'body' => $data['body'] ?? '',
'metadescription' => $data['metadescription'] ?? '',
'metakeywords' => $data['metakeywords'] ?? '',
'contenttype' => $data['contenttype'] ?? Content::TYPE_USEFUL,
'contenttype' => $updatedContentType,
'status' => $data['status'] ?? Content::STATUS_ENABLED,
'ordinal' => $data['ordinal'] ?? 0,
'ordinal' => $ordinal,
'updated_at' => now(),
]);
}
@@ -286,4 +357,20 @@ class AdminContentController extends BasePageController
return $data;
}
/**
* Get the ordinal that places new content at the bottom of the list.
*/
protected function nextBottomOrdinal(int $contentType): int
{
$maximumOrdinal = Content::query()
->where('contenttype', $contentType)
->max('ordinal');
if ($maximumOrdinal === null) {
return 1;
}
return (int) $maximumOrdinal + 1;
}
}
+6 -5
View File
@@ -89,9 +89,7 @@ class ContentController extends BasePageController
*/
protected function getActiveContent(): Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType
{
return Content::active()
->orderByRaw('contenttype, COALESCE(ordinal, 1000000)')
->get();
return Content::active()->ordered()->get(); // @phpstan-ignore method.notFound
}
/**
@@ -103,7 +101,7 @@ class ContentController extends BasePageController
{
return Content::query()
->where('id', '<>', 1)
->orderByRaw('contenttype, COALESCE(ordinal, 1000000)')
->ordered() // @phpstan-ignore method.notFound
->get();
}
@@ -133,6 +131,9 @@ class ContentController extends BasePageController
*/
protected function getIndexContent(): ?Content
{
return Content::active()->ofType(Content::TYPE_INDEX)->first();
return Content::active()
->ofType(Content::TYPE_INDEX)
->ordered() // @phpstan-ignore method.notFound
->first();
}
}
+12 -1
View File
@@ -35,6 +35,7 @@ use Illuminate\Database\Eloquent\Model;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content ofType($type)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content forRole($role)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content frontPage()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content ordered()
*
* @mixin \Eloquent
*
@@ -121,6 +122,16 @@ class Content extends Model
});
}
/**
* Scope: Order content by type and ordinal within each type.
*/
public function scopeOrdered(Builder $query): Builder // @phpstan-ignore missingType.generics
{
return $query
->orderBy('contenttype')
->orderByRaw('COALESCE(ordinal, 1000000) ASC, id ASC');
}
/**
* Scope: Get front page content.
*/
@@ -128,7 +139,7 @@ class Content extends Model
{
return $query->active() // @phpstan-ignore method.notFound
->ofType(self::TYPE_INDEX)
->orderByRaw('ordinal ASC, COALESCE(ordinal, 1000000), id');
->ordered(); // @phpstan-ignore method.notFound
}
/**
+1 -1
View File
@@ -64,7 +64,7 @@ class GlobalDataComposer
$viewData['usefulLinks'] = Cache::remember('content_useful_links', self::CACHE_TTL, function () {
return Content::active()
->ofType(Content::TYPE_USEFUL)
->orderBy('ordinal')
->ordered() // @phpstan-ignore method.notFound
->get();
});