From b33362a235cf3bb320244921f9a9d96b5ebb7cfe Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Fri, 26 Jul 2024 22:04:39 +0500 Subject: [PATCH] Added the ability to upload pictures in the editor. --- .../Admin/Project/About/StoreUpdate.php | 7 + .../Project/DocumentationContent/Content.php | 8 + .../DocumentationContent/StorageDto.php | 30 + .../View/Volt/Form/WysiwygStorageUpload.php | 29 + app/application/app/Enums/Morph.php | 8 +- app/application/app/Enums/StorageType.php | 12 +- .../StorageCommandException.php | 18 + .../Projects/About/StoreUpdateRequest.php | 22 + .../Documentations/StoreUpdateRequest.php | 21 + .../Http/Requests/Storage/ImageRequest.php | 18 + .../app/Models/DocumentationContent.php | 6 +- app/application/app/Models/ProjectContent.php | 6 +- .../Policies/DocumentationContentPolicy.php | 17 + .../app/Policies/ProjectContentPolicy.php | 9 + .../Services/Admin/Project/AboutService.php | 27 +- .../DocumentationContent/ModelSyncCommand.php | 29 + .../DocumentationContent/StorageCommand.php | 31 + .../Components/Volt/Forms/TextareaWysiwyg.php | 22 +- app/application/lang/en/validation.php | 1 + app/application/lang/ru/validation.php | 1 + app/application/package-lock.json | 6 + app/application/package.json | 1 + app/application/resources/fancybox/app.js | 6 + app/application/resources/site/scss/app.scss | 33 + .../tinymce/plugins/my-image/plugin.min.js | 1514 +++++++++++++++++ .../resources/views/_fancybox.blade.php | 1 + .../resources/views/_tinymce.blade.php | 1 + .../views/admin/_scripts/_tinymce.blade.php | 4 + .../admin/projects/about/_from.blade.php | 7 +- .../projects/documentations/_from.blade.php | 37 +- .../volt/forms/textarea-wysiwyg.blade.php | 111 +- .../views/site/projects/about.blade.php | 1 + .../projects/documentation/view.blade.php | 1 + app/application/vite.config.js | 2 + 34 files changed, 2007 insertions(+), 40 deletions(-) create mode 100644 app/application/app/Dto/Service/DocumentationContent/StorageDto.php create mode 100644 app/application/app/Dto/View/Volt/Form/WysiwygStorageUpload.php create mode 100644 app/application/app/Exceptions/Services/DocumentationContent/StorageCommandException.php create mode 100644 app/application/app/Policies/DocumentationContentPolicy.php create mode 100644 app/application/app/Services/DocumentationContent/StorageCommand.php create mode 100644 app/application/resources/fancybox/app.js create mode 100644 app/application/resources/tinymce/plugins/my-image/plugin.min.js create mode 100644 app/application/resources/views/_fancybox.blade.php create mode 100644 app/application/resources/views/_tinymce.blade.php create mode 100644 app/application/resources/views/admin/_scripts/_tinymce.blade.php diff --git a/app/application/app/Dto/Service/Admin/Project/About/StoreUpdate.php b/app/application/app/Dto/Service/Admin/Project/About/StoreUpdate.php index 548fe32..3ac74b1 100644 --- a/app/application/app/Dto/Service/Admin/Project/About/StoreUpdate.php +++ b/app/application/app/Dto/Service/Admin/Project/About/StoreUpdate.php @@ -3,12 +3,14 @@ namespace App\Dto\Service\Admin\Project\About; use App\Dto\Service\Dto; +use App\Dto\Service\Storage\Storages; final readonly class StoreUpdate extends Dto { public function __construct( private string $title, private string $description, + private Storages $storages, ) { } public function getTitle(): string @@ -20,4 +22,9 @@ public function getDescription(): string { return $this->description; } + + public function getStorages(): Storages + { + return $this->storages; + } } diff --git a/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php b/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php index 4712e6e..8f69b70 100644 --- a/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php +++ b/app/application/app/Dto/Service/Admin/Project/DocumentationContent/Content.php @@ -2,12 +2,15 @@ namespace App\Dto\Service\Admin\Project\DocumentationContent; +use App\Dto\Service\Storage\Storages; + final readonly class Content { public function __construct( private int $languageId, private string $title, private string $content, + private Storages $storages, ) { } public function getLanguageId(): int @@ -24,4 +27,9 @@ public function getContent(): string { return $this->content; } + + public function getStorages(): Storages + { + return $this->storages; + } } diff --git a/app/application/app/Dto/Service/DocumentationContent/StorageDto.php b/app/application/app/Dto/Service/DocumentationContent/StorageDto.php new file mode 100644 index 0000000..a0164a5 --- /dev/null +++ b/app/application/app/Dto/Service/DocumentationContent/StorageDto.php @@ -0,0 +1,30 @@ +storages[] = [ + 'documentationContent' => $documentationContent, + 'storages' => $storages + ]; + } + + /** + * @return array [][DocumentationContent documentationContent, Storages storages] + */ + public function getStorages(): array + { + return $this->storages; + } +} diff --git a/app/application/app/Dto/View/Volt/Form/WysiwygStorageUpload.php b/app/application/app/Dto/View/Volt/Form/WysiwygStorageUpload.php new file mode 100644 index 0000000..e53f6ed --- /dev/null +++ b/app/application/app/Dto/View/Volt/Form/WysiwygStorageUpload.php @@ -0,0 +1,29 @@ +inputName; + } + + public function getRequestInputName(): string + { + return Helpers::formatAttributeNameToRequestName($this->getInputName()); + } + + public function getMorph(): Morph + { + return $this->morph; + } +} diff --git a/app/application/app/Enums/Morph.php b/app/application/app/Enums/Morph.php index 59307c6..8d1214a 100644 --- a/app/application/app/Enums/Morph.php +++ b/app/application/app/Enums/Morph.php @@ -2,16 +2,22 @@ namespace App\Enums; +use App\Models\DocumentationContent; use App\Models\Project; +use App\Models\ProjectContent; enum Morph: int { case Project = 1; + case DocumentationContent = 2; + case ProjectContent = 3; public function getPathModel(): string { return match ($this) { - self::Project => Project::class, + self::Project => Project::class, + self::DocumentationContent => DocumentationContent::class, + self::ProjectContent => ProjectContent::class, }; } diff --git a/app/application/app/Enums/StorageType.php b/app/application/app/Enums/StorageType.php index c6b0b94..e511c45 100644 --- a/app/application/app/Enums/StorageType.php +++ b/app/application/app/Enums/StorageType.php @@ -9,39 +9,43 @@ enum StorageType: int implements Image, Video, Audio { case Logo = 1; + case ContentImages = 2; public function getTitle(): string { return match ($this) { self::Logo => __('validation.attributes.logo'), + self::ContentImages => __('validation.attributes.content_images'), }; } public function getAcceptMimes(): array { return match ($this) { - self::Logo => ['jpeg', 'jpg', 'png'], + self::Logo => ['jpeg', 'jpg', 'png'], + self::ContentImages => ['jpeg', 'jpg', 'png'], }; } public function isImage(): bool { return match ($this) { - self::Logo => true, + self::Logo => true, + self::ContentImages => true, default => false }; } public function isVideo(): bool { - return match ($this->name) { + return match ($this) { default => false }; } public function isAudio(): bool { - return match ($this->name) { + return match ($this) { default => false }; } diff --git a/app/application/app/Exceptions/Services/DocumentationContent/StorageCommandException.php b/app/application/app/Exceptions/Services/DocumentationContent/StorageCommandException.php new file mode 100644 index 0000000..53f0e3c --- /dev/null +++ b/app/application/app/Exceptions/Services/DocumentationContent/StorageCommandException.php @@ -0,0 +1,18 @@ +resultError; + } +} diff --git a/app/application/app/Http/Requests/Admin/Projects/About/StoreUpdateRequest.php b/app/application/app/Http/Requests/Admin/Projects/About/StoreUpdateRequest.php index f814182..abc6556 100644 --- a/app/application/app/Http/Requests/Admin/Projects/About/StoreUpdateRequest.php +++ b/app/application/app/Http/Requests/Admin/Projects/About/StoreUpdateRequest.php @@ -4,10 +4,19 @@ use App\Contracts\FormRequestDto; use App\Dto\Service\Admin\Project\About\StoreUpdate; +use App\Dto\Service\Storage\Storages; +use App\Enums\StorageType; use Illuminate\Foundation\Http\FormRequest; 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. */ @@ -16,6 +25,7 @@ public function rules(): array return [ 'title' => ['required', 'string', 'max:255',], 'description' => ['nullable', 'string',], + 'storage.content_images.*.file' => ['numeric', 'min:1'], ]; } @@ -24,6 +34,18 @@ public function getDto(): StoreUpdate return new StoreUpdate( title: $this->input('title'), 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; + } } diff --git a/app/application/app/Http/Requests/Admin/Projects/Documentations/StoreUpdateRequest.php b/app/application/app/Http/Requests/Admin/Projects/Documentations/StoreUpdateRequest.php index f39aa1a..7b8222c 100644 --- a/app/application/app/Http/Requests/Admin/Projects/Documentations/StoreUpdateRequest.php +++ b/app/application/app/Http/Requests/Admin/Projects/Documentations/StoreUpdateRequest.php @@ -6,10 +6,19 @@ 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 App\Dto\Service\Storage\Storages; +use App\Enums\StorageType; use Illuminate\Foundation\Http\FormRequest; 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. */ @@ -22,6 +31,7 @@ public function rules(): array 'category_id' => ['nullable', 'integer', 'exists:documentation_categories,id'], 'content.*.title' => ['required', 'string', 'max:255'], 'content.*.content' => ['nullable', 'string'], + 'content.*.content_images.*.file' => ['numeric', 'min:1'], ]; } @@ -49,8 +59,19 @@ private function getContents(): Contents languageId: (int) $languageId, title: $content['title'], content: $content['content'] ?? '', + storages: $this->contentStorages($content), )); } return $contents; } + + private function contentStorages(array $content): Storages + { + $storages = new Storages(); + + $images = $content['content_images'] ?? []; + $storages->addMany($images, StorageType::ContentImages); + + return $storages; + } } diff --git a/app/application/app/Http/Requests/Storage/ImageRequest.php b/app/application/app/Http/Requests/Storage/ImageRequest.php index 4255321..74f1023 100644 --- a/app/application/app/Http/Requests/Storage/ImageRequest.php +++ b/app/application/app/Http/Requests/Storage/ImageRequest.php @@ -6,7 +6,9 @@ use App\Dto\Service\Storage\Upload; use App\Enums\Morph; use App\Enums\StorageType; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Validation\Rules\Enum; final class ImageRequest extends FormRequest implements FormRequestDto @@ -37,4 +39,20 @@ public function getDto(): Upload 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)); + } + } diff --git a/app/application/app/Models/DocumentationContent.php b/app/application/app/Models/DocumentationContent.php index 500e1db..9cdf94e 100644 --- a/app/application/app/Models/DocumentationContent.php +++ b/app/application/app/Models/DocumentationContent.php @@ -2,13 +2,15 @@ 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\Model; 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'; diff --git a/app/application/app/Models/ProjectContent.php b/app/application/app/Models/ProjectContent.php index b719f2e..71644e7 100644 --- a/app/application/app/Models/ProjectContent.php +++ b/app/application/app/Models/ProjectContent.php @@ -2,13 +2,15 @@ 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\Model; 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'; diff --git a/app/application/app/Policies/DocumentationContentPolicy.php b/app/application/app/Policies/DocumentationContentPolicy.php new file mode 100644 index 0000000..9e5a05a --- /dev/null +++ b/app/application/app/Policies/DocumentationContentPolicy.php @@ -0,0 +1,17 @@ +hasPermission('documentation.create') || $user->hasPermission('documentation.update')) { + return true; + } + + return false; + } +} diff --git a/app/application/app/Policies/ProjectContentPolicy.php b/app/application/app/Policies/ProjectContentPolicy.php index 179aaec..0653b7d 100644 --- a/app/application/app/Policies/ProjectContentPolicy.php +++ b/app/application/app/Policies/ProjectContentPolicy.php @@ -26,4 +26,13 @@ public function update(User $user, ProjectContent $projectContent): bool { 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; + } } diff --git a/app/application/app/Services/Admin/Project/AboutService.php b/app/application/app/Services/Admin/Project/AboutService.php index 4026425..611a99d 100644 --- a/app/application/app/Services/Admin/Project/AboutService.php +++ b/app/application/app/Services/Admin/Project/AboutService.php @@ -4,6 +4,7 @@ use App\Contracts\ServiceResultError; use App\Dto\Service\Admin\Project\About\StoreUpdate; +use App\Enums\Morph; use App\Models\ProjectContent; use App\Models\User; use App\Repositories\ProjectContentRepository; @@ -13,6 +14,7 @@ use App\ServiceResults\StoreUpdateResult; use App\Services\ProjectContent\ProjectContentCommandHandler; use App\Services\Service; +use App\Services\Storage\StorageService; use Illuminate\Support\Facades\DB; final class AboutService extends Service @@ -22,6 +24,7 @@ public function __construct( private readonly ProjectLanguageRepository $projectLanguageRepository, private readonly ProjectContentRepository $projectContentRepository, private readonly ProjectContentCommandHandler $projectContentCommandHandler, + private readonly StorageService $storageService, ) { } 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)) { return $this->errFobidden(__('Access is denied')); } + + $storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::ProjectContent); + if (!$storages->isSuccess()) { + return $storages; + } + try { - $aboutProject = DB::transaction(function () use ($data, $projectId, $languageId) { + $aboutProject = DB::transaction(function () use ($data, $projectId, $languageId, $storages) { $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) { report($e); @@ -108,11 +119,19 @@ private function update(ProjectContent $content, StoreUpdate $data, User $user): return $this->errFobidden(__('Access is denied')); } + $storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::ProjectContent); + if (!$storages->isSuccess()) { + return $storages; + } + try { - $aboutProject = DB::transaction(function () use ($data, $content) { + $aboutProject = DB::transaction(function () use ($data, $content, $storages) { $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) { report($e); diff --git a/app/application/app/Services/DocumentationContent/ModelSyncCommand.php b/app/application/app/Services/DocumentationContent/ModelSyncCommand.php index aa1153a..d64c235 100644 --- a/app/application/app/Services/DocumentationContent/ModelSyncCommand.php +++ b/app/application/app/Services/DocumentationContent/ModelSyncCommand.php @@ -4,18 +4,33 @@ use App\Dto\Service\Admin\Project\DocumentationContent\Content; 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\StorageCommandException; use App\Models\Documentation; +use App\Models\DocumentationContent; use App\Models\Project; final readonly class ModelSyncCommand { + public function __construct( + private StorageCommand $storageCommand, + ) { } + + /** + * @throws StorageCommandException + * @throws ContentSaveException + */ public function execute(Project $project, Documentation $documentation, Contents $contents): void { + $storageDto = new StorageDto(); + $languages = $project->languages; $documentationContents = $documentation->contents; $newContents = []; + $contentsStorageCreated = []; + $contentLanguages = []; foreach ($contents->getContents() as $content) { /** @var Content $content */ $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); $data = $this->getData($content); if (\is_null($model)) { + $contentsStorageCreated[$content->getLanguageId()] = $content->getStorages(); $newContents[] = array_merge(['language_id' => $content->getLanguageId()], $data); + $contentLanguages[] = $content->getLanguageId(); continue; } + $storageDto->add($model, $content->getStorages()); $model->update($data); } if (!empty($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 diff --git a/app/application/app/Services/DocumentationContent/StorageCommand.php b/app/application/app/Services/DocumentationContent/StorageCommand.php new file mode 100644 index 0000000..be2f446 --- /dev/null +++ b/app/application/app/Services/DocumentationContent/StorageCommand.php @@ -0,0 +1,31 @@ +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); + } + } +} diff --git a/app/application/app/View/Components/Volt/Forms/TextareaWysiwyg.php b/app/application/app/View/Components/Volt/Forms/TextareaWysiwyg.php index 79e02dc..bbb63de 100644 --- a/app/application/app/View/Components/Volt/Forms/TextareaWysiwyg.php +++ b/app/application/app/View/Components/Volt/Forms/TextareaWysiwyg.php @@ -2,15 +2,17 @@ namespace App\View\Components\Volt\Forms; +use App\Dto\View\Volt\Form\WysiwygStorageUpload; use Illuminate\Support\Str; use Illuminate\View\View; final class TextareaWysiwyg extends Form { public function __construct( - private readonly string $title, - private readonly string $name, - private readonly ?string $value = '', + private readonly string $title, + private readonly string $name, + private readonly ?string $value = '', + private readonly ?WysiwygStorageUpload $storageUpload = null, ) { } protected function getName(): string @@ -28,6 +30,11 @@ private function getValue(): string return (string) old($this->getRequestName(), $this->value); } + public function getStorageUpload(): ?WysiwygStorageUpload + { + return $this->storageUpload; + } + /** * @inheritDoc */ @@ -37,10 +44,11 @@ public function render(): View return view('components.volt.forms.textarea-wysiwyg', [ 'tinymceLicenseKey' => $tinymceLicenseKey, - 'title' => $this->getTitle(), - 'name' => $this->getName(), - 'requestName' => $this->getRequestName(), - 'value' => $this->getValue(), + 'title' => $this->getTitle(), + 'name' => $this->getName(), + 'requestName' => $this->getRequestName(), + 'value' => $this->getValue(), + 'storageUpload' => $this->getStorageUpload(), ]); } } diff --git a/app/application/lang/en/validation.php b/app/application/lang/en/validation.php index bb54933..ed4aa04 100644 --- a/app/application/lang/en/validation.php +++ b/app/application/lang/en/validation.php @@ -307,5 +307,6 @@ 'content.*.title' => 'title', 'content.*.content' => 'content', 'category_id' => 'category', + 'content_images' => 'content images', ], ]; diff --git a/app/application/lang/ru/validation.php b/app/application/lang/ru/validation.php index f504da7..6e812ac 100644 --- a/app/application/lang/ru/validation.php +++ b/app/application/lang/ru/validation.php @@ -307,5 +307,6 @@ 'content.*.title' => 'заголовок', 'content.*.content' => 'контент', 'category_id' => 'категория', + 'content_images' => 'изображения контента', ], ]; diff --git a/app/application/package-lock.json b/app/application/package-lock.json index 55965ac..860e73a 100644 --- a/app/application/package-lock.json +++ b/app/application/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@fancyapps/ui": "^5.0.36", "@popperjs/core": "^2.9.2", "bootstrap": "5.0.2", "chartist": "^0.11.4", @@ -397,6 +398,11 @@ "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": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", diff --git a/app/application/package.json b/app/application/package.json index b272a93..06e40df 100644 --- a/app/application/package.json +++ b/app/application/package.json @@ -6,6 +6,7 @@ "build": "vite build" }, "dependencies": { + "@fancyapps/ui": "^5.0.36", "@popperjs/core": "^2.9.2", "bootstrap": "5.0.2", "chartist": "^0.11.4", diff --git a/app/application/resources/fancybox/app.js b/app/application/resources/fancybox/app.js new file mode 100644 index 0000000..e3b514c --- /dev/null +++ b/app/application/resources/fancybox/app.js @@ -0,0 +1,6 @@ +import { Fancybox } from "@fancyapps/ui"; +import "@fancyapps/ui/dist/fancybox/fancybox.css"; + +Fancybox.bind('a.image-open', { + // +}); diff --git a/app/application/resources/site/scss/app.scss b/app/application/resources/site/scss/app.scss index 3438838..c1e24ad 100644 --- a/app/application/resources/site/scss/app.scss +++ b/app/application/resources/site/scss/app.scss @@ -29,6 +29,39 @@ body { .content { 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 { diff --git a/app/application/resources/tinymce/plugins/my-image/plugin.min.js b/app/application/resources/tinymce/plugins/my-image/plugin.min.js new file mode 100644 index 0000000..ee7a79f --- /dev/null +++ b/app/application/resources/tinymce/plugins/my-image/plugin.min.js @@ -0,0 +1,1514 @@ +/** + * TinyMCE version 7.0.1 (2024-04-10) + * + * Slightly edited original plugin. Now, when inserting a picture, it adds a link. + */ + +(function () { + 'use strict'; + + var global$4 = tinymce.util.Tools.resolve('tinymce.PluginManager'); + + const getPrototypeOf = Object.getPrototypeOf; + const hasProto = (v, constructor, predicate) => { + var _a; + if (predicate(v, constructor.prototype)) { + return true; + } else { + return ((_a = v.constructor) === null || _a === void 0 ? void 0 : _a.name) === constructor.name; + } + }; + const typeOf = x => { + const t = typeof x; + if (x === null) { + return 'null'; + } else if (t === 'object' && Array.isArray(x)) { + return 'array'; + } else if (t === 'object' && hasProto(x, String, (o, proto) => proto.isPrototypeOf(o))) { + return 'string'; + } else { + return t; + } + }; + const isType = type => value => typeOf(value) === type; + const isSimpleType = type => value => typeof value === type; + const eq = t => a => t === a; + const is = (value, constructor) => isObject(value) && hasProto(value, constructor, (o, proto) => getPrototypeOf(o) === proto); + const isString = isType('string'); + const isObject = isType('object'); + const isPlainObject = value => is(value, Object); + const isArray = isType('array'); + const isNull = eq(null); + const isBoolean = isSimpleType('boolean'); + const isNullable = a => a === null || a === undefined; + const isNonNullable = a => !isNullable(a); + const isFunction = isSimpleType('function'); + const isNumber = isSimpleType('number'); + const isArrayOf = (value, pred) => { + if (isArray(value)) { + for (let i = 0, len = value.length; i < len; ++i) { + if (!pred(value[i])) { + return false; + } + } + return true; + } + return false; + }; + + const noop = () => { + }; + + class Optional { + constructor(tag, value) { + this.tag = tag; + this.value = value; + } + static some(value) { + return new Optional(true, value); + } + static none() { + return Optional.singletonNone; + } + fold(onNone, onSome) { + if (this.tag) { + return onSome(this.value); + } else { + return onNone(); + } + } + isSome() { + return this.tag; + } + isNone() { + return !this.tag; + } + map(mapper) { + if (this.tag) { + return Optional.some(mapper(this.value)); + } else { + return Optional.none(); + } + } + bind(binder) { + if (this.tag) { + return binder(this.value); + } else { + return Optional.none(); + } + } + exists(predicate) { + return this.tag && predicate(this.value); + } + forall(predicate) { + return !this.tag || predicate(this.value); + } + filter(predicate) { + if (!this.tag || predicate(this.value)) { + return this; + } else { + return Optional.none(); + } + } + getOr(replacement) { + return this.tag ? this.value : replacement; + } + or(replacement) { + return this.tag ? this : replacement; + } + getOrThunk(thunk) { + return this.tag ? this.value : thunk(); + } + orThunk(thunk) { + return this.tag ? this : thunk(); + } + getOrDie(message) { + if (!this.tag) { + throw new Error(message !== null && message !== void 0 ? message : 'Called getOrDie on None'); + } else { + return this.value; + } + } + static from(value) { + return isNonNullable(value) ? Optional.some(value) : Optional.none(); + } + getOrNull() { + return this.tag ? this.value : null; + } + getOrUndefined() { + return this.value; + } + each(worker) { + if (this.tag) { + worker(this.value); + } + } + toArray() { + return this.tag ? [this.value] : []; + } + toString() { + return this.tag ? `some(${ this.value })` : 'none()'; + } + } + Optional.singletonNone = new Optional(false); + + const keys = Object.keys; + const hasOwnProperty = Object.hasOwnProperty; + const each = (obj, f) => { + const props = keys(obj); + for (let k = 0, len = props.length; k < len; k++) { + const i = props[k]; + const x = obj[i]; + f(x, i); + } + }; + const objAcc = r => (x, i) => { + r[i] = x; + }; + const internalFilter = (obj, pred, onTrue, onFalse) => { + each(obj, (x, i) => { + (pred(x, i) ? onTrue : onFalse)(x, i); + }); + }; + const filter = (obj, pred) => { + const t = {}; + internalFilter(obj, pred, objAcc(t), noop); + return t; + }; + const has = (obj, key) => hasOwnProperty.call(obj, key); + const hasNonNullableKey = (obj, key) => has(obj, key) && obj[key] !== undefined && obj[key] !== null; + + const nativePush = Array.prototype.push; + const flatten = xs => { + const r = []; + for (let i = 0, len = xs.length; i < len; ++i) { + if (!isArray(xs[i])) { + throw new Error('Arr.flatten item ' + i + ' was not an array, input: ' + xs); + } + nativePush.apply(r, xs[i]); + } + return r; + }; + const get = (xs, i) => i >= 0 && i < xs.length ? Optional.some(xs[i]) : Optional.none(); + const head = xs => get(xs, 0); + const findMap = (arr, f) => { + for (let i = 0; i < arr.length; i++) { + const r = f(arr[i], i); + if (r.isSome()) { + return r; + } + } + return Optional.none(); + }; + + typeof window !== 'undefined' ? window : Function('return this;')(); + + const rawSet = (dom, key, value) => { + if (isString(value) || isBoolean(value) || isNumber(value)) { + dom.setAttribute(key, value + ''); + } else { + console.error('Invalid call to Attribute.set. Key ', key, ':: Value ', value, ':: Element ', dom); + throw new Error('Attribute value was not simple'); + } + }; + const set = (element, key, value) => { + rawSet(element.dom, key, value); + }; + const remove = (element, key) => { + element.dom.removeAttribute(key); + }; + + const fromHtml = (html, scope) => { + const doc = scope || document; + const div = doc.createElement('div'); + div.innerHTML = html; + if (!div.hasChildNodes() || div.childNodes.length > 1) { + const message = 'HTML does not have a single root node'; + console.error(message, html); + throw new Error(message); + } + return fromDom(div.childNodes[0]); + }; + const fromTag = (tag, scope) => { + const doc = scope || document; + const node = doc.createElement(tag); + return fromDom(node); + }; + const fromText = (text, scope) => { + const doc = scope || document; + const node = doc.createTextNode(text); + return fromDom(node); + }; + const fromDom = node => { + if (node === null || node === undefined) { + throw new Error('Node cannot be null or undefined'); + } + return { dom: node }; + }; + const fromPoint = (docElm, x, y) => Optional.from(docElm.dom.elementFromPoint(x, y)).map(fromDom); + const SugarElement = { + fromHtml, + fromTag, + fromText, + fromDom, + fromPoint + }; + + var global$3 = tinymce.util.Tools.resolve('tinymce.dom.DOMUtils'); + + var global$2 = tinymce.util.Tools.resolve('tinymce.util.URI'); + + const isNotEmpty = s => s.length > 0; + + const option = name => editor => editor.options.get(name); + const register$2 = editor => { + const registerOption = editor.options.register; + registerOption('image_dimensions', { + processor: 'boolean', + default: true + }); + registerOption('image_advtab', { + processor: 'boolean', + default: false + }); + registerOption('image_uploadtab', { + processor: 'boolean', + default: true + }); + registerOption('image_prepend_url', { + processor: 'string', + default: '' + }); + registerOption('image_class_list', { processor: 'object[]' }); + registerOption('image_description', { + processor: 'boolean', + default: true + }); + registerOption('image_title', { + processor: 'boolean', + default: false + }); + registerOption('image_caption', { + processor: 'boolean', + default: false + }); + registerOption('image_list', { + processor: value => { + const valid = value === false || isString(value) || isArrayOf(value, isObject) || isFunction(value); + return valid ? { + value, + valid + } : { + valid: false, + message: 'Must be false, a string, an array or a function.' + }; + }, + default: false + }); + }; + const hasDimensions = option('image_dimensions'); + const hasAdvTab = option('image_advtab'); + const hasUploadTab = option('image_uploadtab'); + const getPrependUrl = option('image_prepend_url'); + const getClassList = option('image_class_list'); + const hasDescription = option('image_description'); + const hasImageTitle = option('image_title'); + const hasImageCaption = option('image_caption'); + const getImageList = option('image_list'); + const showAccessibilityOptions = option('a11y_advanced_options'); + const isAutomaticUploadsEnabled = option('automatic_uploads'); + const hasUploadUrl = editor => isNotEmpty(editor.options.get('images_upload_url')); + const hasUploadHandler = editor => isNonNullable(editor.options.get('images_upload_handler')); + + const parseIntAndGetMax = (val1, val2) => Math.max(parseInt(val1, 10), parseInt(val2, 10)); + const getImageSize = url => new Promise(callback => { + const img = document.createElement('img'); + const done = dimensions => { + img.onload = img.onerror = null; + if (img.parentNode) { + img.parentNode.removeChild(img); + } + callback(dimensions); + }; + img.onload = () => { + const width = parseIntAndGetMax(img.width, img.clientWidth); + const height = parseIntAndGetMax(img.height, img.clientHeight); + const dimensions = { + width, + height + }; + done(Promise.resolve(dimensions)); + }; + img.onerror = () => { + done(Promise.reject(`Failed to get image dimensions for: ${ url }`)); + }; + const style = img.style; + style.visibility = 'hidden'; + style.position = 'fixed'; + style.bottom = style.left = '0px'; + style.width = style.height = 'auto'; + document.body.appendChild(img); + img.src = url; + }); + const removePixelSuffix = value => { + if (value) { + value = value.replace(/px$/, ''); + } + return value; + }; + const addPixelSuffix = value => { + if (value.length > 0 && /^[0-9]+$/.test(value)) { + value += 'px'; + } + return value; + }; + const mergeMargins = css => { + if (css.margin) { + const splitMargin = String(css.margin).split(' '); + switch (splitMargin.length) { + case 1: + css['margin-top'] = css['margin-top'] || splitMargin[0]; + css['margin-right'] = css['margin-right'] || splitMargin[0]; + css['margin-bottom'] = css['margin-bottom'] || splitMargin[0]; + css['margin-left'] = css['margin-left'] || splitMargin[0]; + break; + case 2: + css['margin-top'] = css['margin-top'] || splitMargin[0]; + css['margin-right'] = css['margin-right'] || splitMargin[1]; + css['margin-bottom'] = css['margin-bottom'] || splitMargin[0]; + css['margin-left'] = css['margin-left'] || splitMargin[1]; + break; + case 3: + css['margin-top'] = css['margin-top'] || splitMargin[0]; + css['margin-right'] = css['margin-right'] || splitMargin[1]; + css['margin-bottom'] = css['margin-bottom'] || splitMargin[2]; + css['margin-left'] = css['margin-left'] || splitMargin[1]; + break; + case 4: + css['margin-top'] = css['margin-top'] || splitMargin[0]; + css['margin-right'] = css['margin-right'] || splitMargin[1]; + css['margin-bottom'] = css['margin-bottom'] || splitMargin[2]; + css['margin-left'] = css['margin-left'] || splitMargin[3]; + } + delete css.margin; + } + return css; + }; + const createImageList = (editor, callback) => { + const imageList = getImageList(editor); + if (isString(imageList)) { + fetch(imageList).then(res => { + if (res.ok) { + res.json().then(callback); + } + }); + } else if (isFunction(imageList)) { + imageList(callback); + } else { + callback(imageList); + } + }; + const waitLoadImage = (editor, data, imgElm) => { + const selectImage = () => { + imgElm.onload = imgElm.onerror = null; + if (editor.selection) { + editor.selection.select(imgElm); + editor.nodeChanged(); + } + }; + imgElm.onload = () => { + if (!data.width && !data.height && hasDimensions(editor)) { + editor.dom.setAttribs(imgElm, { + width: String(imgElm.clientWidth), + height: String(imgElm.clientHeight) + }); + } + selectImage(); + }; + imgElm.onerror = selectImage; + }; + const blobToDataUri = blob => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result); + }; + reader.onerror = () => { + var _a; + reject((_a = reader.error) === null || _a === void 0 ? void 0 : _a.message); + }; + reader.readAsDataURL(blob); + }); + const isPlaceholderImage = imgElm => imgElm.nodeName === 'IMG' && (imgElm.hasAttribute('data-mce-object') || imgElm.hasAttribute('data-mce-placeholder')); + const isSafeImageUrl = (editor, src) => { + const getOption = editor.options.get; + return global$2.isDomSafe(src, 'img', { + allow_html_data_urls: getOption('allow_html_data_urls'), + allow_script_urls: getOption('allow_script_urls'), + allow_svg_data_urls: getOption('allow_svg_data_urls') + }); + }; + + const DOM = global$3.DOM; + const getHspace = image => { + if (image.style.marginLeft && image.style.marginRight && image.style.marginLeft === image.style.marginRight) { + return removePixelSuffix(image.style.marginLeft); + } else { + return ''; + } + }; + const getVspace = image => { + if (image.style.marginTop && image.style.marginBottom && image.style.marginTop === image.style.marginBottom) { + return removePixelSuffix(image.style.marginTop); + } else { + return ''; + } + }; + const getBorder = image => { + if (image.style.borderWidth) { + return removePixelSuffix(image.style.borderWidth); + } else { + return ''; + } + }; + const getAttrib = (image, name) => { + var _a; + if (image.hasAttribute(name)) { + return (_a = image.getAttribute(name)) !== null && _a !== void 0 ? _a : ''; + } else { + return ''; + } + }; + const hasCaption = image => image.parentNode !== null && image.parentNode.nodeName === 'FIGURE'; + const updateAttrib = (image, name, value) => { + if (value === '' || value === null) { + image.removeAttribute(name); + } else { + image.setAttribute(name, value); + } + }; + const wrapInFigure = image => { + const figureElm = DOM.create('figure', { class: 'image' }); + DOM.insertAfter(figureElm, image); + figureElm.appendChild(image); + figureElm.appendChild(DOM.create('figcaption', { contentEditable: 'true' }, 'Caption')); + figureElm.contentEditable = 'false'; + }; + const removeFigure = image => { + const figureElm = image.parentNode; + if (isNonNullable(figureElm)) { + DOM.insertAfter(image, figureElm); + DOM.remove(figureElm); + } + }; + const toggleCaption = image => { + if (hasCaption(image)) { + removeFigure(image); + } else { + wrapInFigure(image); + } + }; + const normalizeStyle = (image, normalizeCss) => { + const attrValue = image.getAttribute('style'); + const value = normalizeCss(attrValue !== null ? attrValue : ''); + if (value.length > 0) { + image.setAttribute('style', value); + image.setAttribute('data-mce-style', value); + } else { + image.removeAttribute('style'); + } + }; + const setSize = (name, normalizeCss) => (image, name, value) => { + const styles = image.style; + if (styles[name]) { + styles[name] = addPixelSuffix(value); + normalizeStyle(image, normalizeCss); + } else { + updateAttrib(image, name, value); + } + }; + const getSize = (image, name) => { + if (image.style[name]) { + return removePixelSuffix(image.style[name]); + } else { + return getAttrib(image, name); + } + }; + const setHspace = (image, value) => { + const pxValue = addPixelSuffix(value); + image.style.marginLeft = pxValue; + image.style.marginRight = pxValue; + }; + const setVspace = (image, value) => { + const pxValue = addPixelSuffix(value); + image.style.marginTop = pxValue; + image.style.marginBottom = pxValue; + }; + const setBorder = (image, value) => { + const pxValue = addPixelSuffix(value); + image.style.borderWidth = pxValue; + }; + const setBorderStyle = (image, value) => { + image.style.borderStyle = value; + }; + const getBorderStyle = image => { + var _a; + return (_a = image.style.borderStyle) !== null && _a !== void 0 ? _a : ''; + }; + const isFigure = elm => isNonNullable(elm) && elm.nodeName === 'FIGURE'; + const isImage = elm => elm.nodeName === 'IMG'; + const getIsDecorative = image => DOM.getAttrib(image, 'alt').length === 0 && DOM.getAttrib(image, 'role') === 'presentation'; + const getAlt = image => { + if (getIsDecorative(image)) { + return ''; + } else { + return getAttrib(image, 'alt'); + } + }; + const defaultData = () => ({ + src: '', + alt: '', + title: '', + width: '', + height: '', + class: '', + style: '', + caption: false, + hspace: '', + vspace: '', + border: '', + borderStyle: '', + isDecorative: false + }); + const getStyleValue = (normalizeCss, data) => { + var _a; + const image = document.createElement('img'); + updateAttrib(image, 'style', data.style); + if (getHspace(image) || data.hspace !== '') { + setHspace(image, data.hspace); + } + if (getVspace(image) || data.vspace !== '') { + setVspace(image, data.vspace); + } + if (getBorder(image) || data.border !== '') { + setBorder(image, data.border); + } + if (getBorderStyle(image) || data.borderStyle !== '') { + setBorderStyle(image, data.borderStyle); + } + return normalizeCss((_a = image.getAttribute('style')) !== null && _a !== void 0 ? _a : ''); + }; + const create = (normalizeCss, data) => { + const image = document.createElement('img'); + write(normalizeCss, { + ...data, + caption: false + }, image); + setAlt(image, data.alt, data.isDecorative); + if (data.caption) { + const figure = DOM.create('figure', { class: 'image' }); + figure.appendChild(image); + figure.appendChild(DOM.create('figcaption', { contentEditable: 'true' }, 'Caption')); + figure.contentEditable = 'false'; + return figure; + } else { + let linkStyle = ''; + if (isNumber(getSize(image, 'width')) && isNumber(getSize(image, 'height'))) { + linkStyle = '--aspect-ratio:' + getSize(image, 'width') + '/' + getSize(image, 'height') + ';'; + } + const link = DOM.create('a', { class: 'image-open', href: image.getAttribute('src'), target: '_blank', style: linkStyle }); + + link.appendChild(image); + return link; + } + }; + const read = (normalizeCss, image) => ({ + src: getAttrib(image, 'src'), + alt: getAlt(image), + title: getAttrib(image, 'title'), + width: getSize(image, 'width'), + height: getSize(image, 'height'), + class: getAttrib(image, 'class'), + style: normalizeCss(getAttrib(image, 'style')), + caption: hasCaption(image), + hspace: getHspace(image), + vspace: getVspace(image), + border: getBorder(image), + borderStyle: getBorderStyle(image), + isDecorative: getIsDecorative(image) + }); + const updateProp = (image, oldData, newData, name, set) => { + if (newData[name] !== oldData[name]) { + set(image, name, String(newData[name])); + } + }; + const setAlt = (image, alt, isDecorative) => { + if (isDecorative) { + DOM.setAttrib(image, 'role', 'presentation'); + const sugarImage = SugarElement.fromDom(image); + set(sugarImage, 'alt', ''); + } else { + if (isNull(alt)) { + const sugarImage = SugarElement.fromDom(image); + remove(sugarImage, 'alt'); + } else { + const sugarImage = SugarElement.fromDom(image); + set(sugarImage, 'alt', alt); + } + if (DOM.getAttrib(image, 'role') === 'presentation') { + DOM.setAttrib(image, 'role', ''); + } + } + }; + const updateAlt = (image, oldData, newData) => { + if (newData.alt !== oldData.alt || newData.isDecorative !== oldData.isDecorative) { + setAlt(image, newData.alt, newData.isDecorative); + } + }; + const normalized = (set, normalizeCss) => (image, name, value) => { + set(image, value); + normalizeStyle(image, normalizeCss); + }; + const write = (normalizeCss, newData, image) => { + const oldData = read(normalizeCss, image); + updateProp(image, oldData, newData, 'caption', (image, _name, _value) => toggleCaption(image)); + updateProp(image, oldData, newData, 'src', updateAttrib); + updateProp(image, oldData, newData, 'title', updateAttrib); + updateProp(image, oldData, newData, 'width', setSize('width', normalizeCss)); + updateProp(image, oldData, newData, 'height', setSize('height', normalizeCss)); + updateProp(image, oldData, newData, 'class', updateAttrib); + updateProp(image, oldData, newData, 'style', normalized((image, value) => updateAttrib(image, 'style', value), normalizeCss)); + updateProp(image, oldData, newData, 'hspace', normalized(setHspace, normalizeCss)); + updateProp(image, oldData, newData, 'vspace', normalized(setVspace, normalizeCss)); + updateProp(image, oldData, newData, 'border', normalized(setBorder, normalizeCss)); + updateProp(image, oldData, newData, 'borderStyle', normalized(setBorderStyle, normalizeCss)); + updateAlt(image, oldData, newData); + }; + + const normalizeCss$1 = (editor, cssText) => { + const css = editor.dom.styles.parse(cssText); + const mergedCss = mergeMargins(css); + const compressed = editor.dom.styles.parse(editor.dom.styles.serialize(mergedCss)); + return editor.dom.styles.serialize(compressed); + }; + const getSelectedImage = editor => { + const imgElm = editor.selection.getNode(); + const figureElm = editor.dom.getParent(imgElm, 'figure.image'); + if (figureElm) { + return editor.dom.select('img', figureElm)[0]; + } + if (imgElm && (imgElm.nodeName !== 'IMG' || isPlaceholderImage(imgElm))) { + return null; + } + return imgElm; + }; + const splitTextBlock = (editor, figure) => { + var _a; + const dom = editor.dom; + const textBlockElements = filter(editor.schema.getTextBlockElements(), (_, parentElm) => !editor.schema.isValidChild(parentElm, 'figure')); + const textBlock = dom.getParent(figure.parentNode, node => hasNonNullableKey(textBlockElements, node.nodeName), editor.getBody()); + if (textBlock) { + return (_a = dom.split(textBlock, figure)) !== null && _a !== void 0 ? _a : figure; + } else { + return figure; + } + }; + const readImageDataFromSelection = editor => { + const image = getSelectedImage(editor); + return image ? read(css => normalizeCss$1(editor, css), image) : defaultData(); + }; + const insertImageAtCaret = (editor, data) => { + const elm = create(css => normalizeCss$1(editor, css), data); + editor.dom.setAttrib(elm, 'data-mce-id', '__mcenew'); + editor.focus(); + editor.selection.setContent(elm.outerHTML); + const insertedElm = editor.dom.select('*[data-mce-id="__mcenew"]')[0]; + editor.dom.setAttrib(insertedElm, 'data-mce-id', null); + if (isFigure(insertedElm)) { + const figure = splitTextBlock(editor, insertedElm); + editor.selection.select(figure); + } else { + editor.selection.select(insertedElm); + } + }; + const syncSrcAttr = (editor, image) => { + editor.dom.setAttrib(image, 'src', image.getAttribute('src')); + }; + const deleteImage = (editor, image) => { + if (image) { + const elm = editor.dom.is(image.parentNode, 'figure.image') ? image.parentNode : image; + editor.dom.remove(elm); + editor.focus(); + editor.nodeChanged(); + if (editor.dom.isEmpty(editor.getBody())) { + editor.setContent(''); + editor.selection.setCursorLocation(); + } + } + }; + const writeImageDataToSelection = (editor, data) => { + const image = getSelectedImage(editor); + if (image) { + write(css => normalizeCss$1(editor, css), data, image); + syncSrcAttr(editor, image); + if (isFigure(image.parentNode)) { + const figure = image.parentNode; + splitTextBlock(editor, figure); + editor.selection.select(image.parentNode); + } else { + editor.selection.select(image); + waitLoadImage(editor, data, image); + } + } + }; + const sanitizeImageData = (editor, data) => { + const src = data.src; + return { + ...data, + src: isSafeImageUrl(editor, src) ? src : '' + }; + }; + const insertOrUpdateImage = (editor, partialData) => { + const image = getSelectedImage(editor); + if (image) { + const selectedImageData = read(css => normalizeCss$1(editor, css), image); + const data = { + ...selectedImageData, + ...partialData + }; + const sanitizedData = sanitizeImageData(editor, data); + if (data.src) { + writeImageDataToSelection(editor, sanitizedData); + } else { + deleteImage(editor, image); + } + } else if (partialData.src) { + insertImageAtCaret(editor, { + ...defaultData(), + ...partialData + }); + } + }; + + const deep = (old, nu) => { + const bothObjects = isPlainObject(old) && isPlainObject(nu); + return bothObjects ? deepMerge(old, nu) : nu; + }; + const baseMerge = merger => { + return (...objects) => { + if (objects.length === 0) { + throw new Error(`Can't merge zero objects`); + } + const ret = {}; + for (let j = 0; j < objects.length; j++) { + const curObject = objects[j]; + for (const key in curObject) { + if (has(curObject, key)) { + ret[key] = merger(ret[key], curObject[key]); + } + } + } + return ret; + }; + }; + const deepMerge = baseMerge(deep); + + var global$1 = tinymce.util.Tools.resolve('tinymce.util.ImageUploader'); + + var global = tinymce.util.Tools.resolve('tinymce.util.Tools'); + + const getValue = item => isString(item.value) ? item.value : ''; + const getText = item => { + if (isString(item.text)) { + return item.text; + } else if (isString(item.title)) { + return item.title; + } else { + return ''; + } + }; + const sanitizeList = (list, extractValue) => { + const out = []; + global.each(list, item => { + const text = getText(item); + if (item.menu !== undefined) { + const items = sanitizeList(item.menu, extractValue); + out.push({ + text, + items + }); + } else { + const value = extractValue(item); + out.push({ + text, + value + }); + } + }); + return out; + }; + const sanitizer = (extractor = getValue) => list => { + if (list) { + return Optional.from(list).map(list => sanitizeList(list, extractor)); + } else { + return Optional.none(); + } + }; + const sanitize = list => sanitizer(getValue)(list); + const isGroup = item => has(item, 'items'); + const findEntryDelegate = (list, value) => findMap(list, item => { + if (isGroup(item)) { + return findEntryDelegate(item.items, value); + } else if (item.value === value) { + return Optional.some(item); + } else { + return Optional.none(); + } + }); + const findEntry = (optList, value) => optList.bind(list => findEntryDelegate(list, value)); + const ListUtils = { + sanitizer, + sanitize, + findEntry + }; + + const makeTab$2 = _info => ({ + title: 'Advanced', + name: 'advanced', + items: [{ + type: 'grid', + columns: 2, + items: [ + { + type: 'input', + label: 'Vertical space', + name: 'vspace', + inputMode: 'numeric' + }, + { + type: 'input', + label: 'Horizontal space', + name: 'hspace', + inputMode: 'numeric' + }, + { + type: 'input', + label: 'Border width', + name: 'border', + inputMode: 'numeric' + }, + { + type: 'listbox', + name: 'borderstyle', + label: 'Border style', + items: [ + { + text: 'Select...', + value: '' + }, + { + text: 'Solid', + value: 'solid' + }, + { + text: 'Dotted', + value: 'dotted' + }, + { + text: 'Dashed', + value: 'dashed' + }, + { + text: 'Double', + value: 'double' + }, + { + text: 'Groove', + value: 'groove' + }, + { + text: 'Ridge', + value: 'ridge' + }, + { + text: 'Inset', + value: 'inset' + }, + { + text: 'Outset', + value: 'outset' + }, + { + text: 'None', + value: 'none' + }, + { + text: 'Hidden', + value: 'hidden' + } + ] + } + ] + }] + }); + const AdvTab = { makeTab: makeTab$2 }; + + const collect = editor => { + const urlListSanitizer = ListUtils.sanitizer(item => editor.convertURL(item.value || item.url || '', 'src')); + const futureImageList = new Promise(completer => { + createImageList(editor, imageList => { + completer(urlListSanitizer(imageList).map(items => flatten([ + [{ + text: 'None', + value: '' + }], + items + ]))); + }); + }); + const classList = ListUtils.sanitize(getClassList(editor)); + const hasAdvTab$1 = hasAdvTab(editor); + const hasUploadTab$1 = hasUploadTab(editor); + const hasUploadUrl$1 = hasUploadUrl(editor); + const hasUploadHandler$1 = hasUploadHandler(editor); + const image = readImageDataFromSelection(editor); + const hasDescription$1 = hasDescription(editor); + const hasImageTitle$1 = hasImageTitle(editor); + const hasDimensions$1 = hasDimensions(editor); + const hasImageCaption$1 = hasImageCaption(editor); + const hasAccessibilityOptions = showAccessibilityOptions(editor); + const automaticUploads = isAutomaticUploadsEnabled(editor); + const prependURL = Optional.some(getPrependUrl(editor)).filter(preUrl => isString(preUrl) && preUrl.length > 0); + return futureImageList.then(imageList => ({ + image, + imageList, + classList, + hasAdvTab: hasAdvTab$1, + hasUploadTab: hasUploadTab$1, + hasUploadUrl: hasUploadUrl$1, + hasUploadHandler: hasUploadHandler$1, + hasDescription: hasDescription$1, + hasImageTitle: hasImageTitle$1, + hasDimensions: hasDimensions$1, + hasImageCaption: hasImageCaption$1, + prependURL, + hasAccessibilityOptions, + automaticUploads + })); + }; + + const makeItems = info => { + const imageUrl = { + name: 'src', + type: 'urlinput', + filetype: 'image', + label: 'Source', + picker_text: 'Browse files' + }; + const imageList = info.imageList.map(items => ({ + name: 'images', + type: 'listbox', + label: 'Image list', + items + })); + const imageDescription = { + name: 'alt', + type: 'input', + label: 'Alternative description', + enabled: !(info.hasAccessibilityOptions && info.image.isDecorative) + }; + const imageTitle = { + name: 'title', + type: 'input', + label: 'Image title' + }; + const imageDimensions = { + name: 'dimensions', + type: 'sizeinput' + }; + const isDecorative = { + type: 'label', + label: 'Accessibility', + items: [{ + name: 'isDecorative', + type: 'checkbox', + label: 'Image is decorative' + }] + }; + const classList = info.classList.map(items => ({ + name: 'classes', + type: 'listbox', + label: 'Class', + items + })); + const caption = { + type: 'label', + label: 'Caption', + items: [{ + type: 'checkbox', + name: 'caption', + label: 'Show caption' + }] + }; + const getDialogContainerType = useColumns => useColumns ? { + type: 'grid', + columns: 2 + } : { type: 'panel' }; + return flatten([ + [imageUrl], + imageList.toArray(), + info.hasAccessibilityOptions && info.hasDescription ? [isDecorative] : [], + info.hasDescription ? [imageDescription] : [], + info.hasImageTitle ? [imageTitle] : [], + info.hasDimensions ? [imageDimensions] : [], + [{ + ...getDialogContainerType(info.classList.isSome() && info.hasImageCaption), + items: flatten([ + classList.toArray(), + info.hasImageCaption ? [caption] : [] + ]) + }] + ]); + }; + const makeTab$1 = info => ({ + title: 'General', + name: 'general', + items: makeItems(info) + }); + const MainTab = { + makeTab: makeTab$1, + makeItems + }; + + const makeTab = _info => { + const items = [{ + type: 'dropzone', + name: 'fileinput' + }]; + return { + title: 'Upload', + name: 'upload', + items + }; + }; + const UploadTab = { makeTab }; + + const createState = info => ({ + prevImage: ListUtils.findEntry(info.imageList, info.image.src), + prevAlt: info.image.alt, + open: true + }); + const fromImageData = image => ({ + src: { + value: image.src, + meta: {} + }, + images: image.src, + alt: image.alt, + title: image.title, + dimensions: { + width: image.width, + height: image.height + }, + classes: image.class, + caption: image.caption, + style: image.style, + vspace: image.vspace, + border: image.border, + hspace: image.hspace, + borderstyle: image.borderStyle, + fileinput: [], + isDecorative: image.isDecorative + }); + const toImageData = (data, removeEmptyAlt) => ({ + src: data.src.value, + alt: (data.alt === null || data.alt.length === 0) && removeEmptyAlt ? null : data.alt, + title: data.title, + width: data.dimensions.width, + height: data.dimensions.height, + class: data.classes, + style: data.style, + caption: data.caption, + hspace: data.hspace, + vspace: data.vspace, + border: data.border, + borderStyle: data.borderstyle, + isDecorative: data.isDecorative + }); + const addPrependUrl2 = (info, srcURL) => { + if (!/^(?:[a-zA-Z]+:)?\/\//.test(srcURL)) { + return info.prependURL.bind(prependUrl => { + if (srcURL.substring(0, prependUrl.length) !== prependUrl) { + return Optional.some(prependUrl + srcURL); + } + return Optional.none(); + }); + } + return Optional.none(); + }; + const addPrependUrl = (info, api) => { + const data = api.getData(); + addPrependUrl2(info, data.src.value).each(srcURL => { + api.setData({ + src: { + value: srcURL, + meta: data.src.meta + } + }); + }); + }; + const formFillFromMeta2 = (info, data, meta) => { + if (info.hasDescription && isString(meta.alt)) { + data.alt = meta.alt; + } + if (info.hasAccessibilityOptions) { + data.isDecorative = meta.isDecorative || data.isDecorative || false; + } + if (info.hasImageTitle && isString(meta.title)) { + data.title = meta.title; + } + if (info.hasDimensions) { + if (isString(meta.width)) { + data.dimensions.width = meta.width; + } + if (isString(meta.height)) { + data.dimensions.height = meta.height; + } + } + if (isString(meta.class)) { + ListUtils.findEntry(info.classList, meta.class).each(entry => { + data.classes = entry.value; + }); + } + if (info.hasImageCaption) { + if (isBoolean(meta.caption)) { + data.caption = meta.caption; + } + } + if (info.hasAdvTab) { + if (isString(meta.style)) { + data.style = meta.style; + } + if (isString(meta.vspace)) { + data.vspace = meta.vspace; + } + if (isString(meta.border)) { + data.border = meta.border; + } + if (isString(meta.hspace)) { + data.hspace = meta.hspace; + } + if (isString(meta.borderstyle)) { + data.borderstyle = meta.borderstyle; + } + } + }; + const formFillFromMeta = (info, api) => { + const data = api.getData(); + const meta = data.src.meta; + if (meta !== undefined) { + const newData = deepMerge({}, data); + formFillFromMeta2(info, newData, meta); + api.setData(newData); + } + }; + const calculateImageSize = (helpers, info, state, api) => { + const data = api.getData(); + const url = data.src.value; + const meta = data.src.meta || {}; + if (!meta.width && !meta.height && info.hasDimensions) { + if (isNotEmpty(url)) { + helpers.imageSize(url).then(size => { + if (state.open) { + api.setData({ dimensions: size }); + } + }).catch(e => console.error(e)); + } else { + api.setData({ + dimensions: { + width: '', + height: '' + } + }); + } + } + }; + const updateImagesDropdown = (info, state, api) => { + const data = api.getData(); + const image = ListUtils.findEntry(info.imageList, data.src.value); + state.prevImage = image; + api.setData({ images: image.map(entry => entry.value).getOr('') }); + }; + const changeSrc = (helpers, info, state, api) => { + addPrependUrl(info, api); + formFillFromMeta(info, api); + calculateImageSize(helpers, info, state, api); + updateImagesDropdown(info, state, api); + }; + const changeImages = (helpers, info, state, api) => { + const data = api.getData(); + const image = ListUtils.findEntry(info.imageList, data.images); + image.each(img => { + const updateAlt = data.alt === '' || state.prevImage.map(image => image.text === data.alt).getOr(false); + if (updateAlt) { + if (img.value === '') { + api.setData({ + src: img, + alt: state.prevAlt + }); + } else { + api.setData({ + src: img, + alt: img.text + }); + } + } else { + api.setData({ src: img }); + } + }); + state.prevImage = image; + changeSrc(helpers, info, state, api); + }; + const changeFileInput = (helpers, info, state, api) => { + const data = api.getData(); + api.block('Uploading image'); + head(data.fileinput).fold(() => { + api.unblock(); + }, file => { + const blobUri = URL.createObjectURL(file); + const finalize = () => { + api.unblock(); + URL.revokeObjectURL(blobUri); + }; + const updateSrcAndSwitchTab = url => { + api.setData({ + src: { + value: url, + meta: {} + } + }); + api.showTab('general'); + changeSrc(helpers, info, state, api); + }; + blobToDataUri(file).then(dataUrl => { + const blobInfo = helpers.createBlobCache(file, blobUri, dataUrl); + if (info.automaticUploads) { + helpers.uploadImage(blobInfo).then(result => { + updateSrcAndSwitchTab(result.url); + finalize(); + }).catch(err => { + finalize(); + helpers.alertErr(err); + }); + } else { + helpers.addToBlobCache(blobInfo); + updateSrcAndSwitchTab(blobInfo.blobUri()); + api.unblock(); + } + }); + }); + }; + const changeHandler = (helpers, info, state) => (api, evt) => { + if (evt.name === 'src') { + changeSrc(helpers, info, state, api); + } else if (evt.name === 'images') { + changeImages(helpers, info, state, api); + } else if (evt.name === 'alt') { + state.prevAlt = api.getData().alt; + } else if (evt.name === 'fileinput') { + changeFileInput(helpers, info, state, api); + } else if (evt.name === 'isDecorative') { + api.setEnabled('alt', !api.getData().isDecorative); + } + }; + const closeHandler = state => () => { + state.open = false; + }; + const makeDialogBody = info => { + if (info.hasAdvTab || info.hasUploadUrl || info.hasUploadHandler) { + const tabPanel = { + type: 'tabpanel', + tabs: flatten([ + [MainTab.makeTab(info)], + info.hasAdvTab ? [AdvTab.makeTab(info)] : [], + info.hasUploadTab && (info.hasUploadUrl || info.hasUploadHandler) ? [UploadTab.makeTab(info)] : [] + ]) + }; + return tabPanel; + } else { + const panel = { + type: 'panel', + items: MainTab.makeItems(info) + }; + return panel; + } + }; + const submitHandler = (editor, info, helpers) => api => { + const data = deepMerge(fromImageData(info.image), api.getData()); + const finalData = { + ...data, + style: getStyleValue(helpers.normalizeCss, toImageData(data, false)) + }; + editor.execCommand('mceUpdateImage', false, toImageData(finalData, info.hasAccessibilityOptions)); + editor.editorUpload.uploadImagesAuto(); + api.close(); + }; + const imageSize = editor => url => { + if (!isSafeImageUrl(editor, url)) { + return Promise.resolve({ + width: '', + height: '' + }); + } else { + return getImageSize(editor.documentBaseURI.toAbsolute(url)).then(dimensions => ({ + width: String(dimensions.width), + height: String(dimensions.height) + })); + } + }; + const createBlobCache = editor => (file, blobUri, dataUrl) => { + var _a; + return editor.editorUpload.blobCache.create({ + blob: file, + blobUri, + name: (_a = file.name) === null || _a === void 0 ? void 0 : _a.replace(/\.[^\.]+$/, ''), + filename: file.name, + base64: dataUrl.split(',')[1] + }); + }; + const addToBlobCache = editor => blobInfo => { + editor.editorUpload.blobCache.add(blobInfo); + }; + const alertErr = editor => message => { + editor.windowManager.alert(message); + }; + const normalizeCss = editor => cssText => normalizeCss$1(editor, cssText); + const parseStyle = editor => cssText => editor.dom.parseStyle(cssText); + const serializeStyle = editor => (stylesArg, name) => editor.dom.serializeStyle(stylesArg, name); + const uploadImage = editor => blobInfo => global$1(editor).upload([blobInfo], false).then(results => { + var _a; + if (results.length === 0) { + return Promise.reject('Failed to upload image'); + } else if (results[0].status === false) { + return Promise.reject((_a = results[0].error) === null || _a === void 0 ? void 0 : _a.message); + } else { + return results[0]; + } + }); + const Dialog = editor => { + const helpers = { + imageSize: imageSize(editor), + addToBlobCache: addToBlobCache(editor), + createBlobCache: createBlobCache(editor), + alertErr: alertErr(editor), + normalizeCss: normalizeCss(editor), + parseStyle: parseStyle(editor), + serializeStyle: serializeStyle(editor), + uploadImage: uploadImage(editor) + }; + const open = () => { + collect(editor).then(info => { + const state = createState(info); + return { + title: 'Insert/Edit Image', + size: 'normal', + body: makeDialogBody(info), + buttons: [ + { + type: 'cancel', + name: 'cancel', + text: 'Cancel' + }, + { + type: 'submit', + name: 'save', + text: 'Save', + primary: true + } + ], + initialData: fromImageData(info.image), + onSubmit: submitHandler(editor, info, helpers), + onChange: changeHandler(helpers, info, state), + onClose: closeHandler(state) + }; + }).then(editor.windowManager.open); + }; + return { open }; + }; + + const register$1 = editor => { + editor.addCommand('mceImage', Dialog(editor).open); + editor.addCommand('mceUpdateImage', (_ui, data) => { + editor.undoManager.transact(() => insertOrUpdateImage(editor, data)); + }); + }; + + const hasImageClass = node => { + const className = node.attr('class'); + return isNonNullable(className) && /\bimage\b/.test(className); + }; + const toggleContentEditableState = state => nodes => { + let i = nodes.length; + const toggleContentEditable = node => { + node.attr('contenteditable', state ? 'true' : null); + }; + while (i--) { + const node = nodes[i]; + if (hasImageClass(node)) { + node.attr('contenteditable', state ? 'false' : null); + global.each(node.getAll('figcaption'), toggleContentEditable); + } + } + }; + const setup = editor => { + editor.on('PreInit', () => { + editor.parser.addNodeFilter('figure', toggleContentEditableState(true)); + editor.serializer.addNodeFilter('figure', toggleContentEditableState(false)); + }); + }; + + const onSetupEditable = editor => api => { + const nodeChanged = () => { + api.setEnabled(editor.selection.isEditable()); + }; + editor.on('NodeChange', nodeChanged); + nodeChanged(); + return () => { + editor.off('NodeChange', nodeChanged); + }; + }; + const register = editor => { + editor.ui.registry.addToggleButton('image', { + icon: 'image', + tooltip: 'Insert/edit image', + onAction: Dialog(editor).open, + onSetup: buttonApi => { + buttonApi.setActive(isNonNullable(getSelectedImage(editor))); + const unbindSelectorChanged = editor.selection.selectorChangedWithUnbind('img:not([data-mce-object]):not([data-mce-placeholder]),figure.image', buttonApi.setActive).unbind; + const unbindEditable = onSetupEditable(editor)(buttonApi); + return () => { + unbindSelectorChanged(); + unbindEditable(); + }; + } + }); + editor.ui.registry.addMenuItem('image', { + icon: 'image', + text: 'Image...', + onAction: Dialog(editor).open, + onSetup: onSetupEditable(editor) + }); + editor.ui.registry.addContextMenu('image', { update: element => editor.selection.isEditable() && (isFigure(element) || isImage(element) && !isPlaceholderImage(element)) ? ['image'] : [] }); + }; + + var Plugin = () => { + global$4.add('my-image', editor => { + register$2(editor); + setup(editor); + register(editor); + register$1(editor); + }); + }; + + Plugin(); + +})(); diff --git a/app/application/resources/views/_fancybox.blade.php b/app/application/resources/views/_fancybox.blade.php new file mode 100644 index 0000000..a5a6dfc --- /dev/null +++ b/app/application/resources/views/_fancybox.blade.php @@ -0,0 +1 @@ +@vite('resources/fancybox/app.js') diff --git a/app/application/resources/views/_tinymce.blade.php b/app/application/resources/views/_tinymce.blade.php new file mode 100644 index 0000000..4e65a37 --- /dev/null +++ b/app/application/resources/views/_tinymce.blade.php @@ -0,0 +1 @@ + diff --git a/app/application/resources/views/admin/_scripts/_tinymce.blade.php b/app/application/resources/views/admin/_scripts/_tinymce.blade.php new file mode 100644 index 0000000..9753a6d --- /dev/null +++ b/app/application/resources/views/admin/_scripts/_tinymce.blade.php @@ -0,0 +1,4 @@ +@pushOnce('scripts') + @include('_tinymce') + @include('_prism') +@endpushonce diff --git a/app/application/resources/views/admin/projects/about/_from.blade.php b/app/application/resources/views/admin/projects/about/_from.blade.php index 6057481..f3ca3a9 100644 --- a/app/application/resources/views/admin/projects/about/_from.blade.php +++ b/app/application/resources/views/admin/projects/about/_from.blade.php @@ -1,6 +1,11 @@ @csrf - + @canany(['create', 'update'], $content) diff --git a/app/application/resources/views/admin/projects/documentations/_from.blade.php b/app/application/resources/views/admin/projects/documentations/_from.blade.php index e2764f6..defcf3a 100644 --- a/app/application/resources/views/admin/projects/documentations/_from.blade.php +++ b/app/application/resources/views/admin/projects/documentations/_from.blade.php @@ -1,15 +1,22 @@ @csrf - - - - + + + + @@ -18,10 +25,22 @@ @php $content = $documentation->contents->firstWhere('language_id', $language->id); @endphp -
- - - +
+ + +
@endforeach
diff --git a/app/application/resources/views/components/volt/forms/textarea-wysiwyg.blade.php b/app/application/resources/views/components/volt/forms/textarea-wysiwyg.blade.php index e945606..c89074c 100644 --- a/app/application/resources/views/components/volt/forms/textarea-wysiwyg.blade.php +++ b/app/application/resources/views/components/volt/forms/textarea-wysiwyg.blade.php @@ -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
- - + + + @foreach($images as $image) + @continue( empty($image['file']) ) + + @endforeach @error($requestName) - {{ $message }} + {{ $message }} @enderror
-@pushOnce('scripts') - +@include('admin._scripts._tinymce') +@push('scripts') - @include('_prism') -@endpushonce +@endpush diff --git a/app/application/resources/views/site/projects/about.blade.php b/app/application/resources/views/site/projects/about.blade.php index 8452bcc..b5ee93f 100644 --- a/app/application/resources/views/site/projects/about.blade.php +++ b/app/application/resources/views/site/projects/about.blade.php @@ -11,5 +11,6 @@ @push('scripts') @include('_prism') + @include('_fancybox') @endpush diff --git a/app/application/resources/views/site/projects/documentation/view.blade.php b/app/application/resources/views/site/projects/documentation/view.blade.php index c831e15..ddf6c76 100644 --- a/app/application/resources/views/site/projects/documentation/view.blade.php +++ b/app/application/resources/views/site/projects/documentation/view.blade.php @@ -5,5 +5,6 @@
{!! $documentation->content->content !!}
@push('scripts') @include('_prism') + @include('_fancybox') @endpush diff --git a/app/application/vite.config.js b/app/application/vite.config.js index a03e8af..7be9dca 100644 --- a/app/application/vite.config.js +++ b/app/application/vite.config.js @@ -15,6 +15,8 @@ export default defineConfig({ 'resources/site/js/app.js', 'resources/prism/app.js', + + 'resources/fancybox/app.js', ], refresh: true, }),