A documentation section has been added to the site.

This commit is contained in:
Leonid Nikitin 2024-05-19 19:47:02 +05:00
parent 42701a24db
commit e74456ee2f
Signed by: kor-elf
GPG Key ID: 3C0F720C170F6E1D
35 changed files with 766 additions and 34 deletions

View File

@ -2,14 +2,22 @@
namespace App\Dto\Builder; namespace App\Dto\Builder;
use App\Dto\Builder\DocumentationCategory\Category;
final readonly class Documentation final readonly class Documentation
{ {
public function __construct( public function __construct(
private ?bool $isPublic = null, private ?bool $isPublic = null,
private ?Category $categoryId = null,
) { } ) { }
public function isPublic(): ?bool public function isPublic(): ?bool
{ {
return $this->isPublic; return $this->isPublic;
} }
public function getCategoryId(): ?Category
{
return $this->categoryId;
}
} }

View File

@ -2,14 +2,22 @@
namespace App\Dto\Builder; namespace App\Dto\Builder;
use App\Dto\Builder\DocumentationCategory\Category;
final readonly class DocumentationCategory final readonly class DocumentationCategory
{ {
public function __construct( public function __construct(
private ?bool $isPublic = null, private ?bool $isPublic = null,
private ?Category $parentId = null,
) { } ) { }
public function isPublic(): ?bool public function isPublic(): ?bool
{ {
return $this->isPublic; 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

@ -2,6 +2,7 @@
namespace App\Dto\Service\Admin\Project\Translation; namespace App\Dto\Service\Admin\Project\Translation;
use App\Enums\DocumentationVersionStatus;
use App\Exceptions\Dto\Admin\Project\Transaction\TranslationsException; use App\Exceptions\Dto\Admin\Project\Transaction\TranslationsException;
final class Translations final class Translations
@ -23,7 +24,7 @@ public function getTranslations(): array
public static function getTranslationCodes(): array public static function getTranslationCodes(): array
{ {
return [ $translations = [
'site.Menu', 'site.Menu',
'site.Powered by service', 'site.Powered by service',
'site.About project', 'site.About project',
@ -38,6 +39,17 @@ public static function getTranslationCodes(): array
'site.attributes.message', 'site.attributes.message',
'site.Message sent successfully', 'site.Message sent successfully',
'Server Error', '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 Project = 'project';
case ProjectTranslation = 'project_translation'; case ProjectTranslation = 'project_translation';
case DocumantationVersion = 'documantation_version';
public function getCache(): TaggedCache public function getCache(): TaggedCache
{ {

View File

@ -2,6 +2,7 @@
namespace App\Enums; namespace App\Enums;
use App\Services\WebsiteTranslations;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
enum DocumentationVersionStatus: int enum DocumentationVersionStatus: int
@ -11,9 +12,18 @@ enum DocumentationVersionStatus: int
case CurrentVersion = 100; case CurrentVersion = 100;
case FutureVersion = 150; case FutureVersion = 150;
public function getTitle(): string public function getTitle(?WebsiteTranslations $websiteTranslations = null): string
{ {
return __('version-status.' . $this->name); 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 public static function toArray(): array

View File

@ -5,15 +5,18 @@
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectLanguage; use App\Models\ProjectLanguage;
enum ProjectSection enum ProjectSection: string
{ {
case Home; case Home = 'home';
case Feedback; case Feedback = 'feedback';
case FeedbackSend; 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 = ''; $prefixProject = '';
if ($project->http_host === null) { if ($project->http_host === null) {
$prefixProject = 'project.'; $prefixProject = 'project.';
@ -26,12 +29,7 @@ public function url(Project $project, ?ProjectLanguage $language = null): string
$prefixLanguage = '-language'; $prefixLanguage = '-language';
} }
$route = match ($this) { $route = \route($prefixProject . $this->value . $prefixLanguage, $parameters, false);
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),
};
return $project->http_host . $route; return $project->http_host . $route;
} }

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

@ -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

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Models\Scopes\SortScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -9,6 +11,7 @@
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[ScopedBy([SortScope::class])]
final class Documentation extends Model final class Documentation extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\DocumentationVersionStatus; use App\Enums\DocumentationVersionStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -37,6 +38,16 @@ final class DocumentationVersion extends Model
'status', '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. * Get the attributes that should be cast.
* *

View File

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

View File

@ -4,6 +4,7 @@
use App\Contracts\Search; use App\Contracts\Search;
use App\Models\DocumentationCategory; use App\Models\DocumentationCategory;
use App\Models\DocumentationVersion;
use App\Models\ProjectLanguage; use App\Models\ProjectLanguage;
use App\Services\DocumentationCategory\BuilderCommand; use App\Services\DocumentationCategory\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand; use App\Services\Search\CreateSearchInstanceCommand;
@ -35,9 +36,19 @@ public function getCategoryById(int $id): ?DocumentationCategory
return DocumentationCategory::query()->where('id', $id)->first(); return DocumentationCategory::query()->where('id', $id)->first();
} }
public function getCategoryByCode(string $code): ?DocumentationCategory public function getCategoryBySlugWithContent(string $slug, int $versionId, ProjectLanguage $language): ?DocumentationCategory
{ {
return DocumentationCategory::query()->where('code', $code)->first(); $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 public function isExistsSlug(int $versionId, string $slug, ?int $exceptId = null): bool

View File

@ -5,9 +5,11 @@
use App\Dto\Builder\Documentation as DocumentationBuilderDto; use App\Dto\Builder\Documentation as DocumentationBuilderDto;
use App\Contracts\Search; use App\Contracts\Search;
use App\Models\Documentation; use App\Models\Documentation;
use App\Models\ProjectLanguage;
use App\Services\Documentation\BuilderCommand; use App\Services\Documentation\BuilderCommand;
use App\Services\Search\CreateSearchInstanceCommand; use App\Services\Search\CreateSearchInstanceCommand;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final readonly class DocumentationRepository final readonly class DocumentationRepository
@ -32,9 +34,19 @@ public function getDocumentationById(int $id): ?Documentation
return Documentation::query()->where('id', $id)->first(); return Documentation::query()->where('id', $id)->first();
} }
public function getDocumentationByCode(string $code): ?Documentation public function getDocumentationBySlugWithContent(string $slug, int $versionId, ProjectLanguage $language): ?Documentation
{ {
return Documentation::query()->where('code', $code)->first(); $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 public function isExistsSlug(int $versionId, string $slug, ?int $exceptId = null): bool

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

@ -5,6 +5,7 @@
use App\Dto\QuerySettingsDto; use App\Dto\QuerySettingsDto;
use App\Dto\Builder\DocumentationVersion as DocumentationVersionBuilderDto; use App\Dto\Builder\DocumentationVersion as DocumentationVersionBuilderDto;
use App\Dto\Service\Admin\Project\DocumentationVersion\StoreUpdate; use App\Dto\Service\Admin\Project\DocumentationVersion\StoreUpdate;
use App\Enums\CacheTag;
use App\Enums\DocumentationVersionStatus; use App\Enums\DocumentationVersionStatus;
use App\Models\DocumentationVersion; use App\Models\DocumentationVersion;
use App\Models\User; use App\Models\User;
@ -14,6 +15,7 @@
use App\ServiceResults\ServiceResultError; use App\ServiceResults\ServiceResultError;
use App\ServiceResults\ServiceResultSuccess; use App\ServiceResults\ServiceResultSuccess;
use App\ServiceResults\StoreUpdateResult; use App\ServiceResults\StoreUpdateResult;
use App\Services\ClearCacheCommandHandler;
use App\Services\DocumentationVersion\DocumentationVersionCommandHandler; use App\Services\DocumentationVersion\DocumentationVersionCommandHandler;
use App\Services\Service; use App\Services\Service;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -24,6 +26,7 @@ public function __construct(
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly DocumentationVersionRepository $documentationVersionRepository, private readonly DocumentationVersionRepository $documentationVersionRepository,
private readonly DocumentationVersionCommandHandler $documentationVersionCommandHandler, private readonly DocumentationVersionCommandHandler $documentationVersionCommandHandler,
private readonly ClearCacheCommandHandler $clearCacheCommandHandler,
) { } ) { }
public function index(int $projectId, DocumentationVersionBuilderDto $documentationVersionBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray public function index(int $projectId, DocumentationVersionBuilderDto $documentationVersionBuilderDto, QuerySettingsDto $querySettingsDto, User $user): ServiceResultError | ServiceResultArray
@ -138,6 +141,7 @@ public function store(int $projectId, StoreUpdate $data, User $user): ServiceRes
$dataVersion = $this->getDataVersion($data); $dataVersion = $this->getDataVersion($data);
return $this->documentationVersionCommandHandler->handleStore($project, $dataVersion); return $this->documentationVersionCommandHandler->handleStore($project, $dataVersion);
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -174,6 +178,7 @@ public function update(int $projectId, int $versionId, StoreUpdate $data, User $
$dataVersion = $this->getDataVersion($data); $dataVersion = $this->getDataVersion($data);
return $this->documentationVersionCommandHandler->handleUpdate($version, $dataVersion); return $this->documentationVersionCommandHandler->handleUpdate($version, $dataVersion);
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));
@ -202,6 +207,7 @@ public function destroy(int $projectId, int $versionId, User $user): ServiceResu
DB::transaction(function () use ($version) { DB::transaction(function () use ($version) {
$this->documentationVersionCommandHandler->handleDestroy($version); $this->documentationVersionCommandHandler->handleDestroy($version);
}); });
$this->clearCacheCommandHandler->byTag(CacheTag::DocumantationVersion);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
return $this->errService(__('Server Error')); return $this->errService(__('Server Error'));

View File

@ -10,10 +10,12 @@
{ {
public function execute(Relation | Builder $query, DocumentationBuilderDto $documentationBuilderDto): Relation | Builder public function execute(Relation | Builder $query, DocumentationBuilderDto $documentationBuilderDto): Relation | Builder
{ {
if ($documentationBuilderDto->isPublic() !== null) { return $query
->when($documentationBuilderDto->isPublic(), function (Builder $query) use ($documentationBuilderDto) {
$query->where('is_public', $documentationBuilderDto->isPublic()); $query->where('is_public', $documentationBuilderDto->isPublic());
} })
->when($documentationBuilderDto->getCategoryId(), function (Builder $query) use ($documentationBuilderDto) {
return $query; $query->where('category_id', $documentationBuilderDto->getCategoryId()->getCategoryId());
});
} }
} }

View File

@ -10,10 +10,12 @@
{ {
public function execute(Relation | Builder $query, DocumentationCategoryBuilderDto $documentationCategoryBuilderDto): Relation | Builder public function execute(Relation | Builder $query, DocumentationCategoryBuilderDto $documentationCategoryBuilderDto): Relation | Builder
{ {
if ($documentationCategoryBuilderDto->isPublic() !== null) { return $query
->when($documentationCategoryBuilderDto->isPublic(), function (Builder $query) use ($documentationCategoryBuilderDto) {
$query->where('is_public', $documentationCategoryBuilderDto->isPublic()); $query->where('is_public', $documentationCategoryBuilderDto->isPublic());
} })
->when($documentationCategoryBuilderDto->getParentId(), function (Builder $query) use ($documentationCategoryBuilderDto) {
return $query; $query->where('parent_id', $documentationCategoryBuilderDto->getParentId()->getCategoryId());
});
} }
} }

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

@ -16,4 +16,9 @@
'email' => 'email', 'email' => 'email',
'message' => 'message', '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

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

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 './_menu.js';
import './_choose-language.js'; import './_choose-language.js';
import './_choose-documentation-version.js';

View File

@ -43,7 +43,8 @@ body {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-areas: grid-template-areas:
"logo menu" "logo menu"
"language language"; "language language"
"documentation-version documentation-version";
padding: 8px; padding: 8px;
box-shadow: 0 3px 9px rgba(0,0,0,0.48); box-shadow: 0 3px 9px rgba(0,0,0,0.48);
background: #eee; background: #eee;
@ -195,6 +196,77 @@ body.mobile-menu-open {
border-radius: 5px 5px 0 0; 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 { .main-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -225,6 +297,15 @@ body.mobile-menu-open {
font-weight: bold; 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) { @media (min-width: 1000px) {
body { body {
@ -232,8 +313,8 @@ body.mobile-menu-open {
margin: 0 auto; margin: 0 auto;
} }
.header { .header {
grid-template-areas: "logo language"; grid-template-areas: "logo documentation-version language";
grid-template-columns: 1fr 200px; grid-template-columns: 1fr 200px 200px;
} }
.header_logo { .header_logo {
width: 150px; width: 150px;
@ -250,6 +331,7 @@ body.mobile-menu-open {
.main-container { .main-container {
flex-direction: row; flex-direction: row;
} }
#language { #language {
margin-right: 10px; margin-right: 10px;
} }
@ -259,6 +341,17 @@ body.mobile-menu-open {
.language__button { .language__button {
padding: 7px 10px 7px 0; 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 { #menu {
flex: none; flex: none;
background: #eee; background: #eee;

View File

@ -0,0 +1,18 @@
<div id="documentation-version">
<div class="documentation-version__block">
<button class="documentation-version__button" type="button" aria-label="{{ $websiteTranslations->translate('site.Choose version') }}">
<svg width="16" height="16" data-slot="icon" fill="currentColor" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path clip-rule="evenodd" fill-rule="evenodd" d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm1 5.75A.75.75 0 0 1 5.75 7h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 5 7.75Zm0 3a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"></path>
</svg>
{{ $version->title }} <span class="status status__{{ $version->status->value }}">({{ $version->status->getTitle($websiteTranslations) }})</span>
<svg width="16" height="16" class="documentation-version__button__str" 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="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<ul class="documentation-version__list">
@foreach($versions as $v)
<li><a href="{{ \App\Enums\Site\ProjectSection::DocumentationVersion->url($project, $websiteTranslations->getLanguage(), ['version' => $v->slug]) }}">{{ $v->title }} <span class="status status__{{ $v->status->value }}">({{ $v->status->getTitle($websiteTranslations) }})</span></a></li>
@endforeach
</ul>
</div>
</div>

View File

@ -0,0 +1,10 @@
@if($version->status === \App\Enums\DocumentationVersionStatus::NotSupported)
<div class="alert-version version-status-not-supported">
{{ $websiteTranslations->translate('site.alert-status-not-supported') }}
</div>
@endif
@if($version->status === \App\Enums\DocumentationVersionStatus::FutureVersion)
<div class="alert-version version-status-future">
{{ $websiteTranslations->translate('site.alert-status-future') }}
</div>
@endif

View File

@ -30,6 +30,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
@if($attributes->has('documentationVersion'))
<x-site.choose-version :version="$attributes->get('documentationVersion')" :websiteTranslations="$websiteTranslations" :project="$project" :user="auth()->user()" />
@endif
<x-site.choose-language :websiteTranslations="$websiteTranslations" :project="$project" /> <x-site.choose-language :websiteTranslations="$websiteTranslations" :project="$project" />
</header> </header>
<div class="main-container"> <div class="main-container">
@ -37,11 +40,15 @@
<div class="menu__title">{{ __('site.Menu') }}</div> <div class="menu__title">{{ __('site.Menu') }}</div>
<ul> <ul>
<li><a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['home', 'language.home', 'project.home', 'project.language.home'])])>{{ $websiteTranslations->translate('site.About project') }}</a></li> <li><a href="{{ \App\Enums\Site\ProjectSection::Home->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['home', 'language.home', 'project.home', 'project.language.home'])])>{{ $websiteTranslations->translate('site.About project') }}</a></li>
<li><a href="{{ \App\Enums\Site\ProjectSection::Documentation->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['documentation', 'documentation.home'])])>{{ $websiteTranslations->translate('site.Documentation') }}</a></li>
<li><a href="{{ \App\Enums\Site\ProjectSection::Feedback->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['feedback', 'language.feedback', 'project.feedback', 'project.language.feedback'])])>{{ $websiteTranslations->translate('site.Feedback') }}</a></li> <li><a href="{{ \App\Enums\Site\ProjectSection::Feedback->url($project, $websiteTranslations->getLanguage()) }}" @class(['active' => request()->route()->named(['feedback', 'language.feedback', 'project.feedback', 'project.language.feedback'])])>{{ $websiteTranslations->translate('site.Feedback') }}</a></li>
</ul> </ul>
</nav> </nav>
<div class="section-container"> <div class="section-container">
<div class="content"> <div class="content">
@if($attributes->has('documentationVersion'))
<x-site.documentation-version :websiteTranslations="$websiteTranslations" :version="$attributes->get('documentationVersion')" />
@endif
<h1>@yield('h1', '')</h1> <h1>@yield('h1', '')</h1>
@includeWhen($errors->any(), 'layout.site._errors', ['errors' => $errors->all()]) @includeWhen($errors->any(), 'layout.site._errors', ['errors' => $errors->all()])

View File

@ -0,0 +1,13 @@
@section('meta_title', $category->content?->title . ' - ' . $project->name . ' ' . $version->title)
@section('h1', $category->content?->title)
<x-site.layout :project="$project" :websiteTranslations="$websiteTranslations" :documentationVersion="$version">
@foreach($categories as $category)
@continue(! $category->content?->title)
<p><a href="{{ \App\Enums\Site\ProjectSection::DocumentationCategory->url($project, $websiteTranslations->getLanguage(), ['version' => $version->slug, 'slug' => $category->slug]) }}">{{ $category->content->title }}</a></p>
@endforeach
@foreach($documentations as $documentation)
@continue(! $documentation->content?->title)
<p><a href="{{ \App\Enums\Site\ProjectSection::DocumentationView->url($project, $websiteTranslations->getLanguage(), ['version' => $version->slug, 'slug' => $documentation->slug]) }}">{{ $documentation->content->title }}</a></p>
@endforeach
</x-site.layout>

View File

@ -0,0 +1,13 @@
@section('meta_title', $websiteTranslations->translate('site.Documentation') . ' - ' . $project->name . ' ' . $version->title)
@section('h1', $websiteTranslations->translate('site.Documentation'))
<x-site.layout :project="$project" :websiteTranslations="$websiteTranslations" :documentationVersion="$version">
@foreach($categories as $category)
@continue(! $category->content?->title)
<p><a href="{{ \App\Enums\Site\ProjectSection::DocumentationCategory->url($project, $websiteTranslations->getLanguage(), ['version' => $version->slug, 'slug' => $category->slug]) }}">{{ $category->content->title }}</a></p>
@endforeach
@foreach($documentations as $documentation)
@continue(! $documentation->content?->title)
<p><a href="{{ \App\Enums\Site\ProjectSection::DocumentationView->url($project, $websiteTranslations->getLanguage(), ['version' => $version->slug, 'slug' => $documentation->slug]) }}">{{ $documentation->content->title }}</a></p>
@endforeach
</x-site.layout>

View File

@ -0,0 +1,6 @@
@section('meta_title', $websiteTranslations->translate('site.Documentation not created'))
@section('h1', $websiteTranslations->translate('site.Documentation not created'))
<x-site.layout :project="$project" :websiteTranslations="$websiteTranslations">
</x-site.layout>

View File

@ -0,0 +1,9 @@
@section('meta_title', $documentation->content?->title . ' - ' . $project->name . ' ' . $version->title)
@section('h1', $documentation->content?->title)
<x-site.layout :project="$project" :websiteTranslations="$websiteTranslations" :documentationVersion="$version">
<div class="line-numbers">{!! $documentation->content->content !!}</div>
@push('scripts')
@include('_prism')
@endpush
</x-site.layout>

View File

@ -6,6 +6,22 @@
Route::middleware([\App\Http\Middleware\IsProject::class, \App\Http\Middleware\IsWebsiteTranslations::class])->group(function () { Route::middleware([\App\Http\Middleware\IsProject::class, \App\Http\Middleware\IsWebsiteTranslations::class])->group(function () {
Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language'); Route::get('/language/{language}', [\App\Http\Controllers\Site\ProjectsController::class, 'index'])->name('home-language');
Route::prefix('docs')->group(function () {
Route::get('/', [\App\Http\Controllers\Site\DocumentationController::class, 'defaultVersion'])->name('documentation');
Route::get('language/{language}', [\App\Http\Controllers\Site\DocumentationController::class, 'defaultVersion'])->name('documentation-language');
Route::middleware([\App\Http\Middleware\DocumentationVersion::class])->prefix('{version}')->group(function () {
Route::get('/', [\App\Http\Controllers\Site\DocumentationController::class, 'index'])->name('documentation.version');
Route::get('language/{language}', [\App\Http\Controllers\Site\DocumentationController::class, 'index'])->name('documentation.version-language');
Route::get('category/{slug}', [\App\Http\Controllers\Site\DocumentationController::class, 'category'])->name('documentation.category');
Route::get('category/{slug}/language/{language}', [\App\Http\Controllers\Site\DocumentationController::class, 'category'])->name('documentation.category-language');
Route::get('{slug}', [\App\Http\Controllers\Site\DocumentationController::class, 'view'])->name('documentation.view');
Route::get('{slug}/language/{language}', [\App\Http\Controllers\Site\DocumentationController::class, 'view'])->name('documentation.view-language');
});
});
Route::prefix('feedback')->group(function () { Route::prefix('feedback')->group(function () {
Route::get('/', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback'); Route::get('/', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback');
Route::get('language/{language}', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback-language'); Route::get('language/{language}', [\App\Http\Controllers\Site\FeedbackController::class, 'index'])->name('feedback-language');