settings updates, fixed

This commit is contained in:
Mark Gregory
2026-08-25 22:36:39 +01:00
parent d359ac29b6
commit 34569840ae
39 changed files with 3007 additions and 7 deletions
@@ -0,0 +1,82 @@
<?php
namespace Modules\Documentation\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Modules\Documentation\Http\Requests\StoreDocumentationCategoryRequest;
use Modules\Documentation\Http\Requests\UpdateDocumentationCategoryRequest;
use Modules\Documentation\Models\DocumentationCategory;
class DocumentationCategoryController extends Controller
{
/**
* Display category list.
*/
public function index(): View
{
return view('documentation::categories.index', [
'categories' => DocumentationCategory::query()->withCount('documents')->ordered()->paginate(15),
]);
}
/**
* Show create form.
*/
public function create(): View
{
return view('documentation::categories.create', [
'category' => new DocumentationCategory(['colour' => 'emerald', 'is_active' => true]),
]);
}
/**
* Store a category.
*/
public function store(StoreDocumentationCategoryRequest $request): RedirectResponse
{
$data = $request->validated();
$data['is_active'] = $request->boolean('is_active', true);
$data['created_by'] = $request->user()?->id;
$data['updated_by'] = $request->user()?->id;
DocumentationCategory::query()->create($data);
return redirect()->route('documentation.categories.index')->with('status', 'Category created successfully.');
}
/**
* Show edit form.
*/
public function edit(DocumentationCategory $category): View
{
return view('documentation::categories.edit', [
'category' => $category,
]);
}
/**
* Update a category.
*/
public function update(UpdateDocumentationCategoryRequest $request, DocumentationCategory $category): RedirectResponse
{
$data = $request->validated();
$data['is_active'] = $request->boolean('is_active');
$data['updated_by'] = $request->user()?->id;
$category->update($data);
return redirect()->route('documentation.categories.index')->with('status', 'Category updated successfully.');
}
/**
* Delete a category.
*/
public function destroy(DocumentationCategory $category): RedirectResponse
{
$category->delete();
return redirect()->route('documentation.categories.index')->with('status', 'Category deleted successfully.');
}
}
@@ -0,0 +1,319 @@
<?php
namespace Modules\Documentation\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Modules\Documentation\Http\Requests\StoreDocumentationDocumentRequest;
use Modules\Documentation\Http\Requests\UpdateDocumentationDocumentRequest;
use Modules\Documentation\Models\DocumentationAttachment;
use Modules\Documentation\Models\DocumentationCategory;
use Modules\Documentation\Models\DocumentationDocument;
class DocumentationController extends Controller
{
/**
* Display the documentation dashboard and document list.
*/
public function index(Request $request): View
{
$query = DocumentationDocument::query()->with(['category', 'attachments'])->latest();
if ($request->filled('status')) {
$query->where('status', $request->string('status')->toString());
}
if ($request->filled('category')) {
$query->where('documentation_category_id', $request->integer('category'));
}
if ($request->filled('search')) {
$search = '%' . $request->string('search')->trim()->toString() . '%';
$query->where(function ($builder) use ($search): void {
$builder->where('title', 'like', $search)
->orWhere('summary', 'like', $search)
->orWhere('reference', 'like', $search)
->orWhere('body', 'like', $search);
});
}
return view('documentation::documents.index', [
'documents' => $query->paginate(15)->withQueryString(),
'categories' => DocumentationCategory::query()->ordered()->get(),
'featuredDocuments' => DocumentationDocument::query()->with('category')->featured()->latest()->limit(4)->get(),
'stats' => [
'total' => DocumentationDocument::query()->count(),
'published' => DocumentationDocument::query()->published()->count(),
'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(),
'categories' => DocumentationCategory::query()->count(),
],
'statuses' => config('documentation.statuses', []),
]);
}
/**
* Show the create document form.
*/
public function create(): View
{
return view('documentation::documents.create', [
'document' => new DocumentationDocument(['status' => DocumentationDocument::STATUS_DRAFT, 'version' => '1.0.0']),
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
'statuses' => config('documentation.statuses', []),
]);
}
/**
* Store a newly created document.
*/
public function store(StoreDocumentationDocumentRequest $request): RedirectResponse
{
$data = $request->validated();
$data['is_featured'] = $request->boolean('is_featured');
$data['created_by'] = $request->user()?->id;
$data['updated_by'] = $request->user()?->id;
if ($request->hasFile('attachment')) {
if (blank($data['body'] ?? null)) {
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
}
$data = array_merge($data, $this->storeAttachment($request));
}
unset($data['attachment'], $data['attachments']);
$document = DocumentationDocument::query()->create($data);
$this->storeAttachments($request, $document);
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document created successfully.');
}
/**
* Display a document.
*/
public function show(DocumentationDocument $document): View
{
return view('documentation::documents.show', [
'document' => $document->load(['category', 'attachments']),
]);
}
/**
* Show the edit document form.
*/
public function edit(DocumentationDocument $document): View
{
return view('documentation::documents.edit', [
'document' => $document->load('attachments'),
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
'statuses' => config('documentation.statuses', []),
]);
}
/**
* Update an existing document.
*/
public function update(UpdateDocumentationDocumentRequest $request, DocumentationDocument $document): RedirectResponse
{
$data = $request->validated();
$data['is_featured'] = $request->boolean('is_featured');
$data['updated_by'] = $request->user()?->id;
if ($request->boolean('remove_attachment')) {
$this->deleteAttachment($document);
$data = array_merge($data, $this->emptyAttachmentData());
}
if ($request->hasFile('attachment')) {
$this->deleteAttachment($document);
if (blank($data['body'] ?? null)) {
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
}
$data = array_merge($data, $this->storeAttachment($request));
}
unset($data['attachment'], $data['attachments'], $data['remove_attachment']);
$document->update($data);
$this->storeAttachments($request, $document);
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document updated successfully.');
}
/**
* Remove a document.
*/
public function destroy(DocumentationDocument $document): RedirectResponse
{
$document->load('attachments');
$this->deleteAttachment($document);
foreach ($document->attachments as $attachment) {
Storage::disk($attachment->disk)->delete($attachment->path);
$attachment->delete();
}
$document->delete();
return redirect()->route('documentation.documents.index')->with('status', 'Document archived successfully.');
}
/**
* Download the attached document file.
*/
public function download(DocumentationDocument $document)
{
abort_if(blank($document->attachment_path), 404);
return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))
->download($document->attachment_path, $document->attachment_name);
}
/**
* Download one of the document's additional attachments.
*/
public function downloadAttachment(DocumentationDocument $document, DocumentationAttachment $attachment)
{
abort_if($attachment->documentation_document_id !== $document->id, 404);
return Storage::disk($attachment->disk)->download($attachment->path, $attachment->original_name);
}
/**
* Delete one of the document's additional attachments.
*/
public function destroyAttachment(DocumentationDocument $document, DocumentationAttachment $attachment): RedirectResponse
{
abort_if($attachment->documentation_document_id !== $document->id, 404);
Storage::disk($attachment->disk)->delete($attachment->path);
$attachment->delete();
return redirect()->route('documentation.documents.edit', $document)->with('status', 'Attachment removed successfully.');
}
/**
* Extract body content from an uploaded Markdown file when requested.
*/
private function extractMarkdownBody(Request $request): ?string
{
if (! config('documentation.import_markdown_attachments', true)) {
return null;
}
$file = $request->file('attachment');
if ($file === null || ! $this->isMarkdownUpload($request)) {
return null;
}
if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) {
return null;
}
$content = file_get_contents($file->getRealPath());
if ($content === false) {
return null;
}
return trim($content);
}
/**
* Determine whether the uploaded attachment is a Markdown file.
*/
private function isMarkdownUpload(Request $request): bool
{
$file = $request->file('attachment');
if ($file === null) {
return false;
}
$extension = strtolower($file->getClientOriginalExtension());
$mime = strtolower((string) $file->getMimeType());
return $extension === 'md' || in_array($mime, ['text/markdown', 'text/x-markdown'], true);
}
/**
* Store an uploaded attachment and return persisted metadata.
*
* @return array<string, mixed>
*/
private function storeAttachment(Request $request): array
{
$file = $request->file('attachment');
$disk = config('documentation.storage_disk', 'public');
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
return [
'attachment_disk' => $disk,
'attachment_path' => $path,
'attachment_name' => $file->getClientOriginalName(),
'attachment_mime' => $file->getMimeType(),
'attachment_size' => $file->getSize(),
];
}
/**
* Store all additional uploaded attachments for a document.
*/
private function storeAttachments(Request $request, DocumentationDocument $document): void
{
if (! $request->hasFile('attachments')) {
return;
}
$disk = config('documentation.storage_disk', 'public');
foreach ((array) $request->file('attachments') as $file) {
if ($file === null || ! $file->isValid()) {
continue;
}
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
$document->attachments()->create([
'disk' => $disk,
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'uploaded_by' => $request->user()?->id,
]);
}
}
/**
* Delete a stored attachment if it exists.
*/
private function deleteAttachment(DocumentationDocument $document): void
{
if (filled($document->attachment_path)) {
Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path);
}
}
/**
* Empty attachment metadata.
*
* @return array<string, null>
*/
private function emptyAttachmentData(): array
{
return [
'attachment_disk' => null,
'attachment_path' => null,
'attachment_name' => null,
'attachment_mime' => null,
'attachment_size' => null,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Documentation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreDocumentationCategoryRequest extends FormRequest
{
/**
* Determine whether the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')],
'icon' => ['nullable', 'string', 'max:80'],
'colour' => ['required', 'string', 'max:32'],
'description' => ['nullable', 'string', 'max:2000'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Documentation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Documentation\Models\DocumentationDocument;
class StoreDocumentationDocumentRequest extends FormRequest
{
/**
* Determine whether the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'title' => ['required', 'string', 'max:220'],
'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')],
'summary' => ['nullable', 'string', 'max:500'],
'body' => ['nullable', 'string'],
'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])],
'is_featured' => ['nullable', 'boolean'],
'version' => ['required', 'string', 'max:50'],
'reference' => ['nullable', 'string', 'max:100'],
'attachment' => ['nullable', 'file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))],
'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))],
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Documentation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Documentation\Models\DocumentationCategory;
class UpdateDocumentationCategoryRequest extends FormRequest
{
/**
* Determine whether the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var DocumentationCategory|null $category */
$category = $this->route('category');
return [
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:180', Rule::unique('documentation_categories', 'slug')->ignore($category?->id)],
'icon' => ['nullable', 'string', 'max:80'],
'colour' => ['required', 'string', 'max:32'],
'description' => ['nullable', 'string', 'max:2000'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Documentation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Documentation\Models\DocumentationDocument;
class UpdateDocumentationDocumentRequest extends FormRequest
{
/**
* Determine whether the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var DocumentationDocument|null $document */
$document = $this->route('document');
return [
'documentation_category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'title' => ['required', 'string', 'max:220'],
'slug' => ['nullable', 'string', 'max:240', Rule::unique('documentation_documents', 'slug')->ignore($document?->id)],
'summary' => ['nullable', 'string', 'max:500'],
'body' => ['nullable', 'string'],
'status' => ['required', Rule::in([DocumentationDocument::STATUS_DRAFT, DocumentationDocument::STATUS_PUBLISHED, DocumentationDocument::STATUS_ARCHIVED])],
'is_featured' => ['nullable', 'boolean'],
'version' => ['required', 'string', 'max:50'],
'reference' => ['nullable', 'string', 'max:100'],
'attachment' => ['nullable', 'file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))],
'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:' . config('documentation.max_upload_kb', 20480), 'mimes:' . implode(',', config('documentation.allowed_upload_mimes', []))],
'remove_attachment' => ['nullable', 'boolean'],
];
}
}