Merge branch 'develop'

This commit is contained in:
Leonid Nikitin 2024-05-19 21:02:48 +05:00
commit 6e99fdf839
Signed by: kor-elf
GPG Key ID: 3C0F720C170F6E1D
134 changed files with 4462 additions and 51 deletions

View File

@ -1,5 +1,6 @@
DOCKER_APP_PORT=8080
DOCKER_CAPTCHA_PORT=8081
DOCKER_CAPTCHA_WEBSOCKET_PORT=8082
DOCKER_DB_PORT=3306
MYSQL_ROOT_PASSWORD=root_pass
DB_DATABASE=my-projetcs

14
app/.dockerignore Normal file
View File

@ -0,0 +1,14 @@
**/.env
**/*.env
**/.env.example
**/storage/app/*
**/storage/debugbar
**/storage/framework/cache/*
**/storage/framework/sessions/*
**/storage/framework/views/*
**/storage/framework/testing/*
**/storage/logs/*
**/vendor/
**/node_modules/
**/public/build/
**/public/storage

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder;
use App\Dto\Builder\DocumentationCategory\Category;
final readonly class Documentation
{
public function __construct(
private ?bool $isPublic = null,
private ?Category $categoryId = null,
) { }
public function isPublic(): ?bool
{
return $this->isPublic;
}
public function getCategoryId(): ?Category
{
return $this->categoryId;
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder;
use App\Dto\Builder\DocumentationCategory\Category;
final readonly class DocumentationCategory
{
public function __construct(
private ?bool $isPublic = null,
private ?Category $parentId = null,
) { }
public function isPublic(): ?bool
{
return $this->isPublic;
}
public function getParentId(): ?Category
{
return $this->parentId;
}
}

View File

@ -0,0 +1,20 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder\DocumentationCategory;
final readonly class Category
{
public function __construct(
private ?int $categoryId,
) { }
public function getCategoryId(): ?int
{
return $this->categoryId;
}
public function isCategoryNull(): bool
{
return $this->getCategoryId() === null;
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace App\Dto\Builder;
final readonly class DocumentationVersion
{
public function __construct(
private ?bool $isPublic = null,
) { }
public function isPublic(): ?bool
{
return $this->isPublic;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\Documentation;
use App\Dto\Builder\Documentation;
use App\Dto\Service\Pages;
final readonly class Index extends Pages
{
public function __construct(
private Documentation $documentation,
int $page
) {
parent::__construct($page);
}
public function getDocumentation(): Documentation
{
return $this->documentation;
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\Documentation;
use App\Dto\Service\Admin\Project\DocumentationContent\Contents;
use App\Dto\Service\Dto;
final readonly class StoreUpdate extends Dto
{
public function __construct(
private string $slug,
private bool $isPublic,
private int $sort,
private Contents $contents,
private ?int $categoryId,
) { }
public function getSlug(): string
{
return $this->slug;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function getCategoryId(): ?int
{
return $this->categoryId;
}
public function getSort(): int
{
return $this->sort;
}
public function getContents(): Contents
{
return $this->contents;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationCategory;
use App\Dto\Builder\DocumentationCategory;
use App\Dto\Service\Pages;
final readonly class Index extends Pages
{
public function __construct(
private DocumentationCategory $documentationCategory,
int $page
) {
parent::__construct($page);
}
public function getDocumentationCategory(): DocumentationCategory
{
return $this->documentationCategory;
}
}

View File

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationCategory;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Contents;
use App\Dto\Service\Dto;
final readonly class StoreUpdate extends Dto
{
public function __construct(
private string $slug,
private bool $isPublic,
private int $sort,
private Contents $contents,
private ?int $parentId,
) { }
public function getSlug(): string
{
return $this->slug;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function getSort(): int
{
return $this->sort;
}
public function getParentId(): ?int
{
return $this->parentId;
}
public function getContents(): Contents
{
return $this->contents;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationCategoryContent;
final readonly class Content
{
public function __construct(
private int $languageId,
private string $title,
) { }
public function getLanguageId(): int
{
return $this->languageId;
}
public function getTitle(): string
{
return $this->title;
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationCategoryContent;
final class Contents
{
private array $contents = [];
public function addContent(Content $content): void
{
$this->contents[$content->getLanguageId()] = $content;
}
public function getContent(int $languageId): ?Content
{
return $this->contents[$languageId] ?? null;
}
public function getContents(): array
{
return $this->contents;
}
}

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationContent;
final readonly class Content
{
public function __construct(
private int $languageId,
private string $title,
private string $content,
) { }
public function getLanguageId(): int
{
return $this->languageId;
}
public function getTitle(): string
{
return $this->title;
}
public function getContent(): string
{
return $this->content;
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationContent;
final class Contents
{
private array $contents = [];
public function addContent(Content $content): void
{
$this->contents[$content->getLanguageId()] = $content;
}
public function getContent(int $languageId): ?Content
{
return $this->contents[$languageId] ?? null;
}
public function getContents(): array
{
return $this->contents;
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationVersion;
use App\Dto\Builder\DocumentationVersion;
use App\Dto\Service\Pages;
final readonly class Index extends Pages
{
public function __construct(
private DocumentationVersion $documentationVersionBuildDto,
int $page
) {
parent::__construct($page);
}
public function getDocumentationVersionBuildDto(): DocumentationVersion
{
return $this->documentationVersionBuildDto;
}
}

View File

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Admin\Project\DocumentationVersion;
use App\Dto\Service\Dto;
use App\Enums\DocumentationVersionStatus;
final readonly class StoreUpdate extends Dto
{
public function __construct(
private string $title,
private string $slug,
private bool $isPublic,
private DocumentationVersionStatus $status,
) { }
public function getTitle(): string
{
return $this->title;
}
public function getSlug(): string
{
return $this->slug;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function getStatus(): DocumentationVersionStatus
{
return $this->status;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Dto\Service\Admin\Project\Translation;
use App\Enums\DocumentationVersionStatus;
use App\Exceptions\Dto\Admin\Project\Transaction\TranslationsException;
final class Translations
@ -23,7 +24,7 @@ public function getTranslations(): array
public static function getTranslationCodes(): array
{
return [
$translations = [
'site.Menu',
'site.Powered by service',
'site.About project',
@ -38,6 +39,17 @@ public static function getTranslationCodes(): array
'site.attributes.message',
'site.Message sent successfully',
'Server Error',
'site.Documentation',
'site.Documentation not created',
'site.Choose version',
'site.alert-status-not-supported',
'site.alert-status-future',
];
foreach (DocumentationVersionStatus::cases() as $status) {
$translations[] = $status->getCodeForTranslation();
}
return $translations;
}
}

View File

@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\Site;
use App\Models\DocumentationVersion;
use App\Models\Project;
use App\Models\User;
use App\Services\WebsiteTranslations;
final readonly class Documentation
{
public function __construct(
private Project $project,
private DocumentationVersion $version,
private WebsiteTranslations $websiteTranslations,
private ?User $user = null,
) { }
public function getProject(): Project
{
return $this->project;
}
public function getVersion(): DocumentationVersion
{
return $this->version;
}
public function getWebsiteTranslations(): WebsiteTranslations
{
return $this->websiteTranslations;
}
public function getUser(): ?User
{
return $this->user;
}
public function toArray(): array
{
return [
'project' => $this->getProject(),
'version' => $this->getVersion(),
'websiteTranslations' => $this->getWebsiteTranslations(),
];
}
}

View File

@ -9,6 +9,7 @@ enum CacheTag: string
{
case Project = 'project';
case ProjectTranslation = 'project_translation';
case DocumantationVersion = 'documantation_version';
public function getCache(): TaggedCache
{

View File

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace App\Enums;
use App\Services\WebsiteTranslations;
use Illuminate\Support\Collection;
enum DocumentationVersionStatus: int
{
case NotSupported = 0;
case Supported = 50;
case CurrentVersion = 100;
case FutureVersion = 150;
public function getTitle(?WebsiteTranslations $websiteTranslations = null): string
{
if (\is_null($websiteTranslations)) {
return __($this->getCodeForTranslation());
}
return $websiteTranslations->translate($this->getCodeForTranslation());
}
public function getCodeForTranslation(): string
{
return 'version-status.' . $this->name;
}
public static function toArray(): array
{
$items = [];
foreach (self::cases() as $item) {
$items[] = [
'name' => $item->name,
'value' => $item->value,
'title' => $item->getTitle(),
];
}
return $items;
}
public static function toCollection(): Collection
{
return collect(self::toArray());
}
}

View File

@ -12,6 +12,8 @@ enum Permission: string
case ProjectLink = 'project-link';
case ProjectTranslation = 'project-translation';
case ProjectFeedback = 'project-feedback';
case Documentation = 'documentation';
case DocumentationCategory = 'documentation-category';
public function getPermissions(): array
{

View File

@ -5,15 +5,18 @@
use App\Models\Project;
use App\Models\ProjectLanguage;
enum ProjectSection
enum ProjectSection: string
{
case Home;
case Feedback;
case FeedbackSend;
case Home = 'home';
case Feedback = 'feedback';
case FeedbackSend = 'feedback.send';
case Documentation = 'documentation';
case DocumentationVersion = 'documentation.version';
case DocumentationCategory = 'documentation.category';
case DocumentationView = 'documentation.view';
public function url(Project $project, ?ProjectLanguage $language = null): string
public function url(Project $project, ?ProjectLanguage $language = null, array $parameters = []): string
{
$parameters = [];
$prefixProject = '';
if ($project->http_host === null) {
$prefixProject = 'project.';
@ -26,12 +29,7 @@ public function url(Project $project, ?ProjectLanguage $language = null): string
$prefixLanguage = '-language';
}
$route = match ($this) {
self::Home => \route($prefixProject . 'home' . $prefixLanguage, $parameters, false),
self::Feedback => \route($prefixProject . 'feedback' . $prefixLanguage, $parameters, false),
self::FeedbackSend => \route($prefixProject . 'feedback.send' . $prefixLanguage, $parameters, false),
};
$route = \route($prefixProject . $this->value . $prefixLanguage, $parameters, false);
return $project->http_host . $route;
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\DocumentationCategory;
final class ParentException extends \Exception
{
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\DocumentationCategoryContent;
final class ContentSaveException extends \Exception
{
}

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\DocumentationContent;
final class ContentSaveException extends \Exception
{
}

View File

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services;
use App\Contracts\ServiceResultError as ServiceResultErrorContract;
final class ServiceException extends \Exception
{
public function __construct(
private readonly ServiceResultErrorContract $serviceResultError
) {
parent::__construct($this->serviceResultError->getMessage());
}
public function getServiceResultError(): ServiceResultErrorContract
{
return $this->serviceResultError;
}
}

View File

@ -0,0 +1,105 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Admin\Projects;
use App\Dto\QuerySettingsDto;
use App\Http\Controllers\Admin\Controller;
use App\Http\Requests\Admin\Projects\DocumentationCategories\IndexRequest;
use App\Http\Requests\Admin\Projects\DocumentationCategories\StoreUpdateRequest;
use App\Services\Admin\Project\DocumentationCategoryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class DocumentationCategoriesController extends Controller
{
public function __construct(
private readonly DocumentationCategoryService $documentationCategoryService,
) { }
public function index(int $projectId, int $versionId, IndexRequest $request): View
{
$user = $request->user();
$data = $request->getDto();
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $data->getPage(),
queryWith: []
);
$result = $this->documentationCategoryService->index($projectId, $versionId, $data->getDocumentationCategory(), $querySettingsDto, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-categories/index', $result->getData());
}
public function create(int $projectId, int $versionId, Request $request): View
{
$user = $request->user();
$result = $this->documentationCategoryService->create($projectId, $versionId, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-categories/create', $result->getData());
}
public function edit(int $projectId, int $versionId, int $id, Request $request): View
{
$user = $request->user();
$result = $this->documentationCategoryService->edit($projectId, $versionId, $id, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-categories/edit', $result->getData());
}
public function store(int $projectId, int $versionId, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationCategoryService->store($projectId, $versionId, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.categories.edit', [
'project' => $projectId,
'version' => $versionId,
'category' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function update(int $projectId, int $versionId, int $id, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationCategoryService->update($projectId, $versionId, $id, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.categories.edit', [
'project' => $projectId,
'version' => $versionId,
'category' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function destroy(int $projectId, int $versionId, int $id, Request $request): RedirectResponse
{
$user = $request->user();
$result = $this->documentationCategoryService->destroy($projectId, $versionId, $id, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.categories.index', [
'project' => $projectId,
'version' => $versionId,
])->withSuccess($result->getMessage());
}
}

View File

@ -0,0 +1,111 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Admin\Projects;
use App\Dto\QuerySettingsDto;
use App\Http\Controllers\Admin\Controller;
use App\Http\Requests\Admin\Projects\DocumentVersions\IndexRequest;
use App\Http\Requests\Admin\Projects\DocumentVersions\StoreUpdateRequest;
use App\Services\Admin\Project\DocumentationVersionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class DocumentationVersionController extends Controller
{
public function __construct(
private readonly DocumentationVersionService $documentationVersionService,
) { }
public function index(int $projectId, IndexRequest $request): View
{
$user = $request->user();
$data = $request->getDto();
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $data->getPage(),
queryWith: []
);
$result = $this->documentationVersionService->index($projectId, $data->getDocumentationVersionBuildDto(), $querySettingsDto, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-versions/index', $result->getData());
}
public function show(int $projectId, int $id, Request $request): View
{
$user = $request->user();
$result = $this->documentationVersionService->show($projectId, $id, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-versions/show', $result->getData());
}
public function create(int $projectId, Request $request): View
{
$user = $request->user();
$result = $this->documentationVersionService->create($projectId, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-versions/create', $result->getData());
}
public function edit(int $projectId, int $id, Request $request): View
{
$user = $request->user();
$result = $this->documentationVersionService->edit($projectId, $id, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentation-versions/edit', $result->getData());
}
public function store(int $projectId, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationVersionService->store($projectId, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.edit', [
'project' => $projectId,
'documentation_version' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function update(int $projectId, int $id, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationVersionService->update($projectId, $id, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.edit', [
'project' => $projectId,
'documentation_version' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function destroy(int $projectId, int $id, Request $request): RedirectResponse
{
$user = $request->user();
$result = $this->documentationVersionService->destroy($projectId, $id, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.index', ['project' => $projectId])->withSuccess($result->getMessage());
}
}

View File

@ -0,0 +1,105 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Admin\Projects;
use App\Dto\QuerySettingsDto;
use App\Http\Controllers\Admin\Controller;
use App\Http\Requests\Admin\Projects\Documentations\IndexRequest;
use App\Http\Requests\Admin\Projects\Documentations\StoreUpdateRequest;
use App\Services\Admin\Project\DocumentationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class DocumentationsController extends Controller
{
public function __construct(
private readonly DocumentationService $documentationService,
) { }
public function index(int $projectId, int $versionId, IndexRequest $request): View
{
$user = $request->user();
$data = $request->getDto();
$querySettingsDto = new QuerySettingsDto(
limit: 20,
page: $data->getPage(),
queryWith: []
);
$result = $this->documentationService->index($projectId, $versionId, $data->getDocumentation(), $querySettingsDto, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentations/index', $result->getData());
}
public function create(int $projectId, int $versionId, Request $request): View
{
$user = $request->user();
$result = $this->documentationService->create($projectId, $versionId, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentations/create', $result->getData());
}
public function edit(int $projectId, int $versionId, int $id, Request $request): View
{
$user = $request->user();
$result = $this->documentationService->edit($projectId, $versionId, $id, $user);
if ($result->isError()) {
$this->errors($result);
}
return view('admin/projects/documentations/edit', $result->getData());
}
public function store(int $projectId, int $versionId, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationService->store($projectId, $versionId, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.documentations.edit', [
'project' => $projectId,
'version' => $versionId,
'documentation' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function update(int $projectId, int $versionId, int $id, StoreUpdateRequest $request): RedirectResponse
{
$data = $request->getDto();
$user = $request->user();
$result = $this->documentationService->update($projectId, $versionId, $id, $data, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.documentations.edit', [
'project' => $projectId,
'version' => $versionId,
'documentation' => $result->getModel()->id,
])->withSuccess($result->getMessage());
}
public function destroy(int $projectId, int $versionId, int $id, Request $request): RedirectResponse
{
$user = $request->user();
$result = $this->documentationService->destroy($projectId, $versionId, $id, $user);
if ($result->isError()) {
return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage());
}
return redirect()->route('admin.projects.documentation-versions.documentations.index', [
'project' => $projectId,
'version' => $versionId,
])->withSuccess($result->getMessage());
}
}

View File

@ -0,0 +1,96 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Site;
use App\Dto\Service\Site\Documentation;
use App\Services\Site\DocumentationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class DocumentationController extends Controller
{
public function __construct(
private readonly DocumentationService $documentationService,
) { }
public function defaultVersion(Request $request): RedirectResponse | View
{
$project = $request->get('project');
$websiteTranslations = $request->get('websiteTranslations');
$result = $this->documentationService->defaultVersion($project, $request->user());
if ($result->isError()) {
if ($result->getCode() === 404) {
return view('site.projects.documentation.no-default-version', [
'project' => $project,
'websiteTranslations' => $websiteTranslations,
]);
}
$this->errors($result);
}
$url = \App\Enums\Site\ProjectSection::DocumentationVersion->url($project, $websiteTranslations->getLanguage(), ['version' => $result->getVersion()->slug]);
/**
* 302 redirect because the documentation version can change at any time.
*/
return redirect($url, 302);
}
public function index(Request $request): View
{
$documentation = new Documentation(
project: $request->get('project'),
version: $request->get('version'),
websiteTranslations: $request->get('websiteTranslations'),
user: $request->user(),
);
$result = $this->documentationService->index($documentation);
if ($result->isError()) {
$this->errors($result);
}
return view('site.projects.documentation.index', $result->getData());
}
public function category(string $slug, Request $request): View
{
$documentation = new Documentation(
project: $request->get('project'),
version: $request->get('version'),
websiteTranslations: $request->get('websiteTranslations'),
user: $request->user(),
);
$result = $this->documentationService->category($slug, $documentation);
if ($result->isError()) {
$this->errors($result);
}
if ($result->isTranslation()) {
return $this->viewPageWithoutTranslation($result);
}
return view('site.projects.documentation.category', $result->getData());
}
public function view(string $slug, Request $request): View
{
$documentation = new Documentation(
project: $request->get('project'),
version: $request->get('version'),
websiteTranslations: $request->get('websiteTranslations'),
user: $request->user(),
);
$result = $this->documentationService->view($slug, $documentation);
if ($result->isError()) {
$this->errors($result);
}
if ($result->isTranslation()) {
return $this->viewPageWithoutTranslation($result);
}
return view('site.projects.documentation.view', $result->getData());
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Site;
use App\Http\Requests\Site\Feedback\SendRequest;
use App\Models\ProjectFeedback;
use App\Services\Site\FeedbackService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View File

@ -16,7 +16,6 @@ public function index(Request $request): View
{
$user = $request->user();
$project = $request->get('project');
$websiteTranslations = $request->get('websiteTranslations');
if (\is_null($project)) {
$with = ['storage'];
@ -28,7 +27,8 @@ public function index(Request $request): View
return \view('site.projects.index', $result->getData());
}
$result = $this->projectService->getAboutByProject($project, $websiteTranslations, $request->user());
$websiteTranslations = $request->get('websiteTranslations');
$result = $this->projectService->getAboutByProject($project, $websiteTranslations, $user);
if ($result->isError()) {
$this->errors($result);
}

View File

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\CacheTag;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final readonly class DocumentationVersion
{
public function handle(Request $request, \Closure $next): Response
{
$project = $request->get('project');
$versionSlug = $request->route()?->parameter('version');
if ($versionSlug === null || $project === null) {
\abort(Response::HTTP_NOT_FOUND);
}
$seconds = 3600;
$version = CacheTag::DocumantationVersion->getCache()->remember(self::class . $project->id . '-' . $versionSlug, $seconds, function () use ($project, $versionSlug) {
return $project->documentationVersions()->where('slug', $versionSlug)->first() ?? false;
});
if ($version === false) {
\abort(Response::HTTP_NOT_FOUND);
}
unset($request->route()->parameters['version']);
$request->attributes->set('version', $version);
if (
$version->is_public === false
&& ( $request->user() === null || $request->user()->cannot('view', $version) )
) {
\abort(Response::HTTP_FORBIDDEN);
}
return $next($request);
}
}

View File

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Project;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class IsProject
{
public function handle(Request $request, \Closure $next): Response
{
$project = $request->get('project');
if (\is_null($project)) {
\abort(Response::HTTP_NOT_FOUND);
}
if ($project instanceof Project === false) {
\report("$project must be an instance of Project");
\abort(Response::HTTP_NOT_FOUND);
}
if (
$project->is_public === false
&& ( $request->user() === null || $request->user()->cannot('view', $project) )
) {
\abort(Response::HTTP_FORBIDDEN);
}
return $next($request);
}
}

View File

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\WebsiteTranslations;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class IsWebsiteTranslations
{
public function handle(Request $request, \Closure $next): Response
{
$websiteTranslations = $request->get('websiteTranslations');
if (\is_null($websiteTranslations)) {
\abort(Response::HTTP_NOT_FOUND);
}
if ($websiteTranslations instanceof WebsiteTranslations === false) {
\report("$websiteTranslations must be an instance of WebsiteTranslations");
\abort(Response::HTTP_NOT_FOUND);
}
return $next($request);
}
}

View File

@ -7,7 +7,7 @@
use Symfony\Component\HttpFoundation\Response;
use Closure;
class ProjectAndLanguage extends ProjectLanguage
final class ProjectAndLanguage extends ProjectLanguage
{
public function handle(Request $request, Closure $next): Response
{

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\DocumentVersions;
use App\Contracts\FormRequestDto;
use App\Dto\Builder\DocumentationVersion;
use App\Dto\Service\Admin\Project\DocumentationVersion\Index;
use Illuminate\Foundation\Http\FormRequest;
final class IndexRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Index
{
return new Index(
documentationVersionBuildDto: new DocumentationVersion(),
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\DocumentVersions;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Admin\Project\DocumentationVersion\StoreUpdate;
use App\Enums\DocumentationVersionStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:70', 'regex:/^[a-z0-9._-]+$/'],
'is_public' => ['required', 'boolean'],
'status' => ['required', new Enum(DocumentationVersionStatus::class)],
];
}
public function getDto(): StoreUpdate
{
return new StoreUpdate(
title: $this->input('title'),
slug: $this->input('slug'),
isPublic: (bool) $this->input('is_public', false),
status: DocumentationVersionStatus::from((int) $this->input('status')),
);
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\DocumentationCategories;
use App\Contracts\FormRequestDto;
use App\Dto\Builder\DocumentationCategory;
use App\Dto\Service\Admin\Project\DocumentationCategory\Index;
use Illuminate\Foundation\Http\FormRequest;
final class IndexRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Index
{
return new Index(
documentationCategory: new DocumentationCategory(),
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\DocumentationCategories;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Admin\Project\DocumentationCategory\StoreUpdate;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Content;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Contents;
use Illuminate\Foundation\Http\FormRequest;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'slug' => ['required', 'string', 'max:200', 'regex:/^[a-z0-9._-]+$/'],
'is_public' => ['required', 'boolean'],
'sort' => ['required', 'integer', 'min:-1000', 'max:1000'],
'parent_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'content.*.title' => ['required', 'string', 'max:255'],
];
}
public function getDto(): StoreUpdate
{
$parentId = $this->input('parent_id', null);
if (!\is_null($parentId)) {
$parentId = (int) $parentId;
}
return new StoreUpdate(
slug: $this->input('slug'),
isPublic: (bool) $this->input('is_public', false),
sort: (int) $this->input('sort'),
contents: $this->getContents(),
parentId: $parentId,
);
}
private function getContents(): Contents
{
$contents = new Contents();
foreach ($this->input('content', []) as $languageId => $content) {
$contents->addContent(new Content(
languageId: (int) $languageId,
title: $content['title'],
));
}
return $contents;
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\Documentations;
use App\Contracts\FormRequestDto;
use App\Dto\Builder\Documentation;
use App\Dto\Service\Admin\Project\Documentation\Index;
use Illuminate\Foundation\Http\FormRequest;
final class IndexRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'page' => ['nullable', 'numeric', 'min:1']
];
}
public function getDto(): Index
{
return new Index(
documentation: new Documentation(),
page: (int) $this->input('page', 1)
);
}
}

View File

@ -0,0 +1,56 @@
<?php declare(strict_types=1);
namespace App\Http\Requests\Admin\Projects\Documentations;
use App\Contracts\FormRequestDto;
use App\Dto\Service\Admin\Project\Documentation\StoreUpdate;
use App\Dto\Service\Admin\Project\DocumentationContent\Content;
use App\Dto\Service\Admin\Project\DocumentationContent\Contents;
use Illuminate\Foundation\Http\FormRequest;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'slug' => ['required', 'string', 'max:200', 'regex:/^[a-z0-9._-]+$/'],
'is_public' => ['required', 'boolean'],
'sort' => ['required', 'integer', 'min:-1000', 'max:1000'],
'category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'content.*.title' => ['required', 'string', 'max:255'],
'content.*.content' => ['nullable', 'string'],
];
}
public function getDto(): StoreUpdate
{
$categoryId = $this->input('category_id', null);
if (!\is_null($categoryId)) {
$categoryId = (int) $categoryId;
}
return new StoreUpdate(
slug: $this->input('slug'),
isPublic: (bool) $this->input('is_public', false),
sort: (int) $this->input('sort'),
contents: $this->getContents(),
categoryId: $categoryId,
);
}
private function getContents(): Contents
{
$contents = new Contents();
foreach ($this->input('content', []) as $languageId => $content) {
$contents->addContent(new Content(
languageId: (int) $languageId,
title: $content['title'],
content: $content['content'] ?? '',
));
}
return $contents;
}
}

View File

@ -0,0 +1,68 @@
<?php declare(strict_types=1);
namespace App\Models;
use App\Models\Scopes\SortScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ScopedBy([SortScope::class])]
final class Documentation extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'documentation';
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'sort' => 100,
'is_public' => true,
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'slug',
'is_public',
'category_id',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_public' => 'boolean',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(DocumentationCategory::class, 'category_id', 'id');
}
public function contents(): HasMany
{
return $this->hasMany(DocumentationContent::class, 'documentation_id', 'id');
}
public function content(): HasOne
{
return $this->hasOne(DocumentationContent::class, 'documentation_id', 'id');
}
}

View File

@ -0,0 +1,75 @@
<?php declare(strict_types=1);
namespace App\Models;
use App\Models\Scopes\SortScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
#[ScopedBy([SortScope::class])]
final class DocumentationCategory extends Model
{
use HasFactory, SoftDeletes, HasRecursiveRelationships;
protected $table = 'documentation_categories';
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'sort' => 100,
'is_public' => true,
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'slug',
'sort',
'parent_id',
'is_public',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_public' => 'boolean',
];
}
public function parent(): BelongsTo
{
return $this->belongsTo(DocumentationCategory::class, 'parent_id');
}
public function contents(): HasMany
{
return $this->hasMany(DocumentationCategoryContent::class, 'category_id');
}
public function content(): HasOne
{
return $this->hasOne(DocumentationCategoryContent::class, 'category_id');
}
public function version(): BelongsTo
{
return $this->belongsTo(DocumentationVersion::class, 'version_id');
}
}

View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class DocumentationCategoryContent extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'documentation_category_content';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'language_id',
];
}

View File

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class DocumentationContent extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'documentation_content';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'content',
'language_id',
];
}

View File

@ -0,0 +1,78 @@
<?php declare(strict_types=1);
namespace App\Models;
use App\Enums\DocumentationVersionStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
final class DocumentationVersion extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'documentation_versions';
/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'is_public' => true,
'status' => DocumentationVersionStatus::CurrentVersion,
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'slug',
'is_public',
'status',
];
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::addGlobalScope('status', function (Builder $builder) {
$builder->orderBy('status', 'desc');
});
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_public' => 'boolean',
'status' => DocumentationVersionStatus::class,
];
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function documentations(): HasMany
{
return $this->hasMany(Documentation::class, 'version_id');
}
public function categories(): HasMany
{
return $this->hasMany(DocumentationCategory::class, 'version_id');
}
}

View File

@ -62,4 +62,9 @@ public function feedbacks(): HasMany
{
return $this->hasMany(ProjectFeedback::class);
}
public function documentationVersions(): HasMany
{
return $this->hasMany(DocumentationVersion::class);
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\DocumentationCategory;
use App\Models\User;
final readonly class DocumentationCategoryPolicy extends Policy
{
public function viewAny(User $user): bool
{
return $user->hasPermission('documentation-category.view');
}
public function view(User $user, DocumentationCategory $category): bool
{
return $user->hasPermission('documentation-category.view');
}
public function create(User $user): bool
{
return $user->hasPermission('documentation-category.create');
}
public function update(User $user, DocumentationCategory $category): bool
{
return $user->hasPermission('documentation.update');
}
public function delete(User $user, DocumentationCategory $category): bool
{
return $user->hasPermission('documentation-category.delete');
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\Documentation;
use App\Models\User;
final readonly class DocumentationPolicy extends Policy
{
public function viewAny(User $user): bool
{
return $user->hasPermission('documentation.view');
}
public function view(User $user, Documentation $documentation): bool
{
return $user->hasPermission('documentation.view');
}
public function create(User $user): bool
{
return $user->hasPermission('documentation.create');
}
public function update(User $user, Documentation $documentation): bool
{
return $user->hasPermission('documentation.update');
}
public function delete(User $user, Documentation $documentation): bool
{
return $user->hasPermission('documentation.delete');
}
}

View File

@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\DocumentationVersion;
use App\Models\User;
final readonly class DocumentationVersionPolicy extends Policy
{
public function viewAny(User $user): bool
{
// Not a mistake or typo. Shared rights with Documentation.
return $user->hasPermission('documentation.view');
}
public function view(User $user, DocumentationVersion $documentationVersion): bool
{
// Not a mistake or typo. Shared rights with Documentation.
return $user->hasPermission('documentation.view');
}
public function create(User $user): bool
{
// Not a mistake or typo. Shared rights with Documentation.
return $user->hasPermission('documentation.create');
}
public function update(User $user, DocumentationVersion $documentationVersion): bool
{
// Not a mistake or typo. Shared rights with Documentation.
return $user->hasPermission('documentation.update');
}
public function delete(User $user, DocumentationVersion $documentationVersion): bool
{
// Not a mistake or typo. Shared rights with Documentation.
return $user->hasPermission('documentation.delete');
}
}

View File

@ -59,6 +59,8 @@ public function boot(): void
Route::pattern('language', '[a-z_]+');
Route::pattern('project', '[a-z0-9_-]+');
Route::pattern('slug', '[a-z0-9._-]+');
Route::pattern('version', '[a-z0-9._-]+');
$this->configureRateLimiting();
Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']);

View File

@ -0,0 +1,97 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Models\DocumentationCategory;
use App\Models\DocumentationVersion;
use App\Models\ProjectLanguage;
use App\Services\DocumentationCategory\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand;
use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final readonly class DocumentationCategoryRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
private BuilderCommand $builderCommand
) { }
public function getCategories(int $versionId, DocumentationCategoryBuilderDto $documentationCategoryBuilderDto, array $with = []): Search
{
$query = $this->builderCommand->execute(
query: DocumentationCategory::query()->where('version_id', $versionId)->with($with),
documentationCategoryBuilderDto: $documentationCategoryBuilderDto
);
return $this->createSearchInstanceCommand->execute($query);
}
public function getCategoryById(int $id): ?DocumentationCategory
{
return DocumentationCategory::query()->where('id', $id)->first();
}
public function getCategoryBySlugWithContent(string $slug, int $versionId, ProjectLanguage $language): ?DocumentationCategory
{
$with = [
'content' => function (HasOne $hasOne) use ($language) {
$hasOne->where('language_id', $language->id);
}
];
return DocumentationCategory::query()
->where('version_id', $versionId)
->where('slug', $slug)
->with($with)
->first();
}
public function isExistsSlug(int $versionId, string $slug, ?int $exceptId = null): bool
{
return DocumentationCategory::query()
->where('version_id', $versionId)
->where('slug', Str::lower($slug))
->when($exceptId, function (Builder $query, int $exceptId) {
$query->where('id', '!=', $exceptId);
})
->withTrashed()
->exists();
}
public function getForSelect(?ProjectLanguage $defaultLanguage, ?DocumentationCategory $exceptCategory = null, array $withExcepts = []): array
{
$with = [
'content' => function (HasOne $hasOne) use ($defaultLanguage) {
$hasOne->when($defaultLanguage, function (Builder $query, ProjectLanguage $defaultLanguage) {
$query->where('language_id', $defaultLanguage->id);
});
}
];
$categories = DocumentationCategory::query()
->with($with)
->when($exceptCategory, function (Builder $query, DocumentationCategory $exceptCategory) {
$query->whereNotIn(
'id',
$exceptCategory->descendantsAndSelf()->pluck('id')->toArray()
);
})
->when($withExcepts, function (Builder $query) use ($withExcepts) {
$query->withTrashed()->whereNull('deleted_at')->orWhereIn('id', $withExcepts);
})->get();
return $categories->map(function (DocumentationCategory $documentationCategory) use ($defaultLanguage) {
return [
'id' => $documentationCategory->id,
'title' => $documentationCategory->content?->title ?? $documentationCategory->slug,
];
})
->pluck('title', 'id')
->toArray();
}
}

View File

@ -0,0 +1,63 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Dto\Builder\Documentation as DocumentationBuilderDto;
use App\Contracts\Search;
use App\Models\Documentation;
use App\Models\ProjectLanguage;
use App\Services\Documentation\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
final readonly class DocumentationRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
private BuilderCommand $builderCommand
) { }
public function getDocumentations(int $versionId, DocumentationBuilderDto $documentationBuilderDto, array $with = []): Search
{
$query = $this->builderCommand->execute(
query: Documentation::query()->where('version_id', $versionId)->with($with),
documentationBuilderDto: $documentationBuilderDto
);
return $this->createSearchInstanceCommand->execute($query);
}
public function getDocumentationById(int $id): ?Documentation
{
return Documentation::query()->where('id', $id)->first();
}
public function getDocumentationBySlugWithContent(string $slug, int $versionId, ProjectLanguage $language): ?Documentation
{
$with = [
'content' => function (HasOne $hasOne) use ($language) {
$hasOne->where('language_id', $language->id);
}
];
return Documentation::query()
->where('version_id', $versionId)
->where('slug', $slug)
->with($with)
->first();
}
public function isExistsSlug(int $versionId, string $slug, ?int $exceptId = null): bool
{
return Documentation::query()
->where('version_id', $versionId)
->where('slug', Str::lower($slug))
->when($exceptId, function (Builder $query, int $exceptId) {
$query->where('id', '!=', $exceptId);
})
->withTrashed()
->exists();
}
}

View File

@ -0,0 +1,51 @@
<?php declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Search;
use App\Models\DocumentationVersion;
use App\Services\DocumentationVersion\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand;
use App\Dto\Builder\DocumentationVersion as DocumentationVersionBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
final readonly class DocumentationVersionRepository
{
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
private BuilderCommand $builderCommand
) { }
public function getVersions(int $projectId, DocumentationVersionBuilderDto $documentationVersionBuilderDto, array $with = []): Search
{
$query = $this->builderCommand->execute(
query: DocumentationVersion::query()->where('project_id', $projectId)->with($with),
documentationVersionBuilderDto: $documentationVersionBuilderDto
);
return $this->createSearchInstanceCommand->execute($query);
}
public function getVersionById(int $id): ?DocumentationVersion
{
return DocumentationVersion::query()->where('id', $id)->first();
}
public function getVersionByCode(string $code): ?DocumentationVersion
{
return DocumentationVersion::query()->where('code', $code)->first();
}
public function isExistsSlug(int $projectId, string $slug, ?int $exceptId = null): bool
{
return DocumentationVersion::query()
->where('project_id', $projectId)
->where('slug', Str::lower($slug))
->when($exceptId, function (Builder $query, int $exceptId) {
$query->where('id', '!=', $exceptId);
})
->withTrashed()
->exists();
}
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\Site\DocumentationService;
use App\Models\DocumentationVersion;
use App\ServiceResults\ServiceResult;
final class DefaultVersion extends ServiceResult
{
public function __construct(
private readonly DocumentationVersion $version,
) { }
public function getVersion(): DocumentationVersion
{
return $this->version;
}
}

View File

@ -0,0 +1,257 @@
<?php declare(strict_types=1);
namespace App\Services\Admin\Project;
use App\Dto\QuerySettingsDto;
use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto;
use App\Dto\Service\Admin\Project\DocumentationCategory\StoreUpdate;
use App\Exceptions\Services\DocumentationCategory\ParentException;
use App\Exceptions\Services\ServiceException;
use App\Models\DocumentationCategory;
use App\Models\DocumentationCategoryContent;
use App\Models\ProjectLanguage;
use App\Models\User;
use App\Repositories\DocumentationCategoryRepository;
use App\Repositories\DocumentationVersionRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult;
use App\Services\DocumentationCategory\DocumentationCategoryCommandHandler;
use App\Services\DocumentationCategoryContent\ModelSyncCommand;
use App\Services\Service;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
final class DocumentationCategoryService extends Service
{
public function __construct(
private readonly DocumentationVersionRepository $documentationVersionRepository,
private readonly DocumentationCategoryRepository $documentationCategoryRepository,
private readonly DocumentationCategoryCommandHandler $categoryCommandHandler,
private readonly ModelSyncCommand $contentSaveCommand,
) { }
public function index(int $projectId, int $versionId, DocumentationCategoryBuilderDto $documentationCategoryBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('viewAny', DocumentationCategory::class)) {
return $this->errFobidden(__('Access is denied'));
}
$defaultLanguage = $project->languages()->where('is_default', 1)->first();
$with = [
'content' => function (HasOne $hasOne) use ($defaultLanguage) {
/** @var ?ProjectLanguage $defaultLanguage */
$hasOne->when($defaultLanguage, function (Builder $query, ProjectLanguage $defaultLanguage) {
$query->where('language_id', $defaultLanguage->id);
});
}
];
$with = array_merge($with, $querySettingsDto->getQueryWith());
$categories = $this->documentationCategoryRepository->getCategories(
$version->id,
$documentationCategoryBuilderDto,
$with
)->pagination(
$querySettingsDto->getLimit(),
$querySettingsDto->getPage()
);
return $this->result([
'version' => $version,
'project' => $project,
'categories' => $categories,
]);
}
public function create(int $projectId, int $versionId, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('create', DocumentationCategory::class)) {
return $this->errFobidden(__('Access is denied'));
}
$defaultLanguage = $project->languages->where('is_default', 1)->first();
return $this->result([
'version' => $version,
'project' => $project,
'category' => new DocumentationCategory(),
'categories' => $this->documentationCategoryRepository->getForSelect($defaultLanguage),
]);
}
public function edit(int $projectId, int $versionId, int $categoryId, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$category = $this->documentationCategoryRepository->getCategoryById($categoryId);
if (\is_null($category)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $category)) {
return $this->errFobidden(__('Access is denied'));
}
$withCategories = [];
if ($category->parent_id) {
$withCategories[] = $category->parent_id;
}
$defaultLanguage = $project->languages->where('is_default', 1)->first();
return $this->result([
'version' => $version,
'project' => $project,
'category' => $category,
'categories' => $this->documentationCategoryRepository->getForSelect($defaultLanguage, $category, $withCategories),
]);
}
public function store(int $projectId, int $versionId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
if ($user->cannot('create', DocumentationCategory::class)) {
return $this->errFobidden(__('Access is denied'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
try {
$category = DB::transaction(function () use ($data, $version, $user, $versionId, $project) {
if ($this->documentationCategoryRepository->isExistsSlug($versionId, $data->getSlug()) !== false) {
$error = $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
throw new ServiceException($error);
}
$dataCategory = $this->getDataCategory($data);
$category = $this->categoryCommandHandler->handleStore($version, $dataCategory);
$this->contentSaveCommand->execute($project, $category, $data->getContents());
return $category;
});
} catch (ServiceException $e) {
return $e->getServiceResultError();
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($category, __('Category successfully created'));
}
public function update(int $projectId, int $versionId, int $categoryId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$category = $this->documentationCategoryRepository->getCategoryById($categoryId);
if (\is_null($category)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $category)) {
return $this->errFobidden(__('Access is denied'));
}
if ($data->getParentId() === $category->id) {
return $this->errValidate(
__('validation.parent', ['attribute' => __('validation.attributes.parent_id')]),
['parent_id' => __('validation.parent', ['attribute' => __('validation.attributes.parent_id')])]
);
}
try {
$category = DB::transaction(function () use ($data, $category, $versionId, $project) {
if ($this->documentationCategoryRepository->isExistsSlug($versionId, $data->getSlug(), $category->id) !== false) {
$error = $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
throw new ServiceException($error);
}
$dataCategory = $this->getDataCategory($data);
$category = $this->categoryCommandHandler->handleUpdate($category, $dataCategory);
$this->contentSaveCommand->execute($project, $category, $data->getContents());
return $category;
});
} catch (ServiceException $e) {
return $e->getServiceResultError();
} catch (ParentException $e) {
return $this->errValidate(
__('validation.parent_cycle_detected', ['attribute' => __('validation.attributes.parent_id')]),
['parent_id' => __('validation.parent_cycle_detected', ['attribute' => __('validation.attributes.parent_id')])]
);
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($category, __('Category updated successfully'));
}
public function destroy(int $projectId, int $versionId, int $categoryId, User $user): ServiceResultError|ServiceResultSuccess
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$category = $this->documentationCategoryRepository->getCategoryById($categoryId);
if (\is_null($category)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('delete', $category)) {
return $this->errFobidden(__('Access is denied'));
}
try {
DB::transaction(function () use ($category) {
$this->categoryCommandHandler->handleDestroy($category);
});
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->ok(__('Category successfully deleted'));
}
private function getDataCategory(StoreUpdate $data): array
{
return [
'slug' => $data->getSlug(),
'is_public' => $data->isPublic(),
'sort' => $data->getSort(),
'parent_id' => $data->getParentId(),
];
}
}

View File

@ -0,0 +1,239 @@
<?php declare(strict_types=1);
namespace App\Services\Admin\Project;
use App\Dto\Builder\Documentation as DocumentationBuilderDto;
use App\Dto\QuerySettingsDto;
use App\Dto\Service\Admin\Project\Documentation\StoreUpdate;
use App\Models\Documentation;
use App\Models\ProjectLanguage;
use App\Models\User;
use App\Repositories\DocumentationCategoryRepository;
use App\Repositories\DocumentationRepository;
use App\Repositories\DocumentationVersionRepository;
use App\Repositories\ProjectRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult;
use App\Services\Documentation\DocumentationCommandHandler;
use App\Services\DocumentationContent\ModelSyncCommand;
use App\Services\Service;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
final class DocumentationService extends Service
{
public function __construct(
private readonly DocumentationVersionRepository $documentationVersionRepository,
private readonly DocumentationCategoryRepository $documentationCategoryRepository,
private readonly DocumentationRepository $documentationRepository,
private readonly DocumentationCommandHandler $documentationCommandHandler,
private readonly ModelSyncCommand $contentSaveCommand,
) { }
public function index(int $projectId, int $versionId, DocumentationBuilderDto $documentationBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('viewAny', Documentation::class)) {
return $this->errFobidden(__('Access is denied'));
}
$defaultLanguage = $project->languages()->where('is_default', 1)->first();
$with = [
'content' => function (HasOne $hasOne) use ($defaultLanguage) {
/** @var ?ProjectLanguage $defaultLanguage */
$hasOne->when($defaultLanguage, function (Builder $query, ProjectLanguage $defaultLanguage) {
$query->where('language_id', $defaultLanguage->id);
});
}
];
$with = array_merge($with, $querySettingsDto->getQueryWith());
$documentations = $this->documentationRepository->getDocumentations(
$version->id,
$documentationBuilderDto,
$with
)->pagination(
$querySettingsDto->getLimit(),
$querySettingsDto->getPage()
);
return $this->result([
'version' => $version,
'project' => $project,
'documentations' => $documentations,
]);
}
public function create(int $projectId, int $versionId, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('create', Documentation::class)) {
return $this->errFobidden(__('Access is denied'));
}
$defaultLanguage = $project->languages->where('is_default', 1)->first();
return $this->result([
'version' => $version,
'project' => $project,
'documentation' => new Documentation(),
'categories' => $this->documentationCategoryRepository->getForSelect($defaultLanguage),
]);
}
public function edit(int $projectId, int $versionId, int $documentationId, User $user): ServiceResultError | ServiceResultArray
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$documentation = $this->documentationRepository->getDocumentationById($documentationId);
if (\is_null($documentation)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $documentation)) {
return $this->errFobidden(__('Access is denied'));
}
$withCategories = [];
if ($documentation->category_id) {
$withCategories[] = $documentation->category_id;
}
$defaultLanguage = $project->languages->where('is_default', 1)->first();
return $this->result([
'version' => $version,
'project' => $project,
'documentation' => $documentation,
'categories' => $this->documentationCategoryRepository->getForSelect($defaultLanguage, null, $withCategories),
]);
}
public function store(int $projectId, int $versionId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
if ($user->cannot('create', Documentation::class)) {
return $this->errFobidden(__('Access is denied'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
if ($this->documentationRepository->isExistsSlug($versionId, $data->getSlug()) !== false) {
return $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
}
try {
$documentation = DB::transaction(function () use ($data, $version, $user, $project) {
$dataDocumentation = $this->getDataDocumentation($data);
$documentation = $this->documentationCommandHandler->handleStore($version, $dataDocumentation);
$this->contentSaveCommand->execute($project, $documentation, $data->getContents());
return $documentation;
});
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($documentation, __('Documentation created successfully'));
}
public function update(int $projectId, int $versionId, int $documentationId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$documentation = $this->documentationRepository->getDocumentationById($documentationId);
if (\is_null($documentation)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $documentation)) {
return $this->errFobidden(__('Access is denied'));
}
if ($this->documentationRepository->isExistsSlug($versionId, $data->getSlug(), $documentation->id) !== false) {
return $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
}
try {
$documentation = DB::transaction(function () use ($data, $documentation, $project) {
$dataDocumentation = $this->getDataDocumentation($data);
$documentation = $this->documentationCommandHandler->handleUpdate($documentation, $dataDocumentation);
$this->contentSaveCommand->execute($project, $documentation, $data->getContents());
return $documentation;
});
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($documentation, __('Documentation updated successfully'));
}
public function destroy(int $projectId, int $versionId, int $documentationId, User $user): ServiceResultError|ServiceResultSuccess
{
$version = $this->documentationVersionRepository->getVersionById($versionId);
$project = $version?->project;
if (\is_null($version) || $project?->id !== $projectId) {
return $this->errNotFound(__('Not Found'));
}
$documentation = $this->documentationRepository->getDocumentationById($documentationId);
if (\is_null($documentation)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('delete', $documentation)) {
return $this->errFobidden(__('Access is denied'));
}
try {
DB::transaction(function () use ($documentation) {
$this->documentationCommandHandler->handleDestroy($documentation);
});
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->ok(__('Documentation successfully removed'));
}
private function getDataDocumentation(StoreUpdate $data): array
{
return [
'slug' => $data->getSlug(),
'is_public' => $data->isPublic(),
'sort' => $data->getSort(),
'category_id' => $data->getCategoryId(),
];
}
}

View File

@ -0,0 +1,228 @@
<?php declare(strict_types=1);
namespace App\Services\Admin\Project;
use App\Dto\QuerySettingsDto;
use App\Dto\Builder\DocumentationVersion as DocumentationVersionBuilderDto;
use App\Dto\Service\Admin\Project\DocumentationVersion\StoreUpdate;
use App\Enums\CacheTag;
use App\Enums\DocumentationVersionStatus;
use App\Models\DocumentationVersion;
use App\Models\User;
use App\Repositories\DocumentationVersionRepository;
use App\Repositories\ProjectRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult;
use App\Services\ClearCacheCommandHandler;
use App\Services\DocumentationVersion\DocumentationVersionCommandHandler;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
final class DocumentationVersionService extends Service
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly DocumentationVersionRepository $documentationVersionRepository,
private readonly DocumentationVersionCommandHandler $documentationVersionCommandHandler,
private readonly ClearCacheCommandHandler $clearCacheCommandHandler,
) { }
public function index(int $projectId, DocumentationVersionBuilderDto $documentationVersionBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('viewAny', DocumentationVersion::class)) {
return $this->errFobidden(__('Access is denied'));
}
$versions = $this->documentationVersionRepository->getVersions(
$project->id,
$documentationVersionBuilderDto,
$querySettingsDto->getQueryWith()
)->pagination(
$querySettingsDto->getLimit(),
$querySettingsDto->getPage()
);
return $this->result([
'versions' => $versions,
'project' => $project,
]);
}
public function show(int $projectId, int $versionId, User $user): ServiceResultError | ServiceResultArray
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
if (\is_null($version)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $version)) {
return $this->errFobidden(__('Access is denied'));
}
return $this->result([
'version' => $version,
'project' => $project,
]);
}
public function create(int $projectId, User $user): ServiceResultError | ServiceResultArray
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('create', DocumentationVersion::class)) {
return $this->errFobidden(__('Access is denied'));
}
return $this->result([
'version' => new DocumentationVersion(),
'statuses' => DocumentationVersionStatus::toCollection()->pluck( 'title', 'value')->toArray(),
'project' => $project,
]);
}
public function edit(int $projectId, int $versionId, User $user): ServiceResultError | ServiceResultArray
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
if (\is_null($version)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('view', $version)) {
return $this->errFobidden(__('Access is denied'));
}
return $this->result([
'version' => $version,
'statuses' => DocumentationVersionStatus::toCollection()->pluck( 'title', 'value')->toArray(),
'project' => $project,
]);
}
public function store(int $projectId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
if ($user->cannot('create', DocumentationVersion::class)) {
return $this->errFobidden(__('Access is denied'));
}
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
if ($this->documentationVersionRepository->isExistsSlug($projectId, $data->getSlug()) !== false) {
return $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
}
try {
$version = DB::transaction(function () use ($data, $project, $user) {
$dataVersion = $this->getDataVersion($data);
return $this->documentationVersionCommandHandler->handleStore($project, $dataVersion);
});
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($version, __('Documentation version created successfully'));
}
public function update(int $projectId, int $versionId, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
if (\is_null($version)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('update', $version)) {
return $this->errFobidden(__('Access is denied'));
}
if ($this->documentationVersionRepository->isExistsSlug($projectId, $data->getSlug(), $version->id) !== false) {
return $this->errValidate(
__('validation.unique', ['attribute' => __('validation.attributes.slug')]),
['slug' => __('validation.unique', ['attribute' => __('validation.attributes.slug')])]
);
}
try {
$version = DB::transaction(function () use ($data, $version) {
$dataVersion = $this->getDataVersion($data);
return $this->documentationVersionCommandHandler->handleUpdate($version, $dataVersion);
});
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->resultStoreUpdateModel($version, __('Documentation version updated successfully'));
}
public function destroy(int $projectId, int $versionId, User $user): ServiceResultError|ServiceResultSuccess
{
$project = $this->projectRepository->getProjectById($projectId);
if (\is_null($project)) {
return $this->errNotFound(__('Not Found'));
}
$version = $this->documentationVersionRepository->getVersionById($versionId);
if (\is_null($version)) {
return $this->errNotFound(__('Not Found'));
}
if ($user->cannot('delete', $version)) {
return $this->errFobidden(__('Access is denied'));
}
try {
DB::transaction(function () use ($version) {
$this->documentationVersionCommandHandler->handleDestroy($version);
});
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) {
report($e);
return $this->errService(__('Server Error'));
}
return $this->ok(__('Documentation version successfully removed'));
}
private function getDataVersion(StoreUpdate $data): array
{
return [
'title' => $data->getTitle(),
'slug' => $data->getSlug(),
'is_public' => $data->isPublic(),
'status' => $data->getStatus(),
];
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Services\Documentation;
use App\Dto\Builder\Documentation as DocumentationBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
final readonly class BuilderCommand
{
public function execute(Relation | Builder $query, DocumentationBuilderDto $documentationBuilderDto): Relation | Builder
{
return $query
->when($documentationBuilderDto->isPublic(), function (Builder $query) use ($documentationBuilderDto) {
$query->where('is_public', $documentationBuilderDto->isPublic());
})
->when($documentationBuilderDto->getCategoryId(), function (Builder $query) use ($documentationBuilderDto) {
$query->where('category_id', $documentationBuilderDto->getCategoryId()->getCategoryId());
});
}
}

View File

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
namespace App\Services\Documentation;
use App\Models\Documentation;
use App\Models\DocumentationVersion;
use Illuminate\Support\Str;
final readonly class DocumentationCommandHandler
{
public function handleStore(DocumentationVersion $version, array $data): Documentation
{
$data['slug'] = Str::lower($data['slug']);
return $version->documentations()->create($data);
}
public function handleUpdate(Documentation $documentation, array $data): Documentation
{
if (isset($data['slug'])) {
$data['slug'] = Str::lower($data['slug']);
}
$documentation->update($data);
$documentation->touch();
return $documentation;
}
public function handleDestroy(Documentation $documentation): void
{
$documentation->update([
'slug' => $documentation->slug . '#delete:' . $documentation->id,
]);
$documentation->delete();
}
}

View File

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationCategory;
use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
final readonly class BuilderCommand
{
public function execute(Relation | Builder $query, DocumentationCategoryBuilderDto $documentationCategoryBuilderDto): Relation | Builder
{
return $query
->when($documentationCategoryBuilderDto->isPublic(), function (Builder $query) use ($documentationCategoryBuilderDto) {
$query->where('is_public', $documentationCategoryBuilderDto->isPublic());
})
->when($documentationCategoryBuilderDto->getParentId(), function (Builder $query) use ($documentationCategoryBuilderDto) {
$query->where('parent_id', $documentationCategoryBuilderDto->getParentId()->getCategoryId());
});
}
}

View File

@ -0,0 +1,44 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationCategory;
use App\Exceptions\Services\DocumentationCategory\ParentException;
use App\Models\DocumentationCategory;
use App\Models\DocumentationVersion;
use Illuminate\Support\Str;
final readonly class DocumentationCategoryCommandHandler
{
public function handleStore(DocumentationVersion $version, array $data): DocumentationCategory
{
$data['slug'] = Str::lower($data['slug']);
return $version->categories()->create($data);
}
public function handleUpdate(DocumentationCategory $category, array $data): DocumentationCategory
{
if (!empty($data['parent_id'])) {
$category->parent_id = $data['parent_id'];
if ($category->ancestors()->withTrashed()->where('id', $category->id)->exists()) {
throw new ParentException('Category ID occurs in the parent chain (category: ' . $category->id . ', parent_id: ' . $category->parent_id . ').');
}
}
if (isset($data['slug'])) {
$data['slug'] = Str::lower($data['slug']);
}
$category->update($data);
$category->touch();
return $category;
}
public function handleDestroy(DocumentationCategory $category): void
{
$category->update([
'slug' => $category->slug . '#delete:' . $category->id,
]);
$category->delete();
}
}

View File

@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationCategoryContent;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Content;
use App\Dto\Service\Admin\Project\DocumentationCategoryContent\Contents;
use App\Exceptions\Services\DocumentationCategoryContent\ContentSaveException;
use App\Models\DocumentationCategory;
use App\Models\Project;
final readonly class ModelSyncCommand
{
public function execute(Project $project, DocumentationCategory $category, Contents $contents): void
{
$languages = $project->languages;
$categoryContents = $category->contents;
$newContents = [];
foreach ($contents->getContents() as $content) {
/** @var Content $content */
$language = $languages->firstWhere('id', $content->getLanguageId());
if (!$language) {
throw new ContentSaveException('Language not found: ' . $content->getLanguageId());
}
$model = $categoryContents->firstWhere('language_id', $language->id);
$data = $this->getData($content);
if (\is_null($model)) {
$newContents[] = array_merge(['language_id' => $content->getLanguageId()], $data);
continue;
}
$model->update($data);
}
if (!empty($newContents)) {
$category->contents()->createMany($newContents);
}
}
private function getData(Content $content): array
{
return [
'title' => $content->getTitle(),
];
}
}

View File

@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationContent;
use App\Dto\Service\Admin\Project\DocumentationContent\Content;
use App\Dto\Service\Admin\Project\DocumentationContent\Contents;
use App\Exceptions\Services\DocumentationContent\ContentSaveException;
use App\Models\Documentation;
use App\Models\Project;
final readonly class ModelSyncCommand
{
public function execute(Project $project, Documentation $documentation, Contents $contents): void
{
$languages = $project->languages;
$documentationContents = $documentation->contents;
$newContents = [];
foreach ($contents->getContents() as $content) {
/** @var Content $content */
$language = $languages->firstWhere('id', $content->getLanguageId());
if (!$language) {
throw new ContentSaveException('Language not found: ' . $content->getLanguageId());
}
$model = $documentationContents->firstWhere('language_id', $language->id);
$data = $this->getData($content);
if (\is_null($model)) {
$newContents[] = array_merge(['language_id' => $content->getLanguageId()], $data);
continue;
}
$model->update($data);
}
if (!empty($newContents)) {
$documentation->contents()->createMany($newContents);
}
}
private function getData(Content $content): array
{
return [
'title' => $content->getTitle(),
'content' => $content->getContent(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationVersion;
use App\Dto\Builder\DocumentationVersion as DocumentationVersionBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
final readonly class BuilderCommand
{
public function execute(Relation | Builder $query, DocumentationVersionBuilderDto $documentationVersionBuilderDto): Relation | Builder
{
if ($documentationVersionBuilderDto->isPublic() !== null) {
$query->where('is_public', $documentationVersionBuilderDto->isPublic());
}
return $query;
}
}

View File

@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationVersion;
use App\Models\DocumentationVersion;
use App\Models\Project;
use Illuminate\Support\Str;
final readonly class DocumentationVersionCommandHandler
{
public function handleStore(Project $project, array $data): DocumentationVersion
{
$data['slug'] = Str::lower($data['slug']);
return $project->documentationVersions()->create($data);
}
public function handleUpdate(DocumentationVersion $version, array $data): DocumentationVersion
{
if (isset($data['slug'])) {
$data['slug'] = Str::lower($data['slug']);
}
$version->update($data);
$version->touch();
return $version;
}
public function handleDestroy(DocumentationVersion $version): void
{
$version->update([
'slug' => $version->slug . '#delete:' . $version->id,
]);
$version->delete();
}
}

View File

@ -0,0 +1,151 @@
<?php declare(strict_types=1);
namespace App\Services\Site;
use App\Dto\Service\Site\Documentation;
use App\Enums\CacheTag;
use App\Enums\DocumentationVersionStatus;
use App\Models\DocumentationCategory;
use App\Models\Documentation as ModelDocumentation;
use App\Models\DocumentationVersion;
use App\Models\Project;
use App\Models\User;
use App\Repositories\DocumentationCategoryRepository;
use App\Repositories\DocumentationRepository;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\ServiceResults\Site\DocumentationService\DefaultVersion;
use app\ServiceResults\Site\PagePossibleWithoutTranslation;
use App\Services\Service;
use App\Dto\Builder\DocumentationCategory as DocumentationCategoryBuilderDto;
use App\Dto\Builder\Documentation as DocumentationBuilderDto;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection;
final class DocumentationService extends Service
{
public function __construct(
private readonly DocumentationCategoryRepository $documentationCategoryRepository,
private readonly DocumentationRepository $documentationRepository
) { }
public function defaultVersion(Project $project, ?User $user): ServiceResultError | DefaultVersion
{
$seconds = 3600;
$isPublic = null;
if ($user?->cannot('viewAny', DocumentationVersion::class)) {
$isPublic = 1;
}
$version = CacheTag::DocumantationVersion->getCache()
->remember(self::class . $project->id . '_' . $isPublic ?? 0, $seconds, function () use ($project, $isPublic) {
$versions = $project->documentationVersions()
->when($isPublic, function (Builder $query) {
$query->where('is_public', 1);
})
->limit(10)
->get();
return $versions->firstWhere('status', DocumentationVersionStatus::CurrentVersion)
??
$versions->first() ?? false;
});
if ($version === false) {
return $this->errNotFound(__('Not Found'));
}
return new DefaultVersion($version);
}
public function index(Documentation $documentation): ServiceResultError | ServiceResultArray
{
return $this->result(array_merge($documentation->toArray(), [
'categories' => $this->getCategories($documentation, null),
'documentations' => $this->getDocumentations($documentation, null),
]));
}
public function category(string $slug, Documentation $documentation): ServiceResultError | PagePossibleWithoutTranslation
{
$category = $this->documentationCategoryRepository->getCategoryBySlugWithContent($slug, $documentation->getVersion()->id, $documentation->getWebsiteTranslations()->getLanguage());
if (!$category) {
return $this->errNotFound(__('Not Found'));
}
if (
$category->is_public === false &&
($documentation->getUser() === null || $documentation->getUser()->cannot('view', $category))
) {
return $this->errFobidden(__('Access is denied'));
}
$data = array_merge($documentation->toArray(), [
'category' => $category,
'categories' => $this->getCategories($documentation, $category->id),
'documentations' => $this->getDocumentations($documentation, $category->id),
]);
return $this->resultSitePage($documentation->getProject(), $documentation->getWebsiteTranslations(), $data, \is_null($category->content?->title));
}
public function view(string $slug, Documentation $documentation): ServiceResultError | PagePossibleWithoutTranslation
{
$document = $this->documentationRepository->getDocumentationBySlugWithContent($slug, $documentation->getVersion()->id, $documentation->getWebsiteTranslations()->getLanguage());
if (!$document) {
return $this->errNotFound(__('Not Found'));
}
if (
$document->is_public === false &&
($documentation->getUser() === null || $documentation->getUser()->cannot('view', $document))
) {
return $this->errFobidden(__('Access is denied'));
}
$data = array_merge($documentation->toArray(), [
'documentation' => $document,
]);
return $this->resultSitePage($documentation->getProject(), $documentation->getWebsiteTranslations(), $data, \is_null($document->content?->title));
}
private function getCategories(Documentation $documentation, ?int $parentId): Collection
{
$isPublic = null;
if ($documentation->getUser() === null || $documentation->getUser()->cannot('viewAny', DocumentationCategory::class)) {
$isPublic = true;
}
$builderDto = new DocumentationCategoryBuilderDto(
isPublic: $isPublic,
parentId: new DocumentationCategoryBuilderDto\Category($parentId),
);
$with = [
'content' => function (HasOne $hasOne) use ($documentation) {
$hasOne->where('language_id', $documentation->getWebsiteTranslations()->getLanguage()->id);
}
];
return $this->documentationCategoryRepository->getCategories(
$documentation->getVersion()->id,
$builderDto,
$with
)->all();
}
private function getDocumentations(Documentation $documentation, ?int $categoryId): Collection
{
$isPublic = null;
if ($documentation->getUser() === null || $documentation->getUser()->cannot('viewAny', ModelDocumentation::class)) {
$isPublic = true;
}
$builderDto = new DocumentationBuilderDto(
isPublic: $isPublic,
categoryId: new DocumentationCategoryBuilderDto\Category($categoryId),
);
$with = [
'content' => function (HasOne $hasOne) use ($documentation) {
$hasOne->where('language_id', $documentation->getWebsiteTranslations()->getLanguage()->id);
}
];
return $this->documentationRepository->getDocumentations(
$documentation->getVersion()->id,
$builderDto,
$with
)->all();
}
}

View File

@ -0,0 +1,48 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site;
use App\Enums\CacheTag;
use App\Models\DocumentationVersion;
use App\Models\Project;
use App\Models\User;
use App\Services\WebsiteTranslations;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Illuminate\View\Component;
use Illuminate\View\View;
final class ChooseVersion extends Component
{
public function __construct(
private readonly DocumentationVersion $version,
private readonly WebsiteTranslations $websiteTranslations,
private readonly Project $project,
private readonly ?User $user,
) { }
public function render(): View
{
$isPublic = null;
if (\is_null($this->user) || $this->user->cannot('viewAny', DocumentationVersion::class)) {
$isPublic = 1;
}
$seconds = 3600 * 12;
$versions = CacheTag::DocumantationVersion->getCache()
->remember(self::class . $this->project->id . '-' . $isPublic ?? 0, $seconds, function () use ($isPublic) {
return $this->project->documentationVersions()
->when($isPublic, function (Builder $query) {
$query->where('is_public', 1);
})
->get();
});
return view('components.site.choose-version', [
'websiteTranslations' => $this->websiteTranslations,
'versions' => $versions,
'version' => $this->version,
'project' => $this->project,
]);
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace App\View\Components\Site;
use App\Services\WebsiteTranslations;
use Illuminate\View\Component;
use Illuminate\View\View;
final class DocumentationVersion extends Component
{
public function __construct(
private readonly \App\Models\DocumentationVersion $version,
private readonly WebsiteTranslations $websiteTranslations,
) { }
public function render(): View
{
return view('components.site.documentation-version', [
'websiteTranslations' => $this->websiteTranslations,
'version' => $this->version,
]);
}
}

View File

@ -27,12 +27,12 @@ private function getTitle(): string
private function getCheckboxValue(): string
{
return (string) old($this->getRequestName(), $this->checkboxValue);
return (string) $this->checkboxValue;
}
private function getUserValue(): string
{
return (string) $this->userValue;
return (string) old($this->getRequestName(), $this->userValue);
}
private function getNotCheckedValue(): ?string

View File

@ -13,6 +13,7 @@ public function __construct(
private readonly string $type = 'text',
private readonly ?string $value = '',
private readonly ?string $example = null,
private readonly ?string $allowedCharacters = null,
) { }
protected function getName(): string
@ -40,6 +41,11 @@ private function getExample(): ?string
return $this->example;
}
public function getAllowedCharacters(): ?string
{
return $this->allowedCharacters;
}
/**
* @inheritDoc
*/
@ -52,6 +58,7 @@ public function render(): View
'type' => $this->getType(),
'value' => $this->getValue(),
'example' => $this->getExample(),
'allowedCharacters' => $this->getAllowedCharacters(),
]);
}
}

View File

@ -19,7 +19,7 @@ protected function getName(): string
private function getValue(): array
{
$value = old($this->getRequestName(), null);
if (\is_null($value)) {
if (\is_null($value) || !isset($value['items']) || !\is_array($value['items'])) {
return $this->value;
}

View File

@ -9,7 +9,8 @@
"intervention/image-laravel": "^1.2",
"kor-elf/captcha-rule-for-laravel": "^1.0",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.9"
"laravel/tinker": "^2.9",
"staudenmeir/laravel-adjacency-list": "^1.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.13",

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5e3226c3090c81571c830e2c383433ab",
"content-hash": "f7873c3a33599ee1ce0bbce99af9db22",
"packages": [
{
"name": "brick/math",
@ -3381,6 +3381,174 @@
],
"time": "2023-11-08T05:53:05+00:00"
},
{
"name": "staudenmeir/eloquent-has-many-deep-contracts",
"version": "v1.2",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git",
"reference": "bcbe1a921caad7201b324e297eddb696d4bd8647"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/bcbe1a921caad7201b324e297eddb696d4bd8647",
"reference": "bcbe1a921caad7201b324e297eddb696d4bd8647",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentHasManyDeepContracts\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Contracts for staudenmeir/eloquent-has-many-deep",
"support": {
"issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues",
"source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.2"
},
"time": "2024-01-18T01:20:44+00:00"
},
{
"name": "staudenmeir/laravel-adjacency-list",
"version": "v1.22",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/laravel-adjacency-list.git",
"reference": "0ec695e5d4094434f4a7adb956ebd23e228970ea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/laravel-adjacency-list/zipball/0ec695e5d4094434f4a7adb956ebd23e228970ea",
"reference": "0ec695e5d4094434f4a7adb956ebd23e228970ea",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2",
"staudenmeir/eloquent-has-many-deep-contracts": "^1.2",
"staudenmeir/laravel-cte": "^1.11"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0",
"larastan/larastan": "^2.0",
"mockery/mockery": "^1.5.1",
"orchestra/testbench": "^9.0",
"phpunit/phpunit": "^10.5",
"singlestoredb/singlestoredb-laravel": "^1.5.4",
"staudenmeir/eloquent-has-many-deep": "^1.20"
},
"suggest": {
"barryvdh/laravel-ide-helper": "Provide type hints for attributes and relations."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\LaravelAdjacencyList\\IdeHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Staudenmeir\\LaravelAdjacencyList\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Recursive Laravel Eloquent relationships with CTEs",
"support": {
"issues": "https://github.com/staudenmeir/laravel-adjacency-list/issues",
"source": "https://github.com/staudenmeir/laravel-adjacency-list/tree/v1.22"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2024-04-19T12:11:45+00:00"
},
{
"name": "staudenmeir/laravel-cte",
"version": "v1.11",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/laravel-cte.git",
"reference": "1e7063021febf9db7e47d8ea602f582b00d55da6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/laravel-cte/zipball/1e7063021febf9db7e47d8ea602f582b00d55da6",
"reference": "1e7063021febf9db7e47d8ea602f582b00d55da6",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\LaravelCte\\DatabaseServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Staudenmeir\\LaravelCte\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel queries with common table expressions",
"support": {
"issues": "https://github.com/staudenmeir/laravel-cte/issues",
"source": "https://github.com/staudenmeir/laravel-cte/tree/v1.11"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2024-03-09T10:12:45+00:00"
},
{
"name": "symfony/clock",
"version": "v7.0.5",

View File

@ -0,0 +1,124 @@
<?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::create('documentation_versions', function (Blueprint $table) {
$table->id();
$table->string('title', 255);
$table->string('slug', 100);
$table->unsignedBigInteger('project_id')->index();
$table->foreign('project_id')->references('id')->on('projects');
$table->boolean('is_public')->default(0);
$table->unsignedInteger('status')->index();
$table->timestamps();
$table->softDeletes()->index();
$table->unique(['project_id', 'slug']);
$table->index(['is_public', 'deleted_at']);
});
Schema::create('documentation_categories', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->boolean('is_public')->default(0);
$table->integer('sort')->index();
$table->unsignedBigInteger('version_id')->index();
$table->foreign('version_id')->references('id')->on('documentation_versions');
$table->unsignedBigInteger('parent_id')->nullable()->index();
$table->foreign('parent_id')->references('id')->on('documentation_categories');
$table->timestamps();
$table->softDeletes()->index();
$table->index(['slug', 'version_id', 'deleted_at']);
$table->index(['slug', 'version_id', 'is_public', 'deleted_at'], 'slug_version_id_is_public_deleted_at_index');
$table->index(['parent_id', 'deleted_at']);
$table->index(['parent_id', 'is_public', 'deleted_at']);
$table->unique(['version_id', 'slug']);
});
Schema::create('documentation_category_content', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('category_id')->index();
$table->foreign('category_id')->references('id')->on('documentation_categories');
$table->unsignedBigInteger('language_id')->index();
$table->foreign('language_id')->references('id')->on('project_languages');
$table->string('title', 255);
$table->timestamps();
$table->softDeletes();
$table->index(['category_id', 'language_id', 'deleted_at'], 'category_language_deleted_index');
});
Schema::create('documentation', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->unsignedBigInteger('version_id')->index();
$table->foreign('version_id')->references('id')->on('documentation_versions');
$table->unsignedBigInteger('category_id')->nullable()->index();
$table->foreign('category_id')->references('id')->on('documentation_categories');
$table->boolean('is_public')->default(0)->index();
$table->integer('sort')->index();
$table->timestamps();
$table->softDeletes()->index();
$table->index(['slug', 'version_id', 'deleted_at']);
$table->index(['slug', 'version_id', 'is_public', 'deleted_at']);
$table->index(['category_id', 'deleted_at']);
$table->index(['category_id', 'is_public', 'deleted_at']);
$table->unique(['version_id', 'slug']);
});
Schema::create('documentation_content', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('documentation_id')->index();
$table->foreign('documentation_id')->references('id')->on('documentation');
$table->unsignedBigInteger('language_id')->index();
$table->foreign('language_id')->references('id')->on('project_languages');
$table->string('title', 255);
$table->longText('content')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['documentation_id', 'language_id', 'deleted_at'], 'content_documentation_language_deleted_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('documentation_content');
Schema::dropIfExists('documentation');
Schema::dropIfExists('documentation_category_content');
Schema::dropIfExists('documentation_categories');
Schema::dropIfExists('documentation_versions');
}
};

View File

@ -256,5 +256,16 @@
"The link has been deleted": "The link has been deleted",
"Language not found": "Language not found",
"Project not found": "Project not found",
"Translations successfully updated": "Translations successfully updated"
"Translations successfully updated": "Translations successfully updated",
"allowed characters:": "allowed characters:",
"Documentation version created successfully": "Documentation version created successfully",
"Documentation version updated successfully": "Documentation version updated successfully",
"Documentation version successfully removed": "Documentation version successfully removed",
"List": "List",
"Documentation created successfully": "Documentation created successfully",
"Documentation updated successfully": "Documentation updated successfully",
"Documentation successfully removed": "Documentation successfully removed",
"Category successfully created": "Category successfully created",
"Category updated successfully": "Category updated successfully",
"Category successfully deleted": "Category successfully deleted"
}

View File

@ -13,4 +13,7 @@
'Links project' => 'Links from the project',
'Translations' => 'Translations',
'Feedback' => 'Feedback',
'Documentation version' => 'Documentation version',
'Documentation' => 'Documentation',
'Categories' => 'Categories',
];

View File

@ -17,4 +17,6 @@
'ProjectLink' => 'Links from the project',
'ProjectTranslation' => 'Translations',
'ProjectFeedback' => 'Feedback',
'Documentation' => 'Documentation',
'DocumentationCategory' => 'Documentation category',
];

View File

@ -16,4 +16,9 @@
'email' => 'email',
'message' => 'message',
],
'Documentation' => 'Documentation',
'Documentation not created' => 'Documentation not created',
'Choose version' => 'Choose version',
'alert-status-not-supported' => 'WARNING! You are viewing the documentation for an older version.',
'alert-status-future' => 'WARNING! You\'re browsing the documentation for an upcoming version. The documentation and features of this release are subject to change.',
];

View File

@ -151,6 +151,8 @@
'no_type' => 'The :attribute can only use: :type.',
'captcha' => 'Failed to pass human verification.',
'http_host' => 'The :attribute must be a valid domain.',
'parent' => 'A parent cannot refer to itself.',
'parent_cycle_detected' => 'Category ID occurs in the parent chain.',
'attributes' => [
'address' => 'address',
'affiliate_url' => 'affiliate URL',
@ -301,5 +303,9 @@
'translations' => 'translations',
'translations.*.code' => 'translations code',
'translations.*.text' => 'translations',
'parent_id' => 'parent',
'content.*.title' => 'title',
'content.*.content' => 'content',
'category_id' => 'category',
],
];

View File

@ -0,0 +1,7 @@
<?php
return [
'NotSupported' => 'Not Supported',
'Supported' => 'Supported',
'CurrentVersion' => 'Current version',
'FutureVersion' => 'Future version',
];

View File

@ -256,5 +256,16 @@
"The link has been deleted": "Ссылка удалена",
"Language not found": "Язык не найден",
"Project not found": "Проект не найден",
"Translations successfully updated": "Переводы успешно обновлены"
"Translations successfully updated": "Переводы успешно обновлены",
"allowed characters:": "разрешенные символы:",
"Documentation version created successfully": "Версия документации успешно создана",
"Documentation version updated successfully": "Версия документации успешно обновлена",
"Documentation version successfully removed": "Версия документации успешно удалена",
"List": "Список",
"Documentation created successfully": "Документация успешно создана",
"Documentation updated successfully": "Документация успешно обновлена",
"Documentation successfully removed": "Документация успешно удалена",
"Category successfully created": "Категория успешно создана",
"Category updated successfully": "Категория успешно обновлена",
"Category successfully deleted": "Категория успешно удалена"
}

View File

@ -13,4 +13,7 @@
'Links project' => 'Ссылки от проекта',
'Translations' => 'Переводы',
'Feedback' => 'Обратная связь',
'Documentation version' => 'Версия документации',
'Documentation' => 'Документация',
'Categories' => 'Категории',
];

View File

@ -17,4 +17,6 @@
'ProjectLink' => 'Ссылки от проекта',
'ProjectTranslation' => 'Переводы',
'ProjectFeedback' => 'Обратная связь',
'Documentation' => 'Документация',
'DocumentationCategory' => 'Категория документации',
];

View File

@ -16,4 +16,9 @@
'email' => 'email',
'message' => 'сообщение',
],
'Documentation' => 'Документация',
'Documentation not created' => 'Документация не создана',
'Choose version' => 'Выберите версию',
'alert-status-not-supported' => 'ВНИМАНИЕ! Вы просматриваете документацию для старой версии.',
'alert-status-future' => 'ВНИМАНИЕ! Вы просматриваете документацию к будущей версии. Документация и функции этого выпуска могут быть изменены.',
];

View File

@ -151,6 +151,8 @@
'no_type' => 'Значение поля :attribute может использовать только: :type.',
'captcha' => 'Не удалось пройти проверку человеком.',
'http_host' => 'Значение поля :attribute не является доменом или имеет некорректный формат.',
'parent' => 'Родитель не может ссылаться на себя.',
'parent_cycle_detected' => 'ID категории встречается в родительской цепочке.',
'attributes' => [
'address' => 'адрес',
'affiliate_url' => 'Партнёрская ссылка',
@ -301,5 +303,9 @@
'translations' => 'переводы',
'translations.*.code' => 'код перевода',
'translations.*.text' => 'переводы',
'parent_id' => 'родитель',
'content.*.title' => 'заголовок',
'content.*.content' => 'контент',
'category_id' => 'категория',
],
];

View File

@ -0,0 +1,7 @@
<?php
return [
'NotSupported' => 'Не поддерживается',
'Supported' => 'Поддерживается',
'CurrentVersion' => 'Текущая версия',
'FutureVersion' => 'Будущая версия',
];

View File

@ -13,6 +13,7 @@
"notyf": "^3.10.0",
"nouislider": "^15.2.0",
"onscreen": "^1.4.0",
"prismjs": "^1.29.0",
"sass": "^1.47.0",
"simplebar": "^5.3.4",
"smooth-scroll": "^16.1.3",
@ -1766,6 +1767,14 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prismjs": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",

View File

@ -14,6 +14,7 @@
"notyf": "^3.10.0",
"nouislider": "^15.2.0",
"onscreen": "^1.4.0",
"prismjs": "^1.29.0",
"sass": "^1.47.0",
"simplebar": "^5.3.4",
"smooth-scroll": "^16.1.3",

View File

@ -0,0 +1,32 @@
import Prism from 'prismjs';
import "prismjs/components/prism-core.js";
import "prismjs/components/prism-clike.js";
import "prismjs/components/prism-markup.js";
import "prismjs/components/prism-markup-templating.js"
import "prismjs/components/prism-go.js";
import "prismjs/components/prism-go-module.js";
import "prismjs/components/prism-java.js";
import "prismjs/components/prism-javascript.js";
import "prismjs/components/prism-css.js";
import "prismjs/components/prism-ruby.js";
import "prismjs/components/prism-python.js";
import "prismjs/components/prism-c.js";
import "prismjs/components/prism-csharp.js";
import "prismjs/components/prism-cpp.js";
import "prismjs/components/prism-nginx.js";
import "prismjs/components/prism-docker.js";
import "prismjs/components/prism-diff.js";
import "prismjs/components/prism-php.js";
import "prismjs/plugins/toolbar/prism-toolbar.css";
import "prismjs/plugins/toolbar/prism-toolbar.js";
import "prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard.js";
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
import "prismjs/plugins/line-numbers/prism-line-numbers.js";
import "prismjs/plugins/show-language/prism-show-language.js"
import "prismjs/plugins/treeview/prism-treeview.css"
import "prismjs/plugins/treeview/prism-treeview.js"
import "prismjs/themes/prism.css";

View File

@ -0,0 +1,8 @@
let blockDocumentationVersion = document.querySelector('#documentation-version');
blockDocumentationVersion.querySelector('.documentation-version__button').addEventListener('click', (e) => {
if (blockDocumentationVersion.classList.contains('active')) {
blockDocumentationVersion.classList.remove('active');
} else {
blockDocumentationVersion.classList.add('active');
}
});

View File

@ -1,2 +1,3 @@
import './_menu.js';
import './_choose-language.js';
import './_choose-documentation-version.js';

View File

@ -43,7 +43,8 @@ body {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"logo menu"
"language language";
"language language"
"documentation-version documentation-version";
padding: 8px;
box-shadow: 0 3px 9px rgba(0,0,0,0.48);
background: #eee;
@ -79,7 +80,7 @@ body {
margin-left: auto;
cursor: pointer;
position: relative;
z-index: 10;
z-index: 110;
.open {
display: block;
@ -138,6 +139,7 @@ body.mobile-menu-open {
overflow: auto;
width: 100%;
height: 100%;
z-index: 100;
}
}
#language {
@ -176,6 +178,7 @@ body.mobile-menu-open {
padding: 10px 0;
margin: 0;
border-radius: 0 0 5px 5px;
z-index: 100;
a {
text-decoration: none;
@ -193,6 +196,77 @@ body.mobile-menu-open {
border-radius: 5px 5px 0 0;
}
}
#documentation-version {
grid-area: documentation-version;
margin-top: 10px;
.status {
font-size: 13px;
color: #444;
display: block;
}
.status.status__100 {
color: #0a6f4d;
}
.status.status__50 {
color: #0a53be;
}
.status.status__0 {
color: #6c0e22;
}
}
.documentation-version__block {
position: relative;
}
.documentation-version__button {
border: 0;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.48);
background: #ccc;
width: 100%;
cursor: pointer;
font-size: 18px;
position: relative;
padding: 10px;
border-radius: 5px;
color: #000;
}
.documentation-version__button__str {
position: absolute;
top: calc(50% - 8px);
right: 10px;
}
.documentation-version__list {
background: #ddd;
display: none;
position: absolute;
top: 100%;
left: 0;
width: 100%;
list-style: none;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.48);
padding: 10px 0;
margin: 0;
border-radius: 0 0 5px 5px;
z-index: 100;
a {
text-decoration: none;
color: #000;
font-size: 16px;
display: block;
padding: 10px 20px;
}
}
#documentation-version.active {
.documentation-version__list {
display: block;
}
.documentation-version__button {
border-radius: 5px 5px 0 0;
}
}
.main-container {
display: flex;
flex-direction: column;
@ -223,6 +297,15 @@ body.mobile-menu-open {
font-weight: bold;
}
}
.alert-version {
padding: 20px;
margin-top: 20px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.5);
font-size: 16px;
}
.alert-version.version-status-not-supported {
color: #6c0e22;
}
@media (min-width: 1000px) {
body {
@ -230,8 +313,8 @@ body.mobile-menu-open {
margin: 0 auto;
}
.header {
grid-template-areas: "logo language";
grid-template-columns: 1fr 200px;
grid-template-areas: "logo documentation-version language";
grid-template-columns: 1fr 200px 200px;
}
.header_logo {
width: 150px;
@ -242,9 +325,13 @@ body.mobile-menu-open {
#mobile-menu {
display: none;
}
.wrapper .section-container {
flex: auto;
}
.main-container {
flex-direction: row;
}
#language {
margin-right: 10px;
}
@ -254,7 +341,19 @@ body.mobile-menu-open {
.language__button {
padding: 7px 10px 7px 0;
}
#documentation-version {
margin-right: 10px;
}
.documentation-version__block {
margin-top: 0px;
}
.documentation-version__button {
padding: 7px 10px 7px 0;
}
#menu {
flex: none;
background: #eee;
display: block;
width: 175px;

View File

@ -0,0 +1 @@
@vite('resources/prism/app.js')

View File

@ -0,0 +1,33 @@
<script>
(function() {
function clickEnableAddEventListener(element, index, array) {
let parent = element.closest('.{{ $classParent }}'),
contents = parent.querySelectorAll('.{{ $classContent }}'),
disabled = true;
if (element.checked) {
disabled = false;
}
contents.forEach(function (content) {
content.disabled = disabled;
});
element.addEventListener('click', (e) => {
let disabled = true;
if (element.checked) {
disabled = false;
}
contents.forEach(function (content) {
content.disabled = disabled;
});
});
}
let clickEnable = document.querySelectorAll('.{{ $classCheckbox }}');
if (clickEnable.length > 0) {
clickEnable.forEach(clickEnableAddEventListener);
}
})();
</script>

View File

@ -0,0 +1,34 @@
@csrf
<x-volt.forms.checkbox :title="__('validation.attributes.is_public')" name="is_public" checkboxValue="1" notCheckedValue="0" :userValue="(string) $category->is_public" />
<x-volt.forms.input :title="__('validation.attributes.slug')" allowed-characters="a-z0-9.-_" name="slug" type="text" :value="$category->slug" required autofocus />
<x-volt.forms.input :title="__('validation.attributes.sort')" name="sort" type="number" :value="$category->sort" required />
<x-volt.forms.select :title="__('validation.attributes.parent_id')" name="parent_id" :list="$categories" :value="(string) $category->parent_id">
<option value=""></option>
</x-volt.forms.select>
<nav>
<div class="nav nav-tabs mb-4" id="nav-language-tab" role="tablist">
@foreach($project->languages as $index => $language)
<a class="nav-item nav-link @if($index === 0) active @endif" id="language-{{ $language->id }}-tab" data-bs-toggle="tab" href="#language-{{ $language->id }}" role="tab" aria-controls="language-{{ $language->id }}" aria-selected="{{ $index ? 'false' : 'true' }}">{{ $language->title }}</a>
@endforeach
</div>
</nav>
<div class="tab-content" id="nav-language-tabContent">
@foreach($project->languages as $index => $language)
@php
$content = $category->contents->firstWhere('language_id', $language->id);
@endphp
<div class="tab-pane fade @if($index === 0) show active @endif" id="language-{{ $language->id }}" role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab">
<x-volt.forms.checkbox :title="__('Edit')" :name="'content-enable-' . $language->id" :user-value="($index === 0) ? 1 : 0" class="content-enable" checkbox-value="1" notCheckedValue="0"/>
<x-volt.forms.input :title="__('validation.attributes.title')" :name="'content[' . $language->id . '][title]'" type="text" class="language-content" :disabled="$index !== 0" :value="$content?->title ?? ''" required />
</div>
@endforeach
</div>
@canany(['create', 'update'], $category)
<button class="btn btn-primary" type="submit">{{ __('Save') }}</button>
@endcanany
@push('scripts')
@include('admin._scripts._click-content-enable', ['classParent' => 'tab-pane', 'classCheckbox' => 'content-enable', 'classContent' => 'language-content'])
@endpush

View File

@ -0,0 +1,25 @@
<div class="mb-4">
@can('create', \App\Models\DocumentationCategory::class)
<a href="{{ route('admin.projects.documentation-versions.categories.create', ['project' => $project->id, 'version' => $version->id]) }}" class="btn btn-secondary d-inline-flex align-items-center me-2">
<svg class="icon icon-xs me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
{{ __('Create') }}
</a>
@endcan
@can('viewAny', \App\Models\DocumentationCategory::class)
<a href="{{ route('admin.projects.documentation-versions.categories.index', ['project' => $project->id, 'version' => $version->id]) }}" class="btn btn-secondary d-inline-flex align-items-center me-2">
<svg class="icon icon-xs me-2" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"></path>
</svg>
{{ __('List') }}
</a>
@endcan
@can('viewAny', \App\Models\Documentation::class)
<a href="{{ route('admin.projects.documentation-versions.documentations.index', ['project' => $project->id, 'version' => $version->id]) }}" class="btn btn-secondary d-inline-flex align-items-center me-2">
<svg class="icon icon-xs me-2" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"></path>
</svg>
{{ __('admin-sections.Documentation') }}
</a>
@endcan
</div>

View File

@ -0,0 +1,19 @@
@section('meta_title', __('admin-sections.Categories'))
@section('h1')
{{ __('admin-sections.Project') . ': ' . $project->name . ' (' . $version->title . ')' }}
@endsection
<x-admin.layout>
@include('admin.projects.documentation-categories._top')
<div class="row">
<div class="col-12 mb-4">
<div class="card border-0 shadow components-section">
<div class="card-body">
<h3 id="category" class="mb-4">{{ __('admin-sections.Categories') }}</h3>
<form method="post" action="{{ route('admin.projects.documentation-versions.categories.store', ['project' => $project->id, 'version' => $version->id]) }}">
@include('admin.projects.documentation-categories._from')
</form>
</div>
</div>
</div>
</div>
</x-admin.layout>

View File

@ -0,0 +1,20 @@
@section('meta_title', __('admin-sections.Categories'))
@section('h1')
{{ __('admin-sections.Project') . ': ' . $project->name . ' (' . $version->title . ')' }}
@endsection
<x-admin.layout>
@include('admin.projects.documentation-categories._top')
<div class="row">
<div class="col-12 mb-4">
<div class="card border-0 shadow components-section">
<div class="card-body">
<h3 id="category" class="mb-4">{{ __('admin-sections.Categories') }}</h3>
<form method="post" action="{{ route('admin.projects.documentation-versions.categories.update', ['project' => $project->id, 'version' => $version->id, 'category' => $category->id]) }}">
@method('PUT')
@include('admin.projects.documentation-categories._from')
</form>
</div>
</div>
</div>
</div>
</x-admin.layout>

Some files were not shown because too many files have changed in this diff Show More