Added the ability to upload pictures in the editor.

This commit is contained in:
Leonid Nikitin 2024-07-26 22:04:39 +05:00
parent 4d36821ecc
commit b33362a235
Signed by: kor-elf
GPG Key ID: 3C0F720C170F6E1D
34 changed files with 2007 additions and 40 deletions

View File

@ -3,12 +3,14 @@
namespace App\Dto\Service\Admin\Project\About; namespace App\Dto\Service\Admin\Project\About;
use App\Dto\Service\Dto; use App\Dto\Service\Dto;
use App\Dto\Service\Storage\Storages;
final readonly class StoreUpdate extends Dto final readonly class StoreUpdate extends Dto
{ {
public function __construct( public function __construct(
private string $title, private string $title,
private string $description, private string $description,
private Storages $storages,
) { } ) { }
public function getTitle(): string public function getTitle(): string
@ -20,4 +22,9 @@ public function getDescription(): string
{ {
return $this->description; return $this->description;
} }
public function getStorages(): Storages
{
return $this->storages;
}
} }

View File

@ -2,12 +2,15 @@
namespace App\Dto\Service\Admin\Project\DocumentationContent; namespace App\Dto\Service\Admin\Project\DocumentationContent;
use App\Dto\Service\Storage\Storages;
final readonly class Content final readonly class Content
{ {
public function __construct( public function __construct(
private int $languageId, private int $languageId,
private string $title, private string $title,
private string $content, private string $content,
private Storages $storages,
) { } ) { }
public function getLanguageId(): int public function getLanguageId(): int
@ -24,4 +27,9 @@ public function getContent(): string
{ {
return $this->content; return $this->content;
} }
public function getStorages(): Storages
{
return $this->storages;
}
} }

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
namespace App\Dto\Service\DocumentationContent;
use App\Dto\Service\Storage\Storages;
use App\Models\DocumentationContent;
final class StorageDto
{
/**
* @var array [][DocumentationContent documentationContent, Storages storages]
*/
private array $storages = [];
public function add(DocumentationContent $documentationContent, Storages $storages): void
{
$this->storages[] = [
'documentationContent' => $documentationContent,
'storages' => $storages
];
}
/**
* @return array [][DocumentationContent documentationContent, Storages storages]
*/
public function getStorages(): array
{
return $this->storages;
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Dto\View\Volt\Form;
use App\Enums\Morph;
use App\Helpers\Helpers;
final readonly class WysiwygStorageUpload
{
public function __construct(
private string $inputName,
private Morph $morph,
) { }
public function getInputName(): string
{
return $this->inputName;
}
public function getRequestInputName(): string
{
return Helpers::formatAttributeNameToRequestName($this->getInputName());
}
public function getMorph(): Morph
{
return $this->morph;
}
}

View File

@ -2,16 +2,22 @@
namespace App\Enums; namespace App\Enums;
use App\Models\DocumentationContent;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectContent;
enum Morph: int enum Morph: int
{ {
case Project = 1; case Project = 1;
case DocumentationContent = 2;
case ProjectContent = 3;
public function getPathModel(): string public function getPathModel(): string
{ {
return match ($this) { return match ($this) {
self::Project => Project::class, self::Project => Project::class,
self::DocumentationContent => DocumentationContent::class,
self::ProjectContent => ProjectContent::class,
}; };
} }

View File

@ -9,11 +9,13 @@
enum StorageType: int implements Image, Video, Audio enum StorageType: int implements Image, Video, Audio
{ {
case Logo = 1; case Logo = 1;
case ContentImages = 2;
public function getTitle(): string public function getTitle(): string
{ {
return match ($this) { return match ($this) {
self::Logo => __('validation.attributes.logo'), self::Logo => __('validation.attributes.logo'),
self::ContentImages => __('validation.attributes.content_images'),
}; };
} }
@ -21,6 +23,7 @@ public function getAcceptMimes(): array
{ {
return match ($this) { return match ($this) {
self::Logo => ['jpeg', 'jpg', 'png'], self::Logo => ['jpeg', 'jpg', 'png'],
self::ContentImages => ['jpeg', 'jpg', 'png'],
}; };
} }
@ -28,20 +31,21 @@ public function isImage(): bool
{ {
return match ($this) { return match ($this) {
self::Logo => true, self::Logo => true,
self::ContentImages => true,
default => false default => false
}; };
} }
public function isVideo(): bool public function isVideo(): bool
{ {
return match ($this->name) { return match ($this) {
default => false default => false
}; };
} }
public function isAudio(): bool public function isAudio(): bool
{ {
return match ($this->name) { return match ($this) {
default => false default => false
}; };
} }

View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace App\Exceptions\Services\DocumentationContent;
use App\ServiceResults\ServiceResultError;
final class StorageCommandException extends \Exception
{
public function __construct(private readonly ServiceResultError $resultError, string $message = "", int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function getResultError(): ServiceResultError
{
return $this->resultError;
}
}

View File

@ -4,10 +4,19 @@
use App\Contracts\FormRequestDto; use App\Contracts\FormRequestDto;
use App\Dto\Service\Admin\Project\About\StoreUpdate; use App\Dto\Service\Admin\Project\About\StoreUpdate;
use App\Dto\Service\Storage\Storages;
use App\Enums\StorageType;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{ {
public function attributes(): array
{
return [
'storage.content_images.*.file' => __('validation.attributes.content_images'),
];
}
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
*/ */
@ -16,6 +25,7 @@ public function rules(): array
return [ return [
'title' => ['required', 'string', 'max:255',], 'title' => ['required', 'string', 'max:255',],
'description' => ['nullable', 'string',], 'description' => ['nullable', 'string',],
'storage.content_images.*.file' => ['numeric', 'min:1'],
]; ];
} }
@ -24,6 +34,18 @@ public function getDto(): StoreUpdate
return new StoreUpdate( return new StoreUpdate(
title: $this->input('title'), title: $this->input('title'),
description: $this->input('description'), description: $this->input('description'),
storages: $this->storages(),
); );
} }
private function storages(): Storages
{
$storages = new Storages();
$content = $this->input('storage', []);
$images = $content['content_images'] ?? [];
$storages->addMany($images, StorageType::ContentImages);
return $storages;
}
} }

View File

@ -6,10 +6,19 @@
use App\Dto\Service\Admin\Project\Documentation\StoreUpdate; use App\Dto\Service\Admin\Project\Documentation\StoreUpdate;
use App\Dto\Service\Admin\Project\DocumentationContent\Content; use App\Dto\Service\Admin\Project\DocumentationContent\Content;
use App\Dto\Service\Admin\Project\DocumentationContent\Contents; use App\Dto\Service\Admin\Project\DocumentationContent\Contents;
use App\Dto\Service\Storage\Storages;
use App\Enums\StorageType;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
final class StoreUpdateRequest extends FormRequest implements FormRequestDto final class StoreUpdateRequest extends FormRequest implements FormRequestDto
{ {
public function attributes(): array
{
return [
'content.*.content_images.*.file' => __('validation.attributes.content_images'),
];
}
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
*/ */
@ -22,6 +31,7 @@ public function rules(): array
'category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'], 'category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'],
'content.*.title' => ['required', 'string', 'max:255'], 'content.*.title' => ['required', 'string', 'max:255'],
'content.*.content' => ['nullable', 'string'], 'content.*.content' => ['nullable', 'string'],
'content.*.content_images.*.file' => ['numeric', 'min:1'],
]; ];
} }
@ -49,8 +59,19 @@ private function getContents(): Contents
languageId: (int) $languageId, languageId: (int) $languageId,
title: $content['title'], title: $content['title'],
content: $content['content'] ?? '', content: $content['content'] ?? '',
storages: $this->contentStorages($content),
)); ));
} }
return $contents; return $contents;
} }
private function contentStorages(array $content): Storages
{
$storages = new Storages();
$images = $content['content_images'] ?? [];
$storages->addMany($images, StorageType::ContentImages);
return $storages;
}
} }

View File

@ -6,7 +6,9 @@
use App\Dto\Service\Storage\Upload; use App\Dto\Service\Storage\Upload;
use App\Enums\Morph; use App\Enums\Morph;
use App\Enums\StorageType; use App\Enums\StorageType;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\Enum;
final class ImageRequest extends FormRequest implements FormRequestDto final class ImageRequest extends FormRequest implements FormRequestDto
@ -37,4 +39,20 @@ public function getDto(): Upload
morph: Morph::from((int) $this->input('morph')), morph: Morph::from((int) $this->input('morph')),
); );
} }
/**
* Get the error messages for the defined validation rules.*
* @return array
*/
protected function failedValidation(Validator $validator): array
{
/**
* To always return json
*/
throw new HttpResponseException(response()->json([
'errors' => $validator->errors(),
'status' => true
], 422));
}
} }

View File

@ -2,13 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Contracts\Models\Storage as StorageContract;
use App\Models\Traits\StorageTrait;
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\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
final class DocumentationContent extends Model final class DocumentationContent extends Model implements StorageContract
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes, StorageTrait;
protected $table = 'documentation_content'; protected $table = 'documentation_content';

View File

@ -2,13 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Contracts\Models\Storage as StorageContract;
use App\Models\Traits\StorageTrait;
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\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
final class ProjectContent extends Model final class ProjectContent extends Model implements StorageContract
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes, StorageTrait;
protected $table = 'project_content'; protected $table = 'project_content';

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
final readonly class DocumentationContentPolicy extends Policy
{
public function upload(User $user): bool
{
if ($user->hasPermission('documentation.create') || $user->hasPermission('documentation.update')) {
return true;
}
return false;
}
}

View File

@ -26,4 +26,13 @@ public function update(User $user, ProjectContent $projectContent): bool
{ {
return $user->hasPermission('project-content.update'); return $user->hasPermission('project-content.update');
} }
public function upload(User $user): bool
{
if ($user->hasPermission('project-content.create') || $user->hasPermission('project-content.update')) {
return true;
}
return false;
}
} }

View File

@ -4,6 +4,7 @@
use App\Contracts\ServiceResultError; use App\Contracts\ServiceResultError;
use App\Dto\Service\Admin\Project\About\StoreUpdate; use App\Dto\Service\Admin\Project\About\StoreUpdate;
use App\Enums\Morph;
use App\Models\ProjectContent; use App\Models\ProjectContent;
use App\Models\User; use App\Models\User;
use App\Repositories\ProjectContentRepository; use App\Repositories\ProjectContentRepository;
@ -13,6 +14,7 @@
use App\ServiceResults\StoreUpdateResult; use App\ServiceResults\StoreUpdateResult;
use App\Services\ProjectContent\ProjectContentCommandHandler; use App\Services\ProjectContent\ProjectContentCommandHandler;
use App\Services\Service; use App\Services\Service;
use App\Services\Storage\StorageService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class AboutService extends Service final class AboutService extends Service
@ -22,6 +24,7 @@ public function __construct(
private readonly ProjectLanguageRepository $projectLanguageRepository, private readonly ProjectLanguageRepository $projectLanguageRepository,
private readonly ProjectContentRepository $projectContentRepository, private readonly ProjectContentRepository $projectContentRepository,
private readonly ProjectContentCommandHandler $projectContentCommandHandler, private readonly ProjectContentCommandHandler $projectContentCommandHandler,
private readonly StorageService $storageService,
) { } ) { }
public function languages(int $projectId, User $user): ServiceResultError | ServiceResultArray public function languages(int $projectId, User $user): ServiceResultError | ServiceResultArray
@ -88,11 +91,19 @@ private function store(int $projectId, int $languageId, StoreUpdate $data, User
if ($user->cannot('create', ProjectContent::class)) { if ($user->cannot('create', ProjectContent::class)) {
return $this->errFobidden(__('Access is denied')); return $this->errFobidden(__('Access is denied'));
} }
$storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::ProjectContent);
if (!$storages->isSuccess()) {
return $storages;
}
try { try {
$aboutProject = DB::transaction(function () use ($data, $projectId, $languageId) { $aboutProject = DB::transaction(function () use ($data, $projectId, $languageId, $storages) {
$dataAboutProject = $this->getDataAboutProject($data); $dataAboutProject = $this->getDataAboutProject($data);
return $this->projectContentCommandHandler->handleStore($projectId, $languageId, $dataAboutProject); $aboutProject = $this->projectContentCommandHandler->handleStore($projectId, $languageId, $dataAboutProject);
$this->storageService->saveAndDelete($aboutProject, $storages);
return $aboutProject;
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);
@ -108,11 +119,19 @@ private function update(ProjectContent $content, StoreUpdate $data, User $user):
return $this->errFobidden(__('Access is denied')); return $this->errFobidden(__('Access is denied'));
} }
$storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::ProjectContent);
if (!$storages->isSuccess()) {
return $storages;
}
try { try {
$aboutProject = DB::transaction(function () use ($data, $content) { $aboutProject = DB::transaction(function () use ($data, $content, $storages) {
$dataAboutProject = $this->getDataAboutProject($data); $dataAboutProject = $this->getDataAboutProject($data);
return $this->projectContentCommandHandler->handleUpdate($content, $dataAboutProject); $aboutProject = $this->projectContentCommandHandler->handleUpdate($content, $dataAboutProject);
$this->storageService->saveAndDelete($aboutProject, $storages);
return $aboutProject;
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);

View File

@ -4,18 +4,33 @@
use App\Dto\Service\Admin\Project\DocumentationContent\Content; use App\Dto\Service\Admin\Project\DocumentationContent\Content;
use App\Dto\Service\Admin\Project\DocumentationContent\Contents; use App\Dto\Service\Admin\Project\DocumentationContent\Contents;
use App\Dto\Service\DocumentationContent\StorageDto;
use App\Exceptions\Services\DocumentationContent\ContentSaveException; use App\Exceptions\Services\DocumentationContent\ContentSaveException;
use App\Exceptions\Services\DocumentationContent\StorageCommandException;
use App\Models\Documentation; use App\Models\Documentation;
use App\Models\DocumentationContent;
use App\Models\Project; use App\Models\Project;
final readonly class ModelSyncCommand final readonly class ModelSyncCommand
{ {
public function __construct(
private StorageCommand $storageCommand,
) { }
/**
* @throws StorageCommandException
* @throws ContentSaveException
*/
public function execute(Project $project, Documentation $documentation, Contents $contents): void public function execute(Project $project, Documentation $documentation, Contents $contents): void
{ {
$storageDto = new StorageDto();
$languages = $project->languages; $languages = $project->languages;
$documentationContents = $documentation->contents; $documentationContents = $documentation->contents;
$newContents = []; $newContents = [];
$contentsStorageCreated = [];
$contentLanguages = [];
foreach ($contents->getContents() as $content) { foreach ($contents->getContents() as $content) {
/** @var Content $content */ /** @var Content $content */
$language = $languages->firstWhere('id', $content->getLanguageId()); $language = $languages->firstWhere('id', $content->getLanguageId());
@ -26,15 +41,29 @@ public function execute(Project $project, Documentation $documentation, Contents
$model = $documentationContents->firstWhere('language_id', $language->id); $model = $documentationContents->firstWhere('language_id', $language->id);
$data = $this->getData($content); $data = $this->getData($content);
if (\is_null($model)) { if (\is_null($model)) {
$contentsStorageCreated[$content->getLanguageId()] = $content->getStorages();
$newContents[] = array_merge(['language_id' => $content->getLanguageId()], $data); $newContents[] = array_merge(['language_id' => $content->getLanguageId()], $data);
$contentLanguages[] = $content->getLanguageId();
continue; continue;
} }
$storageDto->add($model, $content->getStorages());
$model->update($data); $model->update($data);
} }
if (!empty($newContents)) { if (!empty($newContents)) {
$documentation->contents()->createMany($newContents); $documentation->contents()->createMany($newContents);
$contents = $documentation->contents()->whereIn('language_id', $contentLanguages)->get();
foreach ($contents as $content) {
/** @var DocumentationContent $content */
if (!isset($contentsStorageCreated[$content->language_id])) {
continue;
} }
$storageDto->add($content, $contentsStorageCreated[$content->language_id]);
}
}
$this->storageCommand->execute($storageDto);
} }
private function getData(Content $content): array private function getData(Content $content): array

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\Services\DocumentationContent;
use App\Dto\Service\DocumentationContent\StorageDto;
use App\Enums\Morph;
use App\Exceptions\Services\DocumentationContent\StorageCommandException;
use App\Services\Storage\StorageService;
final readonly class StorageCommand
{
public function __construct(
private StorageService $storageService,
) { }
/**
* @throws StorageCommandException
*/
public function execute(StorageDto $storageDto): void
{
foreach ($storageDto->getStorages() as $storage) {
$storages = $this->storageService->getStoragesAndValidate($storage['storages'], Morph::DocumentationContent, $storage['documentationContent']->id);
if (!$storages->isSuccess()) {
throw new StorageCommandException($storages, 'Error when adding a file to storage: ' . $storages->getMessage());
}
$this->storageService->saveAndDelete($storage['documentationContent'], $storages);
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\View\Components\Volt\Forms; namespace App\View\Components\Volt\Forms;
use App\Dto\View\Volt\Form\WysiwygStorageUpload;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
@ -11,6 +12,7 @@ public function __construct(
private readonly string $title, private readonly string $title,
private readonly string $name, private readonly string $name,
private readonly ?string $value = '', private readonly ?string $value = '',
private readonly ?WysiwygStorageUpload $storageUpload = null,
) { } ) { }
protected function getName(): string protected function getName(): string
@ -28,6 +30,11 @@ private function getValue(): string
return (string) old($this->getRequestName(), $this->value); return (string) old($this->getRequestName(), $this->value);
} }
public function getStorageUpload(): ?WysiwygStorageUpload
{
return $this->storageUpload;
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
@ -41,6 +48,7 @@ public function render(): View
'name' => $this->getName(), 'name' => $this->getName(),
'requestName' => $this->getRequestName(), 'requestName' => $this->getRequestName(),
'value' => $this->getValue(), 'value' => $this->getValue(),
'storageUpload' => $this->getStorageUpload(),
]); ]);
} }
} }

View File

@ -307,5 +307,6 @@
'content.*.title' => 'title', 'content.*.title' => 'title',
'content.*.content' => 'content', 'content.*.content' => 'content',
'category_id' => 'category', 'category_id' => 'category',
'content_images' => 'content images',
], ],
]; ];

View File

@ -307,5 +307,6 @@
'content.*.title' => 'заголовок', 'content.*.title' => 'заголовок',
'content.*.content' => 'контент', 'content.*.content' => 'контент',
'category_id' => 'категория', 'category_id' => 'категория',
'content_images' => 'изображения контента',
], ],
]; ];

View File

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@fancyapps/ui": "^5.0.36",
"@popperjs/core": "^2.9.2", "@popperjs/core": "^2.9.2",
"bootstrap": "5.0.2", "bootstrap": "5.0.2",
"chartist": "^0.11.4", "chartist": "^0.11.4",
@ -397,6 +398,11 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fancyapps/ui": {
"version": "5.0.36",
"resolved": "https://registry.npmjs.org/@fancyapps/ui/-/ui-5.0.36.tgz",
"integrity": "sha512-GMygQzp1MBTFNTT6AzpbL6pXTD6bTxwjmmpI1fe8Ozmmiseu8/g82Sudl1YhcbZmS4bJgaBOF5THDFGpXQ1fDw=="
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",

View File

@ -6,6 +6,7 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@fancyapps/ui": "^5.0.36",
"@popperjs/core": "^2.9.2", "@popperjs/core": "^2.9.2",
"bootstrap": "5.0.2", "bootstrap": "5.0.2",
"chartist": "^0.11.4", "chartist": "^0.11.4",

View File

@ -0,0 +1,6 @@
import { Fancybox } from "@fancyapps/ui";
import "@fancyapps/ui/dist/fancybox/fancybox.css";
Fancybox.bind('a.image-open', {
//
});

View File

@ -29,6 +29,39 @@ body {
.content { .content {
flex: 1 0 auto; flex: 1 0 auto;
img {
max-width: 100%;
object-fit: contain;
}
.image-open {
display: block;
img {
max-width: 100%;
height: auto;
}
}
@supports (--custom:property) {
[style*="--aspect-ratio"] {
position: relative;
}
[style*="--aspect-ratio"]::before {
content: "";
display: block;
padding-bottom: calc(100% / (var(--aspect-ratio)));
}
[style*="--aspect-ratio"] img {
position: absolute;
top: 0;
left: 0;
height: 100%;
max-width: 100%;
object-fit: cover;
}
}
} }
.footer { .footer {

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1 @@
<script src="{{ asset('/build/tinymce/tinymce.min.js') }}" referrerpolicy="origin"></script>

View File

@ -0,0 +1,4 @@
@pushOnce('scripts')
@include('_tinymce')
@include('_prism')
@endpushonce

View File

@ -1,6 +1,11 @@
@csrf @csrf
<x-volt.forms.input :title="__('validation.attributes.title')" name="title" type="text" :value="$content->title" required autofocus /> <x-volt.forms.input :title="__('validation.attributes.title')" name="title" type="text" :value="$content->title" required autofocus />
<x-volt.forms.textarea-wysiwyg :title="__('validation.attributes.description')" name="description" :value="$content->description" /> <x-volt.forms.textarea-wysiwyg
:title="__('validation.attributes.description')"
name="description"
:value="$content->description"
:storageUpload="new \App\Dto\View\Volt\Form\WysiwygStorageUpload(inputName: 'storage', morph: \App\Enums\Morph::ProjectContent)"
/>
@canany(['create', 'update'], $content) @canany(['create', 'update'], $content)
<button class="btn btn-primary" type="submit">{{ __('Save') }}</button> <button class="btn btn-primary" type="submit">{{ __('Save') }}</button>

View File

@ -1,15 +1,22 @@
@csrf @csrf
<x-volt.forms.checkbox :title="__('validation.attributes.is_public')" name="is_public" checkboxValue="1" notCheckedValue="0" :userValue="(string) $documentation->is_public" /> <x-volt.forms.checkbox :title="__('validation.attributes.is_public')" name="is_public" checkboxValue="1"
<x-volt.forms.input :title="__('validation.attributes.slug')" allowed-characters="a-z0-9.-_" name="slug" type="text" :value="$documentation->slug" required autofocus /> notCheckedValue="0" :userValue="(string) $documentation->is_public"/>
<x-volt.forms.input :title="__('validation.attributes.sort')" name="sort" type="number" :value="$documentation->sort" required /> <x-volt.forms.input :title="__('validation.attributes.slug')" allowed-characters="a-z0-9.-_" name="slug" type="text"
<x-volt.forms.select :title="__('validation.attributes.category_id')" name="category_id" :list="$categories" :value="(string) $documentation->category?->id"> :value="$documentation->slug" required autofocus/>
<x-volt.forms.input :title="__('validation.attributes.sort')" name="sort" type="number" :value="$documentation->sort"
required/>
<x-volt.forms.select :title="__('validation.attributes.category_id')" name="category_id" :list="$categories"
:value="(string) $documentation->category?->id">
<option value=""></option> <option value=""></option>
</x-volt.forms.select> </x-volt.forms.select>
<nav> <nav>
<div class="nav nav-tabs mb-4" id="nav-language-tab" role="tablist"> <div class="nav nav-tabs mb-4" id="nav-language-tab" role="tablist">
@foreach($project->languages as $index => $language) @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> <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 @endforeach
</div> </div>
</nav> </nav>
@ -18,10 +25,22 @@
@php @php
$content = $documentation->contents->firstWhere('language_id', $language->id); $content = $documentation->contents->firstWhere('language_id', $language->id);
@endphp @endphp
<div class="tab-pane fade @if($index === 0) show active @endif" id="language-{{ $language->id }}" role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab"> <div class="tab-pane fade @if($index === 0) show active @endif" id="language-{{ $language->id }}"
<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"/> role="tabpanel" aria-labelledby="language-{{ $language->id }}-tab">
<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 /> <x-volt.forms.checkbox :title="__('Edit')" :name="'content-enable-' . $language->id"
<x-volt.forms.textarea-wysiwyg :title="__('validation.attributes.content')" :name="'content[' . $language->id . '][content]'" class="language-content" :value="$content?->content" :disabled="$index !== 0" /> :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/>
<x-volt.forms.textarea-wysiwyg
:title="__('validation.attributes.content')"
:storageUpload="new \App\Dto\View\Volt\Form\WysiwygStorageUpload(inputName: 'content[' . $language->id . ']', morph: \App\Enums\Morph::DocumentationContent)"
:name="'content[' . $language->id . '][content]'"
class="language-content"
:value="$content?->content"
:disabled="$index !== 0"
/>
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -1,23 +1,114 @@
@php
$tinyId = \Illuminate\Support\Str::random(7);
$tinyId = 'form-textarea-wysiwyg-' . $tinyId;
$images = [];
$files = [];
/** @var \App\Dto\View\Volt\Form\WysiwygStorageUpload | null $storageUpload */
if ($storageUpload !== null) {
$storages = old($storageUpload->getRequestInputName(), []);
$images = $storages['content_images'] ?? [];
}
@endphp
<div class="mb-3"> <div class="mb-3">
<label for="form-textarea-wysiwyg-{{ $requestName }}">{{ $title }}</label> <label for="{{ $tinyId }}">{{ $title }}</label>
<textarea class="form-control {{ $attributes->get('class') }} textarea-tinymce @error($requestName) is-invalid @enderror" name="{{ $name }}" id="form-textarea-wysiwyg-{{ $requestName }}" rows="3">{{ $value }}</textarea> <textarea class="form-control {{ $attributes->get('class') }} textarea-tinymce @error($requestName) is-invalid @enderror" name="{{ $name }}" id="{{ $tinyId }}" rows="3">{{ $value }}</textarea>
@foreach($images as $image)
@continue( empty($image['file']) )
<input type="hidden" value="{{ $image['file'] }}" name="{{ $storageUpload->getInputName() }}[content_images][][file]';">
@endforeach
@error($requestName) @error($requestName)
<span class="invalid-feedback">{{ $message }}</span> <span class="invalid-feedback">{{ $message }}</span>
@enderror @enderror
</div> </div>
@pushOnce('scripts') @include('admin._scripts._tinymce')
<script src="{{ asset('/build/tinymce/tinymce.min.js') }}" referrerpolicy="origin"></script> @push('scripts')
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@if($storageUpload !== null)
const textarea = document.querySelector('#{{ $tinyId }}');
if (!textarea) {
return;
}
const storageImageType = '{{ \App\Enums\StorageType::ContentImages->value }}';
const morph = '{{ $storageUpload->getMorph()->value }}';
const storageInputName = '{{ $storageUpload->getInputName() }}';
const imageUpload = (blobInfo, progress) => new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', '{{ route('storage.image_upload_and_resize') }}');
xhr.upload.onprogress = (e) => {
progress(e.loaded / e.total * 100);
};
xhr.onload = () => {
if (xhr.status === 422) {
const json = JSON.parse(xhr.responseText);
let error = 'Error:<br><br>';
for (let key of Object.keys(json.errors)) {
error += json.errors[key] + '<br><br>';
}
reject(error);
return;
}
if (xhr.status === 403) {
reject({ message: 'HTTP Error: ' + xhr.status, remove: true });
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (!json || typeof json.url != 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
let input = document.createElement('input');
input.type = 'hidden';
input.name = storageInputName + '[content_images][][file]';
input.value = json.id;
textarea.after(input);
resolve(json.url);
};
xhr.onerror = () => {
reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
};
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
formData.append('_token', document.querySelector('meta[name="csrf-token"]').content);
formData.append('storage_type', storageImageType);
formData.append('morph', morph);
xhr.send(formData);
});
@endif
tinymce.init({ tinymce.init({
selector: '.textarea-tinymce', selector: '#{{ $tinyId }}',
@if(in_array(app()->getLocale(), ['ru'], true)) @if(in_array(app()->getLocale(), ['ru'], true))
language: '{{ app()->getLocale() }}', language: '{{ app()->getLocale() }}',
@endif @endif
license_key: '{{ $tinymceLicenseKey }}', license_key: '{{ $tinymceLicenseKey }}',
plugins: 'advlist code emoticons link lists table codesample', plugins: 'advlist code emoticons link lists table codesample media my-image',
toolbar: 'bold italic | bullist numlist | link emoticons codesample', toolbar: 'bold italic | bullist numlist | link image emoticons media codesample',
referrer_policy: 'origin', referrer_policy: 'origin',
@if($storageUpload !== null)
images_upload_handler: imageUpload,
@endif
relative_urls: false,
convert_urls: false,
codesample_global_prismjs: true, codesample_global_prismjs: true,
codesample_languages: [ codesample_languages: [
{text: 'HTML/XML', value: 'markup'}, {text: 'HTML/XML', value: 'markup'},
@ -33,11 +124,11 @@
{text: 'Go', value: 'go'}, {text: 'Go', value: 'go'},
{text: 'Nginx', value: 'nginx'}, {text: 'Nginx', value: 'nginx'},
{text: 'Docker', value: 'docker'}, {text: 'Docker', value: 'docker'},
{text: 'Yaml', value: 'yaml'},
{text: "Treeview", value: "treeview"}, {text: "Treeview", value: "treeview"},
{text: "Diff", value: "diff"}, {text: "Diff", value: "diff"},
], ],
}); });
}); });
</script> </script>
@include('_prism') @endpush
@endpushonce

View File

@ -11,5 +11,6 @@
@push('scripts') @push('scripts')
@include('_prism') @include('_prism')
@include('_fancybox')
@endpush @endpush
</x-site.layout> </x-site.layout>

View File

@ -5,5 +5,6 @@
<div class="line-numbers">{!! $documentation->content->content !!}</div> <div class="line-numbers">{!! $documentation->content->content !!}</div>
@push('scripts') @push('scripts')
@include('_prism') @include('_prism')
@include('_fancybox')
@endpush @endpush
</x-site.layout> </x-site.layout>

View File

@ -15,6 +15,8 @@ export default defineConfig({
'resources/site/js/app.js', 'resources/site/js/app.js',
'resources/prism/app.js', 'resources/prism/app.js',
'resources/fancybox/app.js',
], ],
refresh: true, refresh: true,
}), }),