diff --git a/app/docker/Dockerfile b/app/docker/Dockerfile index 19ded04..7afaf50 100644 --- a/app/docker/Dockerfile +++ b/app/docker/Dockerfile @@ -111,6 +111,7 @@ ENTRYPOINT ["composer"] FROM BUILD AS NPM +RUN mkdir "/.npm" && chmod -R 0777 "/.npm" WORKDIR /var/www/html STOPSIGNAL SIGTERM RUN apk --no-cache add nodejs npm diff --git a/app/src/app/Contracts/Models/Storage.php b/app/src/app/Contracts/Models/Storage.php new file mode 100644 index 0000000..6245433 --- /dev/null +++ b/app/src/app/Contracts/Models/Storage.php @@ -0,0 +1,15 @@ +name; + } + + public function getIndex(): int + { + return $this->index; + } +} diff --git a/app/src/app/Dto/Service/Admin/Project/Index.php b/app/src/app/Dto/Service/Admin/Project/Index.php new file mode 100644 index 0000000..3e21631 --- /dev/null +++ b/app/src/app/Dto/Service/Admin/Project/Index.php @@ -0,0 +1,21 @@ +projectBuilderDto; + } +} diff --git a/app/src/app/Dto/Service/Admin/Project/Language.php b/app/src/app/Dto/Service/Admin/Project/Language.php new file mode 100644 index 0000000..c8fb97d --- /dev/null +++ b/app/src/app/Dto/Service/Admin/Project/Language.php @@ -0,0 +1,41 @@ +title; + } + + public function getCode(): string + { + return $this->code; + } + + public function getSort(): int + { + return $this->sort; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/app/src/app/Dto/Service/Admin/Project/Languages.php b/app/src/app/Dto/Service/Admin/Project/Languages.php new file mode 100644 index 0000000..66aefbe --- /dev/null +++ b/app/src/app/Dto/Service/Admin/Project/Languages.php @@ -0,0 +1,18 @@ +languages[] = $language; + } + + public function getLanguages(): array + { + return $this->languages; + } +} diff --git a/app/src/app/Dto/Service/Admin/Project/StoreUpdate.php b/app/src/app/Dto/Service/Admin/Project/StoreUpdate.php new file mode 100644 index 0000000..a84c2b0 --- /dev/null +++ b/app/src/app/Dto/Service/Admin/Project/StoreUpdate.php @@ -0,0 +1,48 @@ +name; + } + + public function getCode(): string + { + return $this->code; + } + + public function isPublic(): bool + { + return $this->isPublic; + } + + public function getLanguages(): Languages + { + return $this->languages; + } + + public function getHttpHost(): ?string + { + return $this->httpHost; + } + + public function getStorages(): Storages + { + return $this->storages; + } +} diff --git a/app/src/app/Dto/Service/Admin/Role/Index.php b/app/src/app/Dto/Service/Admin/Role/Index.php index cf38632..0be052c 100644 --- a/app/src/app/Dto/Service/Admin/Role/Index.php +++ b/app/src/app/Dto/Service/Admin/Role/Index.php @@ -3,22 +3,19 @@ namespace App\Dto\Service\Admin\Role; use App\Dto\Builder\Role; -use App\Dto\Service\Dto; +use App\Dto\Service\Pages; -final readonly class Index extends Dto +final readonly class Index extends Pages { public function __construct( private Role $roleBuilderDto, - private int $page - ) { } + int $page + ) { + parent::__construct($page); + } public function getRoleBuilderDto(): Role { return $this->roleBuilderDto; } - - public function getPage(): int - { - return $this->page; - } } diff --git a/app/src/app/Dto/Service/Admin/User/Index.php b/app/src/app/Dto/Service/Admin/User/Index.php index 1a0f25c..d59cedb 100644 --- a/app/src/app/Dto/Service/Admin/User/Index.php +++ b/app/src/app/Dto/Service/Admin/User/Index.php @@ -3,22 +3,19 @@ namespace App\Dto\Service\Admin\User; use App\Dto\Builder\User; -use App\Dto\Service\Dto; +use App\Dto\Service\Pages; -final readonly class Index extends Dto +final readonly class Index extends Pages { public function __construct( private User $userBuilderDto, - private int $page - ) { } + int $page + ) { + parent::__construct($page); + } public function getUserBuilderDto(): User { return $this->userBuilderDto; } - - public function getPage(): int - { - return $this->page; - } } diff --git a/app/src/app/Dto/Service/Pages.php b/app/src/app/Dto/Service/Pages.php new file mode 100644 index 0000000..80cc2c0 --- /dev/null +++ b/app/src/app/Dto/Service/Pages.php @@ -0,0 +1,15 @@ +page; + } +} diff --git a/app/src/app/Dto/Service/Storage/File.php b/app/src/app/Dto/Service/Storage/File.php new file mode 100644 index 0000000..f5ea6e3 --- /dev/null +++ b/app/src/app/Dto/Service/Storage/File.php @@ -0,0 +1,29 @@ +id < 1) { + throw new FileException('ID cannot be equal to or less than zero: ' . $this->id); + } + } + + public function getId(): int + { + return $this->id; + } + + public function getStorageType(): StorageType + { + return $this->storageType; + } +} diff --git a/app/src/app/Dto/Service/Storage/Storage.php b/app/src/app/Dto/Service/Storage/Storage.php new file mode 100644 index 0000000..dc53caa --- /dev/null +++ b/app/src/app/Dto/Service/Storage/Storage.php @@ -0,0 +1,57 @@ +storageType, + ); + } + $this->file = $file; + $this->isDelete = !empty($data['delete']); + } + + public function getFile(): ?File + { + return $this->file; + } + + public function isDelete(): bool + { + return $this->isDelete; + } + + public function isFile(): bool + { + return \is_null($this->file) === false; + } + + public function getStorageType(): StorageType + { + return $this->storageType; + } + + public function isMany(): bool + { + return $this->isMany; + } +} diff --git a/app/src/app/Dto/Service/Storage/Storages.php b/app/src/app/Dto/Service/Storage/Storages.php new file mode 100644 index 0000000..9367dd8 --- /dev/null +++ b/app/src/app/Dto/Service/Storage/Storages.php @@ -0,0 +1,73 @@ +storages[$type->value])) { + throw new StoragesException('You cannot attach two files of the same type!'); + } + $this->storages[$type->value] = new Storage(data: $data, storageType: $type, isMany: false); + } + + public function addMany(array $data, StorageType $type): void + { + if (!isset($this->storages[$type->value])) { + $this->storages[$type->value] = []; + } + + foreach ($data as $storageData) { + $this->storages[$type->value][] = new Storage(data: $storageData, storageType: $type, isMany: true); + } + } + + public function toArray(): array + { + return $this->storages; + } + + public function getAllStorageIds(): array + { + $ids = []; + foreach ($this->storages as $storage) { + /** @var Storage $storage */ + if (!is_array($storage)) { + if ($storage->isFile() && !$storage->isDelete()) { + $ids[] = $storage->getFile()->getId(); + } + continue; + } + foreach ($storage as $storageOne) { + /** @var Storage $storageOne */ + if ($storageOne->isFile() && !$storageOne->isDelete()) { + $ids[] = $storageOne->getFile()->getId(); + } + } + } + + return $ids; + } + + public function getAllStorages(): array + { + $storages = []; + foreach ($this->storages as $storage) { + if (!is_array($storage)) { + $storages[] = $storage; + continue; + } + foreach ($storage as $storageOne) { + $storages[] = $storageOne; + } + } + + return $storages; + } +} diff --git a/app/src/app/Dto/Service/Storage/Upload.php b/app/src/app/Dto/Service/Storage/Upload.php new file mode 100644 index 0000000..1b1ad9c --- /dev/null +++ b/app/src/app/Dto/Service/Storage/Upload.php @@ -0,0 +1,32 @@ +file; + } + + public function getStorageType(): StorageType + { + return $this->storageType; + } + + public function getMorph(): Morph + { + return $this->morph; + } +} diff --git a/app/src/app/Enums/Morph.php b/app/src/app/Enums/Morph.php new file mode 100644 index 0000000..59307c6 --- /dev/null +++ b/app/src/app/Enums/Morph.php @@ -0,0 +1,31 @@ + Project::class, + }; + } + + public function getFolderName(): string + { + return $this->name; + } + + public static function map(): array + { + $map = []; + foreach (self::cases() as $item) { + $map[$item->value] = $item->getPathModel(); + } + return $map; + } +} diff --git a/app/src/app/Enums/Permission.php b/app/src/app/Enums/Permission.php index 92295ba..ed427e9 100644 --- a/app/src/app/Enums/Permission.php +++ b/app/src/app/Enums/Permission.php @@ -7,6 +7,7 @@ enum Permission: string case AdminPanel = 'allow-admin-panel'; case Role = 'role'; case User = 'user'; + case Project = 'project'; public function getPermissions(): array { diff --git a/app/src/app/Enums/StorageType.php b/app/src/app/Enums/StorageType.php new file mode 100644 index 0000000..c6b0b94 --- /dev/null +++ b/app/src/app/Enums/StorageType.php @@ -0,0 +1,53 @@ + __('validation.attributes.logo'), + }; + } + + public function getAcceptMimes(): array + { + return match ($this) { + self::Logo => ['jpeg', 'jpg', 'png'], + }; + } + + public function isImage(): bool + { + return match ($this) { + self::Logo => true, + default => false + }; + } + + public function isVideo(): bool + { + return match ($this->name) { + default => false + }; + } + + public function isAudio(): bool + { + return match ($this->name) { + default => false + }; + } + + public function getFolderName(): string + { + return $this->name; + } +} diff --git a/app/src/app/Enums/SystemRole.php b/app/src/app/Enums/SystemRole.php index 4eb28ce..f13093b 100644 --- a/app/src/app/Enums/SystemRole.php +++ b/app/src/app/Enums/SystemRole.php @@ -5,4 +5,5 @@ namespace App\Enums; enum SystemRole: string { case Admin = 'admin'; + case AdminProject = 'admin-project'; } diff --git a/app/src/app/Exceptions/Dto/Storage/FileException.php b/app/src/app/Exceptions/Dto/Storage/FileException.php new file mode 100644 index 0000000..23f57b9 --- /dev/null +++ b/app/src/app/Exceptions/Dto/Storage/FileException.php @@ -0,0 +1,8 @@ +user()?->timezone ?? config('app.user_timezone'); } + + /** + * $name = 'field[key]' return 'field.key' + */ + public static function formatAttributeNameToRequestName(string $name): string + { + return Str::of($name) + ->replace( + ['.', '[', ']'], + ['_', '.', ''], + ) + ->rtrim('.') + ->value(); + } } diff --git a/app/src/app/Http/Controllers/Admin/LanguagesController.php b/app/src/app/Http/Controllers/Admin/LanguagesController.php new file mode 100644 index 0000000..4726f7c --- /dev/null +++ b/app/src/app/Http/Controllers/Admin/LanguagesController.php @@ -0,0 +1,25 @@ +languageService->newLanguage($request->getDto()); + if ($result->isError()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + + return response()->json(new NewLanguage($result)); + } +} diff --git a/app/src/app/Http/Controllers/Admin/ProjectsController.php b/app/src/app/Http/Controllers/Admin/ProjectsController.php new file mode 100644 index 0000000..a2253a2 --- /dev/null +++ b/app/src/app/Http/Controllers/Admin/ProjectsController.php @@ -0,0 +1,93 @@ +user(); + $data = $request->getDto(); + $querySettingsDto = new QuerySettingsDto( + limit: 20, + page: $data->getPage(), + queryWith: [] + ); + + $result = $this->projectService->index($data->getProjectBuilderDto(), $querySettingsDto, $user); + if ($result->isError()) { + $this->errors($result); + } + + return view('admin/projects/index', $result->getData()); + } + + public function create(Request $request): View + { + $user = $request->user(); + $result = $this->projectService->create($user); + if ($result->isError()) { + $this->errors($result); + } + + return view('admin/projects/create', $result->getData()); + } + + public function edit(int $id, Request $request): View + { + $user = $request->user(); + $result = $this->projectService->edit($id, $user); + if ($result->isError()) { + $this->errors($result); + } + + return view('admin/projects/edit', $result->getData()); + } + + public function store(StoreUpdateRequest $request): RedirectResponse + { + $data = $request->getDto(); + $user = $request->user(); + $result = $this->projectService->store($data, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('admin.projects.edit', $result->getModel())->withSuccess($result->getMessage()); + } + + public function update(int $id, StoreUpdateRequest $request): RedirectResponse + { + $data = $request->getDto(); + $user = $request->user(); + $result = $this->projectService->update($id, $data, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('admin.projects.edit', $result->getModel())->withSuccess($result->getMessage()); + } + + public function destroy(int $id, Request $request): RedirectResponse + { + $user = $request->user(); + $result = $this->projectService->destroy($id, $user); + if ($result->isError()) { + return redirect()->back()->withInput()->withErrors($result->getErrorsOrMessage()); + } + + return redirect()->route('admin.projects.index')->withSuccess($result->getMessage()); + } +} diff --git a/app/src/app/Http/Controllers/Admin/RolesController.php b/app/src/app/Http/Controllers/Admin/RolesController.php index eb9e48c..9632ea5 100644 --- a/app/src/app/Http/Controllers/Admin/RolesController.php +++ b/app/src/app/Http/Controllers/Admin/RolesController.php @@ -22,7 +22,7 @@ final class RolesController extends Controller $querySettingsDto = new QuerySettingsDto( limit: 20, page: $data->getPage(), - queryWith: [] + queryWith: ['morph'] ); $result = $this->roleService->index($data->getRoleBuilderDto(), $querySettingsDto, $user); diff --git a/app/src/app/Http/Controllers/Admin/UsersController.php b/app/src/app/Http/Controllers/Admin/UsersController.php index 252cdb6..4d13ec1 100644 --- a/app/src/app/Http/Controllers/Admin/UsersController.php +++ b/app/src/app/Http/Controllers/Admin/UsersController.php @@ -11,7 +11,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; -class UsersController extends Controller +final class UsersController extends Controller { public function __construct( private readonly UserService $userService diff --git a/app/src/app/Http/Controllers/Storage/Controller.php b/app/src/app/Http/Controllers/Storage/Controller.php new file mode 100644 index 0000000..08d304f --- /dev/null +++ b/app/src/app/Http/Controllers/Storage/Controller.php @@ -0,0 +1,10 @@ +getDto(); + $user = $request->user(); + $result = $this->imageService->uploadAndResize($data, $user); + + if (!$result->isSuccess()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + + return response()->json(new Upload($result->getStorage())); + } +} diff --git a/app/src/app/Http/Requests/Admin/Languages/NewLanguageRequest.php b/app/src/app/Http/Requests/Admin/Languages/NewLanguageRequest.php new file mode 100644 index 0000000..3c4f53e --- /dev/null +++ b/app/src/app/Http/Requests/Admin/Languages/NewLanguageRequest.php @@ -0,0 +1,29 @@ + ['required', 'numeric'], + 'name' => ['required', 'string'], + ]; + } + + public function getDto(): NewLanguage + { + return new NewLanguage( + name: $this->input('name'), + index: $this->input('index'), + ); + } +} diff --git a/app/src/app/Http/Requests/Admin/Projects/IndexRequest.php b/app/src/app/Http/Requests/Admin/Projects/IndexRequest.php new file mode 100644 index 0000000..2775201 --- /dev/null +++ b/app/src/app/Http/Requests/Admin/Projects/IndexRequest.php @@ -0,0 +1,30 @@ +redirect = route('admin.projects.index'); + return [ + 'page' => ['nullable', 'numeric', 'min:1'] + ]; + } + + public function getDto(): Index + { + return new Index( + projectBuilderDto: new Project(), + page: (int) $this->input('page', 1) + ); + } +} diff --git a/app/src/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php b/app/src/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php new file mode 100644 index 0000000..cf68e57 --- /dev/null +++ b/app/src/app/Http/Requests/Admin/Projects/StoreUpdateRequest.php @@ -0,0 +1,104 @@ + ['required', 'string', 'max:255'], + 'code' => ['required', 'string', 'min:3', 'max:255', 'regex:/^[a-z0-9_-]+$/i'], + 'http_host' => ['nullable', 'string', 'max:255', new HttpHost()], + 'is_public' => ['required', 'boolean'], + + 'logo.file' => ['nullable', 'numeric', 'min:1'], + 'logo.delete' => ['nullable', 'boolean'], + + 'languages.items.*.id' => ['nullable', 'numeric'], + 'languages.items.*.title' => ['required', 'string', 'max:255'], + 'languages.items.*.code' => ['required', 'string', 'min:2', 'max:30', 'regex:/^[a-zA-Z_]+$/i'], + 'languages.items.*.sort' => ['required', 'numeric', 'min:-1000', 'max:1000'], + 'languages.default' => ['required', 'numeric', function (string $attribute, mixed $value, \Closure $fail) { + $languages = $this->input('languages.items', []); + if (!isset($languages[$value])) { + $this->validator->getMessageBag()->add('languages.default', __('validation.required', ['attribute' => __('validation.attributes.language-default')])); + } + }], + 'languages.items' => ['required', 'array', function (string $attribute, mixed $value, \Closure $fail) { + $values = []; + foreach ($value as $index => $item) { + if (!isset($item['code'])) { + continue; + } + if (in_array($item['code'], $values)) { + $this->validator->getMessageBag()->add('languages.items.' . $index . '.code', __('validation.unique', ['attribute' => __('validation.attributes.language-code')])); + } + $values[] = $item['code']; + } + }], + ]; + } + + + public function getDto(): StoreUpdate + { + return new StoreUpdate( + name: $this->input('name'), + code: $this->input('code'), + isPublic: (bool) $this->input('is_public', false), + languages: $this->languages(), + storages: $this->storages(), + httpHost: $this->input('http_host', null), + ); + } + + private function languages(): Languages + { + $languages = new Languages(); + + $default = $this->input('languages.default', null); + if ($default !== null) { + $default = (int) $default; + } + + foreach ($this->input('languages.items', []) as $index => $lang) { + $languageId = $lang['id'] ?? null; + if ($languageId !== null) { + $languageId = (int) $languageId; + } + $language = new Language( + title: $lang['title'], + code: $lang['code'], + sort: (int) $lang['sort'], + isDefault: ($default === $index), + id: $languageId, + ); + $languages->addLanguage($language); + } + + return $languages; + } + + private function storages(): Storages + { + $storages = new Storages(); + + $logo = $this->get('logo', []); + $storages->add($logo, StorageType::Logo); + + return $storages; + } +} diff --git a/app/src/app/Http/Requests/Storage/ImageRequest.php b/app/src/app/Http/Requests/Storage/ImageRequest.php new file mode 100644 index 0000000..4255321 --- /dev/null +++ b/app/src/app/Http/Requests/Storage/ImageRequest.php @@ -0,0 +1,40 @@ +has('storage_type')) { + $storageType = StorageType::tryFrom((int) $this->input('storage_type')) ?? []; + $mimes = implode(',', $storageType->getAcceptMimes()); + } + + return [ + 'file' => ['required', 'file', 'mimes:' . $mimes], + 'storage_type' => ['required', 'numeric', new Enum(StorageType::class)], + 'morph' => ['required', 'numeric', new Enum(Morph::class)], + ]; + } + + public function getDto(): Upload + { + return new Upload( + file: $this->file('file'), + storageType: StorageType::from((int) $this->input('storage_type')), + morph: Morph::from((int) $this->input('morph')), + ); + } +} diff --git a/app/src/app/Http/Resources/Admin/Languages/NewLanguage.php b/app/src/app/Http/Resources/Admin/Languages/NewLanguage.php new file mode 100644 index 0000000..339ec1c --- /dev/null +++ b/app/src/app/Http/Resources/Admin/Languages/NewLanguage.php @@ -0,0 +1,26 @@ + view('components.volt.forms.languages.language', [ + 'index' => $this->resource->getIndex(), + 'name' => $this->resource->getName(), + 'lang' => $this->resource->getLanguage()->toArray(), + ])->render(), + ]; + } +} diff --git a/app/src/app/Http/Resources/Storage/Upload.php b/app/src/app/Http/Resources/Storage/Upload.php new file mode 100644 index 0000000..6f68446 --- /dev/null +++ b/app/src/app/Http/Resources/Storage/Upload.php @@ -0,0 +1,23 @@ + $this->resource->id, + 'url' => $this->resource->url, + ]; + } +} diff --git a/app/src/app/Models/Project.php b/app/src/app/Models/Project.php new file mode 100644 index 0000000..2f6db89 --- /dev/null +++ b/app/src/app/Models/Project.php @@ -0,0 +1,50 @@ + + */ + protected function casts(): array + { + return [ + 'is_public' => 'boolean', + ]; + } + + public function roles(): MorphMany + { + return $this->morphMany(Role::class, 'morph'); + } + + public function languages(): HasMany + { + return $this->hasMany(ProjectLanguage::class); + } +} diff --git a/app/src/app/Models/ProjectLanguage.php b/app/src/app/Models/ProjectLanguage.php new file mode 100644 index 0000000..a7b8f77 --- /dev/null +++ b/app/src/app/Models/ProjectLanguage.php @@ -0,0 +1,50 @@ + false, + 'sort' => 100, + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'title', + 'code', + 'is_default', + 'sort', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'is_default' => 'boolean', + 'sort' => 'integer', + ]; + } +} diff --git a/app/src/app/Models/Role.php b/app/src/app/Models/Role.php index d5c8395..9fcb7b0 100644 --- a/app/src/app/Models/Role.php +++ b/app/src/app/Models/Role.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\hasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; final class Role extends Model @@ -21,7 +22,7 @@ final class Role extends Model */ protected $fillable = [ 'name', - 'code' + 'code', ]; public function scopeLatest(Builder $query): Builder @@ -34,6 +35,25 @@ final class Role extends Model return $query->orderBy('name', 'asc'); } + protected function nameWithMorph(): Attribute + { + return Attribute::make( + get: function () { + $name = $this->name; + if ($this->morph_type) { + $name .= ' (' . __('admin-sections.Projects') . ': ' . $this->morph?->name . ')'; + } + + return $name; + }, + )->shouldCache(); + } + + public function morph(): MorphTo + { + return $this->morphTo('morph'); + } + public function permissions(): hasMany { return $this->hasMany(RolePermission::class, 'role_id', 'id'); @@ -49,7 +69,7 @@ final class Role extends Model protected function isAdmin(): Attribute { return Attribute::make( - get: fn () => ( $this->code === SystemRoleEnum::Admin->value ), + get: fn () => ( \is_null($this->morphable_type) && \is_null($this->morphable_id) && $this->code === SystemRoleEnum::Admin->value ), )->shouldCache(); } } diff --git a/app/src/app/Models/Scopes/SortScope.php b/app/src/app/Models/Scopes/SortScope.php new file mode 100644 index 0000000..ab632bc --- /dev/null +++ b/app/src/app/Models/Scopes/SortScope.php @@ -0,0 +1,18 @@ +orderBy('sort', 'asc'); + } +} diff --git a/app/src/app/Models/Storage.php b/app/src/app/Models/Storage.php new file mode 100644 index 0000000..f88055a --- /dev/null +++ b/app/src/app/Models/Storage.php @@ -0,0 +1,70 @@ + + */ + protected function casts(): array + { + return [ + 'data' => 'array', + 'type' => StorageType::class, + ]; + } + + protected function dataImages(): Attribute + { + return Attribute::make( + get: fn () => $this->data['images'] ?? [], + ); + } + + protected function url(): Attribute + { + return Attribute::make( + get: function () { + if (\is_null($this->file)) { + return ''; + } + return StorageSupport::disk(config('storage.disk'))->url($this->file) . '?time=' . $this->updated_at->getTimestamp(); + }, + ); + } + + protected function path(): Attribute + { + return Attribute::make( + get: function () { + if (\is_null($this->file)) { + return ''; + } + return StorageSupport::disk(config('storage.disk'))->path($this->file); + }, + ); + } +} diff --git a/app/src/app/Models/Traits/StorageTrait.php b/app/src/app/Models/Traits/StorageTrait.php new file mode 100644 index 0000000..60a16de --- /dev/null +++ b/app/src/app/Models/Traits/StorageTrait.php @@ -0,0 +1,26 @@ +morphMany(StorageModel::class, 'morph'); + } + + public function getStorageOne(StorageType $type): ?StorageModel + { + return $this->storage->firstWhere('type', $type); + } + + public function getStorageMany(StorageType $type): Collection + { + return $this->storage->where('type', $type); + } +} diff --git a/app/src/app/Models/User.php b/app/src/app/Models/User.php index efbaff5..bce95fc 100644 --- a/app/src/app/Models/User.php +++ b/app/src/app/Models/User.php @@ -12,7 +12,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\SoftDeletes; -class User extends Authenticatable +final class User extends Authenticatable { use HasFactory, Notifiable, SoftDeletes; @@ -59,11 +59,6 @@ class User extends Authenticatable return $this->belongsToMany(Role::class); } - public function hasRole(string $role): bool - { - return $this->roles->where('code', $role)->isNotEmpty(); - } - public function hasPermission(string $permission): bool { return $this->permissions->search($permission) !== false; @@ -72,7 +67,11 @@ class User extends Authenticatable protected function isAdmin(): Attribute { return Attribute::make( - get: fn () => $this->hasRole(SystemRole::Admin->value), + get: fn () => $this->roles + ->where('code', SystemRole::Admin->value) + ->whereNull('morphable_type') + ->whereNull('morphable_id') + ->isNotEmpty(), )->shouldCache(); } diff --git a/app/src/app/Policies/AdminPanel.php b/app/src/app/Policies/AdminPanel.php index 64c6550..d5ba2af 100644 --- a/app/src/app/Policies/AdminPanel.php +++ b/app/src/app/Policies/AdminPanel.php @@ -2,12 +2,15 @@ namespace App\Policies; +use App\Enums\SystemRole; use App\Models\User; readonly class AdminPanel extends Policy { public function view(User $user): bool { - return $user->hasPermission('allow-admin-panel.view'); + return + $user->hasPermission('allow-admin-panel.view') + || $user->roles->where('code', SystemRole::AdminProject->value)->isNotEmpty(); } } diff --git a/app/src/app/Policies/ProjectPolicy.php b/app/src/app/Policies/ProjectPolicy.php new file mode 100644 index 0000000..d4e4539 --- /dev/null +++ b/app/src/app/Policies/ProjectPolicy.php @@ -0,0 +1,43 @@ +hasPermission('project.view'); + } + + public function view(User $user, Project $project): bool + { + return $user->hasPermission('project.view'); + } + + public function create(User $user): bool + { + return $user->hasPermission('project.create'); + } + + public function update(User $user, Project $project): bool + { + return $user->hasPermission('project.update'); + } + + public function delete(User $user, Project $project): bool + { + return $user->hasPermission('project.delete'); + } + + public function upload(User $user): bool + { + if ($user->hasPermission('project.create') || $user->hasPermission('project.update')) { + return true; + } + + return false; + } +} diff --git a/app/src/app/Providers/AppServiceProvider.php b/app/src/app/Providers/AppServiceProvider.php index c368b53..cdf595d 100644 --- a/app/src/app/Providers/AppServiceProvider.php +++ b/app/src/app/Providers/AppServiceProvider.php @@ -2,9 +2,15 @@ namespace App\Providers; +use App\Enums\Morph; use App\Services\Search\CreateSearchInstanceCommand; use App\Services\Search\Search; +use App\Services\Storage\Image\ResizeCommandHandler; +use App\Services\Storage\ImageService; +use App\Services\Storage\StorageCommandHandler; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; @@ -22,6 +28,19 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(CreateSearchInstanceCommand::class, function () { return new CreateSearchInstanceCommand(Search::class); }); + + $this->app->bind(StorageCommandHandler::class, function () { + return new StorageCommandHandler(disc: (string) config('storage.disk')); + }); + + $this->app->bind(ImageService::class, function (Application $app) { + return new ImageService( + storageCommandHandler: $app->make(StorageCommandHandler::class), + resizeCommandHandler: $app->make(ResizeCommandHandler::class), + maxImageWidth: (int) config('storage.max_image_width', 4000), + maxImageHeight: (int) config('storage.max_image_height', 4000), + ); + }); } /** @@ -33,19 +52,9 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } - Password::defaults(function () { - $rule = Password::min(8); + $this->passwordDefaults(); - if ($this->app->isProduction()) { - $rule->letters() - ->mixedCase() - ->numbers() - ->symbols() - ->uncompromised(); - } - - return $rule; - }); + Relation::enforceMorphMap(Morph::map()); $this->configureRateLimiting(); Gate::define('AdminPanel', [\App\Policies\AdminPanel::class, 'view']); @@ -63,4 +72,21 @@ class AppServiceProvider extends ServiceProvider ]; }); } + + private function passwordDefaults(): void + { + Password::defaults(function () { + $rule = Password::min(8); + + if ($this->app->isProduction()) { + $rule->letters() + ->mixedCase() + ->numbers() + ->symbols() + ->uncompromised(); + } + + return $rule; + }); + } } diff --git a/app/src/app/Repositories/ProjectRepository.php b/app/src/app/Repositories/ProjectRepository.php new file mode 100644 index 0000000..a28e88e --- /dev/null +++ b/app/src/app/Repositories/ProjectRepository.php @@ -0,0 +1,49 @@ +builderCommand->execute( + query: Project::query()->with($with), + projectBuilderDto: $projectBuilderDto + ); + + return $this->createSearchInstanceCommand->execute($query); + } + + public function getProjectById(int $id): ?Project + { + return Project::query()->where('id', $id)->first(); + } + + public function getProjectByCode(string $code): ?Project + { + return Project::query()->where('code', $code)->first(); + } + + public function isExistsCode(string $code, ?int $exceptId = null): bool + { + return Project::query() + ->where('code', $code) + ->when($exceptId, function (Builder $query, int $exceptId) { + $query->where('id', '!=', $exceptId); + }) + ->withTrashed() + ->exists(); + } +} diff --git a/app/src/app/Repositories/RoleRepository.php b/app/src/app/Repositories/RoleRepository.php index 5a9dedf..2c587a0 100644 --- a/app/src/app/Repositories/RoleRepository.php +++ b/app/src/app/Repositories/RoleRepository.php @@ -38,18 +38,20 @@ final readonly class RoleRepository public function getRolesForSelect(array $withExcepts = []): array { - return Role::query() + $roles = Role::query() + ->with(['morph']) ->when($withExcepts, function (Builder $query, array $withExcepts) { $query->withTrashed()->whereNull('deleted_at')->orWhereIn('id', $withExcepts); - }) - ->pluck('name', 'id') - ->toArray(); + })->get(); + return $roles->pluck('name_with_morph', 'id')->toArray(); } public function isExistsCode(string $code, ?int $exceptId = null): bool { return Role::query() ->where('code', $code) + ->whereNull('morphable_type') + ->whereNull('morphable_id') ->when($exceptId, function (Builder $query, int $exceptId) { $query->where('id', '!=', $exceptId); }) diff --git a/app/src/app/Repositories/StorageRepository.php b/app/src/app/Repositories/StorageRepository.php new file mode 100644 index 0000000..8dab5e3 --- /dev/null +++ b/app/src/app/Repositories/StorageRepository.php @@ -0,0 +1,25 @@ +where('id', $id)->first(); + } + + public function getStoregsByIds(array $ids): Search + { + $query = Storage::query()->whereIn('id', $ids); + return $this->createSearchInstanceCommand->execute($query); + } +} diff --git a/app/src/app/Rules/HttpHost.php b/app/src/app/Rules/HttpHost.php new file mode 100644 index 0000000..1b1fed4 --- /dev/null +++ b/app/src/app/Rules/HttpHost.php @@ -0,0 +1,52 @@ +translate(['type' => 'string, array']); + return; + } + + if (is_string($value)) { + $this->validateHttpHost($value, $fail); + } + + if (is_array($value)) { + foreach ($value as $item) { + $this->validateHttpHost($item, $fail); + } + } + } + + private function validateHttpHost(string $value, Closure $fail): void + { + $scheme = explode('://', $value, 2); + if (count($scheme) != 2 || in_array($scheme[0], ['http', 'https'], true) !== true) { + $fail('validation.http_host')->translate(); + return; + } + + $host = explode(':', $scheme[1], 2); + if (!filter_var($host[0], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + $fail('validation.http_host')->translate(); + return; + } + + if (isset($host[1]) && (!is_numeric($host[1]) || $host[1] <= 0)) { + $fail('validation.http_host')->translate(); + return; + } + } +} diff --git a/app/src/app/ServiceResults/Admin/LanguageService/NewLanguageResult.php b/app/src/app/ServiceResults/Admin/LanguageService/NewLanguageResult.php new file mode 100644 index 0000000..fc1686b --- /dev/null +++ b/app/src/app/ServiceResults/Admin/LanguageService/NewLanguageResult.php @@ -0,0 +1,30 @@ +language; + } + + public function getName(): string + { + return $this->name; + } + + public function getIndex(): int + { + return $this->index; + } +} diff --git a/app/src/app/ServiceResults/Storage/SaveDeleteStorageResult.php b/app/src/app/ServiceResults/Storage/SaveDeleteStorageResult.php new file mode 100644 index 0000000..140aa63 --- /dev/null +++ b/app/src/app/ServiceResults/Storage/SaveDeleteStorageResult.php @@ -0,0 +1,48 @@ +getStorageType(); + if (!isset($this->storagesForDelete[$type->value])) { + $this->storagesForDelete[$type->value] = [ + 'ids' => [], + 'type' => $type, + 'isDeleteType' => ($storageDto->isMany() !== true), + ]; + } + + if ($storageDto->isFile()) { + $id = $storageDto->getFile()->getId(); + $this->storagesForDelete[$type->value]['ids'][] = $id; + } + } + + public function addSave(StorageModel $storage, StorageDto $storageDto): void + { + $this->storagesForSave[] = [ + 'storage' => $storage, + 'isMany' => $storageDto->isMany(), + ]; + } + + public function getStoragesForDelete(): array + { + return $this->storagesForDelete; + } + + public function getStoragesForSave(): array + { + return $this->storagesForSave; + } +} diff --git a/app/src/app/ServiceResults/Storage/UploadResult.php b/app/src/app/ServiceResults/Storage/UploadResult.php new file mode 100644 index 0000000..2627cd7 --- /dev/null +++ b/app/src/app/ServiceResults/Storage/UploadResult.php @@ -0,0 +1,18 @@ +storage; + } +} diff --git a/app/src/app/Services/Admin/LanguageService.php b/app/src/app/Services/Admin/LanguageService.php new file mode 100644 index 0000000..c800a43 --- /dev/null +++ b/app/src/app/Services/Admin/LanguageService.php @@ -0,0 +1,25 @@ +title = ""; + $language->code = ""; + + return new NewLanguageResult( + language: $language, + name: $data->getName(), + index: $data->getIndex(), + ); + } +} diff --git a/app/src/app/Services/Admin/ProjectService.php b/app/src/app/Services/Admin/ProjectService.php new file mode 100644 index 0000000..a2f4d28 --- /dev/null +++ b/app/src/app/Services/Admin/ProjectService.php @@ -0,0 +1,199 @@ +cannot('viewAny', Project::class)) { + return $this->errFobidden(__('Access is denied')); + } + + $projects = $this->projectRepository->getProjects( + $projectBuilderDto, + $querySettingsDto->getQueryWith() + )->pagination( + $querySettingsDto->getLimit(), + $querySettingsDto->getPage() + ); + + return $this->result([ + 'projects' => $projects, + ]); + } + + public function create(User $user): ServiceResultError | ServiceResultArray + { + if ($user->cannot('create', Project::class)) { + return $this->errFobidden(__('Access is denied')); + } + + $language = new ProjectLanguage(); + $language->is_default = true; + $language->title = $user->lang?->getTitle(); + $language->code = $user->lang?->getLocale(); + return $this->result([ + 'project' => new Project(), + 'languages' => collect([$language])->toArray(), + ]); + } + + public function edit(int $id, User $user): ServiceResultError | ServiceResultArray + { + $project = $this->projectRepository->getProjectById($id); + + if (is_null($project)) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('view', $project)) { + return $this->errFobidden(__('Access is denied')); + } + + return $this->result([ + 'project' => $project, + 'languages' => $project->languages->toArray(), + ]); + } + + public function store(StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult + { + if ($user->cannot('create', Project::class)) { + return $this->errFobidden(__('Access is denied')); + } + + if ($this->projectRepository->isExistsCode($data->getCode())) { + return $this->errValidate( + __('validation.unique', ['attribute' => __('validation.attributes.code')]), + ['code' => __('validation.unique', ['attribute' => __('validation.attributes.code')])] + ); + } + + $storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::Project); + if (!$storages->isSuccess()) { + return $storages; + } + + try { + $project = DB::transaction(function () use ($data, $user, $storages) { + $dataProject = $this->getDataProject($data); + + $project = $this->projectCommandHandler->handleStore($dataProject); + $this->createAdminRoleForProjectCommand->execute($project, $user); + $this->languageModelSyncCommand->execute($project, $data->getLanguages()); + $this->storageService->saveAndDelete($project, $storages); + + return $project; + }); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->resultStoreUpdateModel($project, __('The project was successfully created')); + } + + public function update(int $id, StoreUpdate $data, User $user): ServiceResultError | StoreUpdateResult + { + $project = $this->projectRepository->getProjectById($id); + + if (is_null($project)) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('update', $project)) { + return $this->errFobidden(__('Access is denied')); + } + + if ($this->projectRepository->isExistsCode($data->getCode(), $project->id)) { + return $this->errValidate( + __('validation.unique', ['attribute' => __('validation.attributes.code')]), + ['code' => __('validation.unique', ['attribute' => __('validation.attributes.code')])] + ); + } + + $storages = $this->storageService->getStoragesAndValidate($data->getStorages(), Morph::Project, $project->id); + if (!$storages->isSuccess()) { + return $storages; + } + + try { + $project = DB::transaction(function () use ($data, $project, $storages) { + $dataProject = $this->getDataProject($data); + + $project = $this->projectCommandHandler->handleUpdate($project, $dataProject); + $this->languageModelSyncCommand->execute($project, $data->getLanguages()); + $this->storageService->saveAndDelete($project, $storages); + + return $project; + }); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->resultStoreUpdateModel($project, __('The project was successfully updated')); + } + + public function destroy(int $id, User $user): ServiceResultError|ServiceResultSuccess + { + $project = $this->projectRepository->getProjectById($id); + + if (is_null($project)) { + return $this->errNotFound(__('Not Found')); + } + + if ($user->cannot('delete', $project)) { + return $this->errFobidden(__('Access is denied')); + } + + try { + DB::transaction(function () use ($project) { + $this->projectCommandHandler->handleDestroy($project); + }); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return $this->ok(__('The project has been deleted')); + } + + private function getDataProject(StoreUpdate $data): array + { + return [ + 'name' => $data->getName(), + 'code' => $data->getCode(), + 'http_host' => $data->getHttpHost(), + 'is_public' => $data->isPublic(), + ]; + } +} diff --git a/app/src/app/Services/Project/BuilderCommand.php b/app/src/app/Services/Project/BuilderCommand.php new file mode 100644 index 0000000..3bafbea --- /dev/null +++ b/app/src/app/Services/Project/BuilderCommand.php @@ -0,0 +1,15 @@ +update($data); + $project->touch(); + + return $project; + } + + public function handleDestroy(Project $project): void + { + $project->delete(); + } +} diff --git a/app/src/app/Services/ProjectLanguage/ModelSyncCommand.php b/app/src/app/Services/ProjectLanguage/ModelSyncCommand.php new file mode 100644 index 0000000..51816fb --- /dev/null +++ b/app/src/app/Services/ProjectLanguage/ModelSyncCommand.php @@ -0,0 +1,74 @@ +languages()->get(); + $this->deleteLanguages($languages, $projectLanguages); + + $languagesInsert = []; + $languagesUpdate = []; + foreach ($languages->getLanguages() as $language) { + /** @var Language $language */ + $data = [ + 'code' => $language->getCode(), + 'title' => $language->getTitle(), + 'sort' => $language->getSort(), + 'is_default' => $language->isDefault(), + ]; + + if ($language->getId() !== null) { + $projectLanguage = $projectLanguages->firstWhere('id', $language->getId()); + if ($projectLanguage !== null) { + + $projectLanguageFix = $projectLanguages->firstWhere('code', $language->getCode()); + if ($projectLanguageFix !== null && !in_array($projectLanguageFix->id, $languagesUpdate)) { + // Fixing a bug when language codes are swapped. + $projectLanguageFix->update([ + 'code' => $projectLanguageFix->code . '-' . $projectLanguageFix->id, + ]); + } + + $projectLanguage->update($data); + $languagesUpdate[] = $projectLanguage->id; + continue; + } + } + $languagesInsert[] = $data; + } + + if (!empty($languagesInsert)) { + $project->languages()->createMany($languagesInsert); + } + } + + private function deleteLanguages(Languages $languages, Collection $projectLanguages): void + { + $languagesIdUpdate = []; + foreach ($languages->getLanguages() as $language) { + /** @var Language $language */ + if ($language->getId() === null) { + continue; + } + $languagesIdUpdate[] = $language->getId(); + } + + foreach ($projectLanguages as $projectLanguage) { + if (in_array($projectLanguage->id, $languagesIdUpdate)) { + continue; + } + $projectLanguage->update([ + 'code' => $projectLanguage->code . '-' . $projectLanguage->id, + ]); + $projectLanguage->delete(); + } + } +} diff --git a/app/src/app/Services/Role/CreateAdminRoleForProjectCommand.php b/app/src/app/Services/Role/CreateAdminRoleForProjectCommand.php new file mode 100644 index 0000000..02cf493 --- /dev/null +++ b/app/src/app/Services/Role/CreateAdminRoleForProjectCommand.php @@ -0,0 +1,29 @@ + 'Administrator', + 'code' => SystemRole::AdminProject->value, + + ]; + $role = $project->roles()->create($data); + $this->userCommandHandler->attachRole($user, $role); + + return $role; + } +} diff --git a/app/src/app/Services/Storage/Image/ResizeCommandHandler.php b/app/src/app/Services/Storage/Image/ResizeCommandHandler.php new file mode 100644 index 0000000..ba0d285 --- /dev/null +++ b/app/src/app/Services/Storage/Image/ResizeCommandHandler.php @@ -0,0 +1,51 @@ +path); + if ($image->width() < $width && $image->height() < $height) { + return $storage; + } + + $image->scale($width, $height) + ->save($storage->path); + + $storage->touch(); + return $storage; + } + + public function width(Storage $storage, int $width): Storage + { + $image = InterventionImage::read($storage->path); + if ($image->width() < $width) { + return $storage; + } + + $image->scale(width: $width) + ->save($storage->path); + + $storage->touch(); + return $storage; + } + + public function height(Storage $storage, int $height): Storage + { + $image = InterventionImage::read($storage->path); + if ($image->height() < $height) { + return $storage; + } + + $image->scale(height: $height) + ->save($storage->path); + + $storage->touch(); + return $storage; + } +} diff --git a/app/src/app/Services/Storage/ImageService.php b/app/src/app/Services/Storage/ImageService.php new file mode 100644 index 0000000..d914952 --- /dev/null +++ b/app/src/app/Services/Storage/ImageService.php @@ -0,0 +1,65 @@ +cannot('upload', $upload->getMorph()->getPathModel())) { + return $this->errFobidden(__('Access is denied')); + } + + if ($upload->getStorageType()->isImage() !== true) { + return $this->errValidate(__('storage.Trying to upload a wrong image')); + } + + try { + $storage = DB::transaction(function () use ($upload, $user) { + $storage = $this->storageCommandHandler->handleStore( + upload: $upload, + user: $user, + ); + return $this->imageResize($storage, $this->maxImageWidth, $this->maxImageHeight); + }); + } catch (\Throwable $e) { + report($e); + return $this->errService(__('Server Error')); + } + + return new UploadResult($storage); + } + + private function imageResize(Storage $storage, ?int $width, ?int $height): Storage + { + if (empty($width) && empty($height)) { + return $storage; + } + + if (!empty($width) && !empty($height)) { + return $this->resizeCommandHandler->resize($storage, $width, $height); + } + + if (!empty($height)) { + return $this->resizeCommandHandler->height($storage, $height); + } + + return $this->resizeCommandHandler->width($storage, $width); + } +} diff --git a/app/src/app/Services/Storage/StorageCommandHandler.php b/app/src/app/Services/Storage/StorageCommandHandler.php new file mode 100644 index 0000000..7ebfac1 --- /dev/null +++ b/app/src/app/Services/Storage/StorageCommandHandler.php @@ -0,0 +1,91 @@ +getMorph()->getFolderName(); + $folder .= date("/Y/m/d"); + $folder .= '/' . $upload->getStorageType()->getFolderName(); + $folder = Str::lower($folder); + $path = $upload->getFile()->store($folder, $this->disc); + + if ($path === false) { + throw new StorageSaveFileException('Could not save the file: ' . $upload->getFile()->getFilename() . '.'); + } + + $storage = new Storage(); + $storage->created_user_id = $user->id; + $storage->file = '/' . $path; + $storage->type = $upload->getStorageType(); + $storage->morph_type = $upload->getMorph()->value; + + if (!empty($data)) { + $storage->fill($data); + } + $storage->save(); + + return $storage; + } + + public function handleStorageAttachModel(Storage $storage, ModelStorageContract $model): Storage + { + if ($storage->morph_id === $model->id) { + return $storage; + } + + if (!\is_null($storage->morph_id)) { + throw new StorageCommandHandlerException('The model is already attached!'); + } + + $storage->morph_id = $model->id; + $storage->save(); + + return $storage; + } + + public function handleUpdate(Storage $storage, array $data = []): Storage + { + $storage->update($data); + return $storage; + } + + public function handleDestroy(Storage $storage): void + { + $storage->delete(); + } + + public function handleDestroyByIds(array $ids): void + { + if (!empty($ids)) { + Storage::whereIn('id', $ids)->delete(); + } + } + + public function handleDestroyByModel(ModelStorageContract $model, StorageType $type): void + { + $model->storage()->where('type', $type)->delete(); + } + + public function handleDestroyByModelStorageIds(ModelStorageContract $model, array $ids, StorageType $type): void + { + if (!empty($ids)) { + $model->storage()->whereIn('id', $ids)->where('type', $type)->delete(); + } + } +} diff --git a/app/src/app/Services/Storage/StorageService.php b/app/src/app/Services/Storage/StorageService.php new file mode 100644 index 0000000..10417ad --- /dev/null +++ b/app/src/app/Services/Storage/StorageService.php @@ -0,0 +1,72 @@ +storageRepository->getStoregsByIds($storages->getAllStorageIds())->all(); + $result = new SaveDeleteStorageResult(); + + foreach ($storages->getAllStorages() as $storage) { + /** @var Storage $storage */ + if ($storage->isDelete()) { + $result->addDelete($storage); + continue; + } + if (!$storage->isFile()) { + continue; + } + + $storageModel = $storageModels->firstWhere('id', $storage->getFile()->getId()); + if (\is_null($storageModel)) { + return $this->errValidate(__('storage.File_not_found')); + } + $validate = $this->storageValidation->execute($storageModel, $morph, $storage->getStorageType(), $modelId); + if (!$validate->isSuccess()) { + return $this->errValidate($validate->getMessage()); + } + + $result->addSave($storageModel, $storage); + } + + return $result; + } + + public function saveAndDelete(ModelStorageContract $model, SaveDeleteStorageResult $storageResult): void + { + foreach ($storageResult->getStoragesForDelete() as $dataStorage) { + $type = $dataStorage['type']; + if ($dataStorage['isDeleteType']) { + $this->storageCommandHandler->handleDestroyByModel($model, $type); + } + if ($dataStorage['isDeleteType'] || empty($dataStorage['ids'])) { + continue; + } + $this->storageCommandHandler->handleDestroyByModelStorageIds($model, $dataStorage['ids'], $type); + } + foreach ($storageResult->getStoragesForSave() as $dataStorage) { + $storage = $dataStorage['storage']; + if ($dataStorage['isMany'] !== true && $storage->morph_id !== $model->id) { + $this->storageCommandHandler->handleDestroyByModel($model, $storage->type); + } + $this->storageCommandHandler->handleStorageAttachModel($storage, $model); + } + } +} diff --git a/app/src/app/Services/Storage/ValidationService.php b/app/src/app/Services/Storage/ValidationService.php new file mode 100644 index 0000000..835eb85 --- /dev/null +++ b/app/src/app/Services/Storage/ValidationService.php @@ -0,0 +1,48 @@ +isMorphMatched($storageModel, $morph, $modelId)) { + return $this->errValidate(__('storage.Attempt to replace a file')); + } + + if (! $this->isTypeMatched($storageModel, $storageType)) { + return $this->errValidate(__('storage.Wrong file type')); + } + + return $this->ok(__('OK')); + } + + private function isMorphMatched(StorageModel $storageModel, Morph $morph, ?int $modelId = null): bool + { + if ($storageModel->morph_type !== $morph->value) { + return false; + } + + if (!\is_null($storageModel->morph_id) && $storageModel->morph_id !== $modelId) { + return false; + } + + return true; + } + + private function isTypeMatched(StorageModel $storageModel, StorageType $type): bool + { + if ($storageModel->type !== $type) { + return false; + } + + return true; + } +} diff --git a/app/src/app/Services/User/UserCommandHandler.php b/app/src/app/Services/User/UserCommandHandler.php index ab83fb8..e39e4b5 100644 --- a/app/src/app/Services/User/UserCommandHandler.php +++ b/app/src/app/Services/User/UserCommandHandler.php @@ -3,6 +3,7 @@ namespace App\Services\User; use App\Dto\User\ManyRoleDto; +use App\Models\Role; use App\Models\User; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Hash; @@ -50,6 +51,16 @@ final readonly class UserCommandHandler $user->roles()->sync($roles->toArray()); } + public function attachRole(User $user, Role $role): void + { + $user->roles()->attach($role); + } + + public function detachRole(User $user, Role $role): void + { + $user->roles()->detach($user); + } + private function hashPassword(int|string $password): string { return Hash::make($password); diff --git a/app/src/app/View/Components/PrivateLayout.php b/app/src/app/View/Components/PrivateLayout.php index 467433d..063886a 100644 --- a/app/src/app/View/Components/PrivateLayout.php +++ b/app/src/app/View/Components/PrivateLayout.php @@ -2,6 +2,7 @@ namespace App\View\Components; +use Illuminate\Pagination\Paginator; use Illuminate\View\Component; use Illuminate\View\View; @@ -11,6 +12,8 @@ abstract class PrivateLayout extends Component public function render(): View { + Paginator::useBootstrapFive(); + return view('layout.private', [ 'navigation' => $this->getNavigation(), ]); diff --git a/app/src/app/View/Components/Volt/Forms/Checkbox.php b/app/src/app/View/Components/Volt/Forms/Checkbox.php index 5a7d809..b9f432f 100644 --- a/app/src/app/View/Components/Volt/Forms/Checkbox.php +++ b/app/src/app/View/Components/Volt/Forms/Checkbox.php @@ -30,12 +30,12 @@ final class Checkbox extends Form return (string) old($this->getRequestName(), $this->checkboxValue); } - public function getUserValue(): string + private function getUserValue(): string { return (string) $this->userValue; } - public function getNotCheckedValue(): ?string + private function getNotCheckedValue(): ?string { return $this->notCheckedValue; } diff --git a/app/src/app/View/Components/Volt/Forms/Form.php b/app/src/app/View/Components/Volt/Forms/Form.php index 99b79f8..02ecafd 100644 --- a/app/src/app/View/Components/Volt/Forms/Form.php +++ b/app/src/app/View/Components/Volt/Forms/Form.php @@ -2,7 +2,7 @@ namespace App\View\Components\Volt\Forms; -use Illuminate\Support\Str; +use App\Helpers\Helpers; use Illuminate\View\Component; use Illuminate\View\View; @@ -19,13 +19,7 @@ abstract class Form extends Component return $this->requestName; } - $this->requestName = Str::of($this->getName()) - ->replace( - ['.', '[', ']'], - ['_', '.', ''], - ) - ->rtrim('.') - ->value(); + $this->requestName = Helpers::formatAttributeNameToRequestName($this->getName()); return $this->requestName; } diff --git a/app/src/app/View/Components/Volt/Forms/Input.php b/app/src/app/View/Components/Volt/Forms/Input.php index 426cea8..6b65e04 100644 --- a/app/src/app/View/Components/Volt/Forms/Input.php +++ b/app/src/app/View/Components/Volt/Forms/Input.php @@ -11,7 +11,8 @@ final class Input extends Form private readonly string $title, private readonly string $name, private readonly string $type = 'text', - private readonly ?string $value = '' + private readonly ?string $value = '', + private readonly ?string $example = null, ) { } protected function getName(): string @@ -34,6 +35,11 @@ final class Input extends Form return (string) old($this->getRequestName(), $this->value); } + private function getExample(): ?string + { + return $this->example; + } + /** * @inheritDoc */ @@ -44,7 +50,8 @@ final class Input extends Form 'name' => $this->getName(), 'requestName' => $this->getRequestName(), 'type' => $this->getType(), - 'value' => $this->getValue() + 'value' => $this->getValue(), + 'example' => $this->getExample(), ]); } } diff --git a/app/src/app/View/Components/Volt/Forms/InputTypeHidden.php b/app/src/app/View/Components/Volt/Forms/InputTypeHidden.php new file mode 100644 index 0000000..4741d94 --- /dev/null +++ b/app/src/app/View/Components/Volt/Forms/InputTypeHidden.php @@ -0,0 +1,34 @@ +name; + } + + private function getValue(): ?string + { + return (string) old($this->getRequestName(), $this->value); + } + + /** + * @inheritDoc + */ + public function render(): View + { + return view('components.volt.forms.input-type-hidden', [ + 'name' => $this->getName(), + 'requestName' => $this->getRequestName(), + 'value' => $this->getValue(), + ]); + } +} diff --git a/app/src/app/View/Components/Volt/Forms/Languages.php b/app/src/app/View/Components/Volt/Forms/Languages.php new file mode 100644 index 0000000..a4c3e41 --- /dev/null +++ b/app/src/app/View/Components/Volt/Forms/Languages.php @@ -0,0 +1,54 @@ +name; + } + + private function getValue(): array + { + $value = old($this->getRequestName(), null); + if (\is_null($value)) { + return $this->value; + } + + $langs = []; + $default = $value['default'] ?? null; + if ($default !== null) { + $default = (int) $default; + } + foreach ($value['items'] as $index => $lang) { + $langs[$index] = [ + 'title' => $lang['title'] ?? '', + 'code' => $lang['code'] ?? '', + 'is_default' => ($index === $default), + 'sort' => $lang['sort'] ?? '', + 'id' => $lang['id'] ?? null, + ]; + } + + return $langs; + } + + /** + * @inheritDoc + */ + public function render(): View + { + return view('components.volt.forms.languages', [ + 'name' => $this->getName(), + 'value' => $this->getValue(), + ]); + } +} diff --git a/app/src/app/View/Components/Volt/Forms/PermissionsForRole.php b/app/src/app/View/Components/Volt/Forms/PermissionsForRole.php index 87aebfc..65cdbf2 100644 --- a/app/src/app/View/Components/Volt/Forms/PermissionsForRole.php +++ b/app/src/app/View/Components/Volt/Forms/PermissionsForRole.php @@ -22,26 +22,17 @@ final class PermissionsForRole extends Form return $this->name; } - /** - * @return string - */ private function getTitle(): string { return Str::ucfirst($this->title); } - /** - * @return array - */ private function getValue(): Collection { $value = old($this->getRequestName(), $this->value); return collect($value); } - /** - * @return Role - */ private function getRole(): Role { return $this->role; diff --git a/app/src/app/View/Components/Volt/Forms/Upload/Image.php b/app/src/app/View/Components/Volt/Forms/Upload/Image.php new file mode 100644 index 0000000..f63d313 --- /dev/null +++ b/app/src/app/View/Components/Volt/Forms/Upload/Image.php @@ -0,0 +1,75 @@ +name; + } + + private function getTitle(): string + { + return Str::ucfirst($this->title); + } + + private function getMorph(): Morph + { + return $this->morph; + } + + private function getStorageType(): ImageContract + { + return $this->storageType; + } + + private function getAcceptedFiles(): Collection + { + return collect($this->getStorageType()->getAcceptMimes()); + } + + private function getStorage(): ?Storage + { + $name = $this->getRequestName(); + $storageId = old($name . '.file'); + if (!empty($storageId)) { + return $this->storageRepository->getStorageById((int) $storageId); + } + + return $this->storage; + } + + /** + * @inheritDoc + */ + public function render(): View + { + return view('components.volt.forms.upload.image', [ + 'storage' => $this->getStorage(), + 'title' => $this->getTitle(), + 'name' => $this->getName(), + 'morph' => $this->getMorph(), + 'storageType' => $this->getStorageType(), + 'acceptedFiles' => $this->getAcceptedFiles(), + ]); + } +} diff --git a/app/src/bootstrap/providers.php b/app/src/bootstrap/providers.php index 38b258d..db4271b 100644 --- a/app/src/bootstrap/providers.php +++ b/app/src/bootstrap/providers.php @@ -1,5 +1,6 @@ false, ], + 'storage' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/storage'), + 'url' => env('APP_URL') . '/storage/storage', + 'visibility' => 'public', + 'throw' => true, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/app/src/config/image.php b/app/src/config/image.php new file mode 100644 index 0000000..858cdfa --- /dev/null +++ b/app/src/config/image.php @@ -0,0 +1,21 @@ + \Intervention\Image\Drivers\Gd\Driver::class + +]; diff --git a/app/src/config/storage.php b/app/src/config/storage.php new file mode 100644 index 0000000..01ac7d0 --- /dev/null +++ b/app/src/config/storage.php @@ -0,0 +1,9 @@ + 1000, + // to resize + 'max_image_height' => 1000, + // filesystems + 'disk' => 'storage', +]; diff --git a/app/src/database/migrations/2024_04_03_062346_create_projects.php b/app/src/database/migrations/2024_04_03_062346_create_projects.php new file mode 100644 index 0000000..d43d2d9 --- /dev/null +++ b/app/src/database/migrations/2024_04_03_062346_create_projects.php @@ -0,0 +1,106 @@ +id(); + $table->string('name')->index(); + $table->string('code')->unique(); + $table->string('http_host')->nullable()->unique()->comment('Scheme and Domain and Port (example: http://localhost:8080).'); + $table->boolean('is_public')->default(0)->index(); + $table->timestamps(); + $table->softDeletes()->index(); + $table->index('created_at'); + $table->index(['is_public', 'deleted_at']); + }); + + Schema::table('roles', function (Blueprint $table) { + $table->unsignedInteger('morph_type')->nullable(); + $table->unsignedBigInteger('morph_id')->nullable(); + $table->index(['morph_type', 'morph_id']); + $table->dropUnique(['code']); + $table->unique(['code', 'morph_type', 'morph_id']); + }); + + Schema::create('storage', function (Blueprint $table) { + $table->id(); + $table->string('file'); + $table->unsignedInteger('morph_type'); + $table->unsignedBigInteger('morph_id')->nullable()->index(); + $table->unsignedInteger('type')->comment('File type (main photo, video, gallery, etc.).'); + $table->json('data')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unsignedBigInteger('created_user_id')->nullable(); + $table->foreign('created_user_id')->references('id')->on('users'); + $table->index(['morph_type', 'morph_id']); + }); + + Schema::create('project_languages', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('project_id')->index(); + $table->foreign('project_id')->references('id')->on('projects'); + $table->string('code'); + $table->string('title'); + $table->boolean('is_default'); + $table->integer('sort')->index(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['project_id', 'deleted_at']); + $table->unique(['project_id', 'code']); + }); + + Schema::create('project_content', function (Blueprint $table) { + $table->id(); + $table->string('title')->index(); + $table->unsignedBigInteger('project_id')->index(); + $table->foreign('project_id')->references('id')->on('projects'); + $table->unsignedBigInteger('language_id')->index(); + $table->foreign('language_id')->references('id')->on('project_languages'); + $table->longText('description'); + $table->timestamps(); + $table->softDeletes(); + $table->index(['project_id', 'language_id', 'deleted_at']); + }); + + Schema::create('project_links', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->string('link'); + $table->unsignedBigInteger('project_id')->index(); + $table->foreign('project_id')->references('id')->on('projects'); + $table->unsignedBigInteger('language_id')->nullable()->index(); + $table->foreign('language_id')->references('id')->on('project_languages'); + $table->index(['project_id', 'language_id', 'deleted_at']); + $table->integer('sort')->index(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_links'); + Schema::dropIfExists('project_content'); + Schema::dropIfExists('project_languages'); + Schema::dropIfExists('storage'); + Schema::table('roles', function (Blueprint $table) { + $table->dropUnique(['code', 'morph_type', 'morph_id']); + $table->dropColumn('morph_type'); + $table->dropColumn('morph_id'); + $table->unique('code'); + }); + Schema::dropIfExists('projects'); + } +}; diff --git a/app/src/lang/en.json b/app/src/lang/en.json index b17acf4..431d292 100644 --- a/app/src/lang/en.json +++ b/app/src/lang/en.json @@ -238,5 +238,15 @@ "The group has been deleted": "The group has been deleted", "The user was successfully created": "The user was successfully created", "The user was successfully updated": "The user was successfully updated", - "The user has been deleted": "The user has been deleted" + "The user has been deleted": "The user has been deleted", + "example:": "example:", + "or": "or", + "The project was successfully created": "The project was successfully created", + "The project was successfully updated": "The project was successfully updated", + "The project has been deleted": "The project has been deleted", + "Languages": "Languages", + "Add language": "Add language", + "There was an error adding a language": "There was an error adding a language", + "Select images": "Select images", + "loading": "loading" } diff --git a/app/src/lang/en/admin-sections.php b/app/src/lang/en/admin-sections.php index 1f4179f..2a252ee 100644 --- a/app/src/lang/en/admin-sections.php +++ b/app/src/lang/en/admin-sections.php @@ -4,4 +4,5 @@ return [ 'User group' => 'User group', 'Users' => 'Users', 'AdminPanel' => 'Admin Panel', + 'Projects' => 'Projects', ]; diff --git a/app/src/lang/en/permissions.php b/app/src/lang/en/permissions.php index 4e1b331..a7ff33b 100644 --- a/app/src/lang/en/permissions.php +++ b/app/src/lang/en/permissions.php @@ -9,4 +9,5 @@ return [ 'Allowed to delete' => 'Allowed to delete', 'Administrative panel allowed' => 'Administrative panel allowed', 'AdminPanel' => 'Administrative panel allowed', + 'Project' => 'Projects', ]; diff --git a/app/src/lang/en/storage.php b/app/src/lang/en/storage.php new file mode 100644 index 0000000..a1d554b --- /dev/null +++ b/app/src/lang/en/storage.php @@ -0,0 +1,7 @@ + 'Trying to upload a wrong image!', + 'File_not_found' => 'File not found.', + 'Attempt to replace a file' => 'Attempt to replace a file.', + 'Wrong file type' => 'Wrong file type.', +]; diff --git a/app/src/lang/en/validation.php b/app/src/lang/en/validation.php index 0a85764..c8a8fc9 100644 --- a/app/src/lang/en/validation.php +++ b/app/src/lang/en/validation.php @@ -150,6 +150,7 @@ return [ 'uuid' => 'The :attribute must be a valid UUID.', 'no_type' => 'The :attribute can only use: :type.', 'captcha' => 'Failed to pass human verification.', + 'http_host' => 'The :attribute must be a valid domain.', 'attributes' => [ 'address' => 'address', 'affiliate_url' => 'affiliate URL', @@ -277,5 +278,19 @@ return [ 'permissions' => 'permissions', 'is_active' => 'is active', 'roles' => 'user group', + 'is_public' => 'public', + 'http_host' => 'hostname', + 'sort' => 'sorting', + 'is_default' => 'default', + 'language-code' => 'language code', + 'languages.items.*.code' => 'language code', + 'languages.items.*.title' => 'language title', + 'languages.items.*.sort' => 'language sorting', + 'languages.items.*.id' => 'language ID', + 'languages.default' => 'default language', + 'language-default' => 'default language', + 'logo' => 'logo', + 'logo.file' => 'logo file', + 'logo.delete' => 'remove logo', ], ]; diff --git a/app/src/lang/ru.json b/app/src/lang/ru.json index 031cc0f..07de063 100644 --- a/app/src/lang/ru.json +++ b/app/src/lang/ru.json @@ -238,5 +238,15 @@ "The group has been deleted": "Группа была удалена", "The user was successfully created": "Пользователь был успешно создан", "The user was successfully updated": "Пользователь был успешно обновлен", - "The user has been deleted": "Пользователь был удален" + "The user has been deleted": "Пользователь был удален", + "example:": "пример:", + "or": "или", + "The project was successfully created": "Проект был успешно создан", + "The project was successfully updated": "Проект был успешно обновлен", + "The project has been deleted": "Проект был удален", + "Languages": "Языки", + "Add language": "Добавить язык", + "There was an error adding a language": "Произошла ошибка при добавлении языка", + "Select images": "Выберите изображения", + "loading": "загрузка" } diff --git a/app/src/lang/ru/admin-sections.php b/app/src/lang/ru/admin-sections.php index 425be65..735c23d 100644 --- a/app/src/lang/ru/admin-sections.php +++ b/app/src/lang/ru/admin-sections.php @@ -4,4 +4,5 @@ return [ 'User group' => 'Группа пользователей', 'Users' => 'Пользователи', 'AdminPanel' => 'Админка', + 'Projects' => 'Проекты', ]; diff --git a/app/src/lang/ru/permissions.php b/app/src/lang/ru/permissions.php index 08b0305..5444134 100644 --- a/app/src/lang/ru/permissions.php +++ b/app/src/lang/ru/permissions.php @@ -10,4 +10,5 @@ return [ 'Allowed to delete' => 'Разрешено удалять', 'Administrative panel allowed' => 'Административная панель разрешена', 'AdminPanel' => 'Административная панель разрешена', + 'Project' => 'Проекты', ]; diff --git a/app/src/lang/ru/storage.php b/app/src/lang/ru/storage.php new file mode 100644 index 0000000..3364478 --- /dev/null +++ b/app/src/lang/ru/storage.php @@ -0,0 +1,7 @@ + 'Попытка загрузить не картинку!', + 'File_not_found' => 'Файл не найден.', + 'Attempt to replace a file' => 'Попытка подменить файл.', + 'Wrong file type' => 'Неправильный тип файла.', +]; diff --git a/app/src/lang/ru/validation.php b/app/src/lang/ru/validation.php index 3dd484b..22e30cc 100644 --- a/app/src/lang/ru/validation.php +++ b/app/src/lang/ru/validation.php @@ -150,6 +150,7 @@ return [ 'uuid' => 'Значение поля :attribute должно быть корректным UUID.', 'no_type' => 'Значение поля :attribute может использовать только: :type.', 'captcha' => 'Не удалось пройти проверку человеком.', + 'http_host' => 'Значение поля :attribute не является доменом или имеет некорректный формат.', 'attributes' => [ 'address' => 'адрес', 'affiliate_url' => 'Партнёрская ссылка', @@ -213,7 +214,7 @@ return [ 'minute' => 'минута', 'mobile' => 'моб. номер', 'month' => 'месяц', - 'name' => 'имя', + 'name' => 'название', 'national_code' => 'национальный код', 'number' => 'номер', 'password' => 'пароль', @@ -264,7 +265,7 @@ return [ 'test_name' => 'тестовое имя', 'text' => 'текст', 'time' => 'время', - 'title' => 'наименование', + 'title' => 'заголовок', 'type' => 'тип', 'updated_at' => 'обновлено в', 'user' => 'пользователь', @@ -277,5 +278,19 @@ return [ 'permissions' => 'разрешения', 'is_active' => 'активен', 'roles' => 'группа пользователей', + 'is_public' => 'публичный', + 'http_host' => 'имя хоста', + 'sort' => 'сортировка', + 'is_default' => 'по умолчанию', + 'language-code' => 'код языка', + 'languages.items.*.code' => 'код языка', + 'languages.items.*.title' => 'заголовок языка', + 'languages.items.*.sort' => 'языковая сортировка', + 'languages.items.*.id' => 'ID языка', + 'languages.default' => 'язык по умолчанию', + 'language-default' => 'язык по умолчанию', + 'logo' => 'логотип', + 'logo.file' => 'файл логотипа', + 'logo.delete' => 'удалить логотип', ], ]; diff --git a/app/src/package-lock.json b/app/src/package-lock.json index c5a1cc4..0d815f9 100644 --- a/app/src/package-lock.json +++ b/app/src/package-lock.json @@ -9,6 +9,7 @@ "bootstrap": "5.0.2", "chartist": "^0.11.4", "chartist-plugin-tooltips": "^0.0.17", + "dropzone": "^6.0.0-beta.2", "notyf": "^3.10.0", "nouislider": "^15.2.0", "onscreen": "^1.4.0", @@ -667,6 +668,11 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.2.14.tgz", + "integrity": "sha512-wpCQMhf5p5GhNg2MmGKXzUNwxe7zRiCsmqYsamez2beP7mKPCSiu+BjZcdN95yYSzO857kr0VfQewmGpS77nqA==" + }, "node_modules/@types/eslint": { "version": "8.56.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz", @@ -1153,6 +1159,15 @@ "node": ">=0.4.0" } }, + "node_modules/dropzone": { + "version": "6.0.0-beta.2", + "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-6.0.0-beta.2.tgz", + "integrity": "sha512-k44yLuFFhRk53M8zP71FaaNzJYIzr99SKmpbO/oZKNslDjNXQsBTdfLs+iONd0U0L94zzlFzRnFdqbLcs7h9fQ==", + "dependencies": { + "@swc/helpers": "^0.2.13", + "just-extend": "^5.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.722", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.722.tgz", @@ -1465,6 +1480,11 @@ "dev": true, "peer": true }, + "node_modules/just-extend": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", + "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==" + }, "node_modules/laravel-vite-plugin": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.0.2.tgz", diff --git a/app/src/package.json b/app/src/package.json index 04fbfe8..93712ee 100644 --- a/app/src/package.json +++ b/app/src/package.json @@ -10,6 +10,7 @@ "bootstrap": "5.0.2", "chartist": "^0.11.4", "chartist-plugin-tooltips": "^0.0.17", + "dropzone": "^6.0.0-beta.2", "notyf": "^3.10.0", "nouislider": "^15.2.0", "onscreen": "^1.4.0", diff --git a/app/src/resources/views/admin/layout/_navigation.blade.php b/app/src/resources/views/admin/layout/_navigation.blade.php index 2a01fac..146d8a4 100644 --- a/app/src/resources/views/admin/layout/_navigation.blade.php +++ b/app/src/resources/views/admin/layout/_navigation.blade.php @@ -9,6 +9,23 @@ {{ __('admin-sections.Dashboard') }} + +@can('viewAny', \App\Models\Project::class) +
  • request()->route()->named('admin.projects.*'), + ])> + + + + + {{ __('admin-sections.Projects') }} + +
  • +@endcan + @can('viewAny', \App\Models\User::class)
  • + + + + + + +@canany(['create', 'update'], $project) + +@endcanany diff --git a/app/src/resources/views/admin/projects/_top.blade.php b/app/src/resources/views/admin/projects/_top.blade.php new file mode 100644 index 0000000..c508b5d --- /dev/null +++ b/app/src/resources/views/admin/projects/_top.blade.php @@ -0,0 +1,8 @@ +@can('create', \App\Models\Project::class) + +@endcan diff --git a/app/src/resources/views/admin/projects/create.blade.php b/app/src/resources/views/admin/projects/create.blade.php new file mode 100644 index 0000000..ce44758 --- /dev/null +++ b/app/src/resources/views/admin/projects/create.blade.php @@ -0,0 +1,16 @@ +@section('meta_title', __('admin-sections.Projects')) +@section('h1', __('admin-sections.Projects')) + + @include('admin.projects._top') +
    +
    +
    +
    +
    + @include('admin.projects._from') +
    +
    +
    +
    +
    +
    diff --git a/app/src/resources/views/admin/projects/edit.blade.php b/app/src/resources/views/admin/projects/edit.blade.php new file mode 100644 index 0000000..eee7180 --- /dev/null +++ b/app/src/resources/views/admin/projects/edit.blade.php @@ -0,0 +1,17 @@ +@section('meta_title', __('admin-sections.Projects')) +@section('h1', __('admin-sections.Projects')) + + @include('admin.projects._top') +
    +
    +
    +
    +
    + @method('PUT') + @include('admin.projects._from') +
    +
    +
    +
    +
    +
    diff --git a/app/src/resources/views/admin/projects/index.blade.php b/app/src/resources/views/admin/projects/index.blade.php new file mode 100644 index 0000000..08ca5f1 --- /dev/null +++ b/app/src/resources/views/admin/projects/index.blade.php @@ -0,0 +1,58 @@ +@section('meta_title', __('admin-sections.Projects')) +@section('h1', __('admin-sections.Projects')) + + @include('admin.projects._top') +
    +
    +
    + + + + + + + + + + + @foreach($projects as $project) + + + + + + + @endforeach + +
    {{ __('validation.attributes.name') }}{{ __('validation.attributes.code') }}{{ __('validation.attributes.http_host') }}
    + + + + + {{ $project->name }} + + + {{ $project->code }} + + {{ $project->http_host }} + + @can('delete', $project) +
    + @csrf + @method('DELETE') + +
    + @endcan +
    + +
    +
    +
    + @push('scripts') + @include('admin._scripts._click-confirm', ['alert' => __('Do you want to delete?')]) + @endpush +
    diff --git a/app/src/resources/views/admin/roles/edit.blade.php b/app/src/resources/views/admin/roles/edit.blade.php index 26482ca..13b8ecd 100644 --- a/app/src/resources/views/admin/roles/edit.blade.php +++ b/app/src/resources/views/admin/roles/edit.blade.php @@ -3,6 +3,9 @@ @include('admin.roles._top')
    + @if($role->morph_type) +

    {{ $role->name_with_morph }}

    + @endif
    diff --git a/app/src/resources/views/admin/roles/index.blade.php b/app/src/resources/views/admin/roles/index.blade.php index b7f46e7..27ca4c0 100644 --- a/app/src/resources/views/admin/roles/index.blade.php +++ b/app/src/resources/views/admin/roles/index.blade.php @@ -21,7 +21,7 @@ - {{ $role->name }} + {{ $role->name_with_morph }} {{ $role->code }} diff --git a/app/src/resources/views/components/volt/forms/input-type-hidden.blade.php b/app/src/resources/views/components/volt/forms/input-type-hidden.blade.php new file mode 100644 index 0000000..fb97d15 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/input-type-hidden.blade.php @@ -0,0 +1,4 @@ + +@error($requestName) + {{ $message }} +@enderror diff --git a/app/src/resources/views/components/volt/forms/input.blade.php b/app/src/resources/views/components/volt/forms/input.blade.php index 2550e3a..f9794b2 100644 --- a/app/src/resources/views/components/volt/forms/input.blade.php +++ b/app/src/resources/views/components/volt/forms/input.blade.php @@ -1,7 +1,11 @@
    - + - @error($name) + @error($requestName) {{ $message }} @enderror
    diff --git a/app/src/resources/views/components/volt/forms/languages.blade.php b/app/src/resources/views/components/volt/forms/languages.blade.php new file mode 100644 index 0000000..fc5b6d0 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/languages.blade.php @@ -0,0 +1,94 @@ +
    +
    + +
    + + + + + + + + + + + + @foreach($value as $index => $lang) + + @include('components.volt.forms.languages.language', ['index' => $index, 'name' => $name, 'lang' => $lang]) + + @endforeach + + + + + + +
    {{ __('validation.attributes.title') }}{{ __('validation.attributes.code') }}{{ __('validation.attributes.sort') }}{{ __('validation.attributes.is_default') }}
    + + +
    +
    +
    +@pushOnce('scripts') + +@endPushOnce diff --git a/app/src/resources/views/components/volt/forms/languages/input.blade.php b/app/src/resources/views/components/volt/forms/languages/input.blade.php new file mode 100644 index 0000000..49d9ba7 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/languages/input.blade.php @@ -0,0 +1,4 @@ + +@error(\App\Helpers\Helpers::formatAttributeNameToRequestName($name)) + {{ $message }} +@enderror diff --git a/app/src/resources/views/components/volt/forms/languages/language.blade.php b/app/src/resources/views/components/volt/forms/languages/language.blade.php new file mode 100644 index 0000000..6f8b4a1 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/languages/language.blade.php @@ -0,0 +1,34 @@ + + @if(isset($lang['id'])) + + @endif + @include('components.volt.forms.languages.input', [ + 'value' => $lang['title'], + 'name' => $name . '[items][' .$index . '][title]', + 'type' => 'text', + ]) + + + @include('components.volt.forms.languages.input', [ + 'value' => $lang['code'], + 'name' => $name . '[items][' . $index . '][code]', + 'type' => 'text', + ]) + + + @include('components.volt.forms.languages.input', [ + 'value' => $lang['sort'], + 'name' => $name . '[items][' . $index . '][sort]', + 'type' => 'number', + ]) + + + @include('components.volt.forms.languages.radio', [ + 'value' => $index, + 'checked' => $lang['is_default'], + 'name' => $name . '[default]', + ]) + + + + diff --git a/app/src/resources/views/components/volt/forms/languages/radio.blade.php b/app/src/resources/views/components/volt/forms/languages/radio.blade.php new file mode 100644 index 0000000..9d52021 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/languages/radio.blade.php @@ -0,0 +1,4 @@ + +@error(\App\Helpers\Helpers::formatAttributeNameToRequestName($name)) + {{ $message }} +@enderror diff --git a/app/src/resources/views/components/volt/forms/select.blade.php b/app/src/resources/views/components/volt/forms/select.blade.php index 341fde5..59f05c7 100644 --- a/app/src/resources/views/components/volt/forms/select.blade.php +++ b/app/src/resources/views/components/volt/forms/select.blade.php @@ -6,7 +6,7 @@ @endforeach - @error($name) + @error($requestName) {{ $message }} @enderror
    diff --git a/app/src/resources/views/components/volt/forms/upload/image.blade.php b/app/src/resources/views/components/volt/forms/upload/image.blade.php new file mode 100644 index 0000000..415b7b7 --- /dev/null +++ b/app/src/resources/views/components/volt/forms/upload/image.blade.php @@ -0,0 +1,32 @@ +
    + +
    +
    id)) style="display: none;" @endif> +
    + + +
    +
    + + +
    + + + +
    diff --git a/app/src/resources/views/layout/private.blade.php b/app/src/resources/views/layout/private.blade.php index 45d9e5d..995457e 100644 --- a/app/src/resources/views/layout/private.blade.php +++ b/app/src/resources/views/layout/private.blade.php @@ -9,6 +9,7 @@ @vite('resources/volt/scss/app.scss') + diff --git a/app/src/resources/volt/images/brand/dark.png b/app/src/resources/volt/images/brand/dark.png deleted file mode 100644 index 2c5a026..0000000 Binary files a/app/src/resources/volt/images/brand/dark.png and /dev/null differ diff --git a/app/src/resources/volt/images/brand/light.png b/app/src/resources/volt/images/brand/light.png deleted file mode 100644 index f0eb3cd..0000000 Binary files a/app/src/resources/volt/images/brand/light.png and /dev/null differ diff --git a/app/src/resources/volt/js/app.js b/app/src/resources/volt/js/app.js index daaea29..e051352 100644 --- a/app/src/resources/volt/js/app.js +++ b/app/src/resources/volt/js/app.js @@ -3,3 +3,4 @@ import.meta.glob([ ]); import './bootstrap'; import './volt.js'; +import './image.js'; diff --git a/app/src/resources/volt/js/image.js b/app/src/resources/volt/js/image.js new file mode 100644 index 0000000..f6895f6 --- /dev/null +++ b/app/src/resources/volt/js/image.js @@ -0,0 +1,81 @@ +import Dropzone from "dropzone"; + +let elementImage = document.querySelectorAll('.upload-image'); +if (elementImage.length > 0) { + elementImage.forEach(addDropzoneImage); +} +function addDropzoneImage(element) { + let blockError = element.querySelector('.upload-image__error'), + blockProgressBar = element.querySelector('.progress-wrapper'), + progressBarText = blockProgressBar.querySelector('.upload-image__progressbar-text'), + progressBar = blockProgressBar.querySelector('.progress-bar'), + storageType = element.dataset.storageType, + morph = element.dataset.morph, + imageBlock = element.querySelector('.add-image'); + + imageBlock.querySelector('button.add-image__remove').addEventListener('click', (e) => { + element.querySelector('input.save').value = ''; + element.querySelector('input.delete').value = 1; + imageBlock.style.display = 'none'; + }); + + let myDropzone = new Dropzone(element.querySelector('button.upload-image__button'), { + url: "/storage/image/upload-resize", + uploadMultiple: false, + parallelUploads: 1, + maxFiles: 1, + acceptedFiles: element.dataset.acceptedFiles, + sending: function (file, response, formData) { + blockError.textContent = ''; + blockError.style.display = 'none'; + formData.append('_token', document.querySelector('meta[name="csrf-token"]').content); + formData.append('storage_type', storageType); + formData.append('morph', morph); + blockProgressBar.style.display = 'block'; + }, + thumbnail: function(file, dataUrl) { + + }, + addedfile: function(file) { + + }, + totaluploadprogress: function(progress) { + progressBarText.textContent = progress + '%'; + progressBar.style.width = progress + '%'; + progressBar.setAttribute('aria-valuenow', progress); + }, + complete: function(file) { + this.removeFile(file); + + progressBarText.textContent = '0%'; + progressBar.style.width = '0%'; + progressBar.setAttribute('aria-valuenow', 0); + blockProgressBar.style.display = 'none'; + }, + success: function (file, image) { + imageBlock.querySelector('img').setAttribute('src', image.url); + element.querySelector('input.save').value = image.id; + element.querySelector('input.delete').value = 0; + imageBlock.style.display = 'block'; + }, + error: function (file, response) { + let message = ''; + + if (response.errors) { + for (let errorKey in response.errors) { + message += response.errors[errorKey] + '
    ' + } + if(response.message) { + message += response.message + '
    ' + } + } else if(response.message) { + message += response.message + '
    ' + } else { + message += response + '
    ' + } + + blockError.innerHTML = message; + blockError.style.display = 'block'; + }, + }); +} diff --git a/app/src/resources/volt/scss/volt/components/_alerts.scss b/app/src/resources/volt/scss/volt/components/_alerts.scss index 6fd954d..b745fb6 100644 --- a/app/src/resources/volt/scss/volt/components/_alerts.scss +++ b/app/src/resources/volt/scss/volt/components/_alerts.scss @@ -7,4 +7,10 @@ span{ font-size: $alert-icon-font-size; } -} \ No newline at end of file +} +.alert__close { + position: absolute; + top: 0.4rem; + right: 0.4rem; + font-size: 0.7rem; +} diff --git a/app/src/resources/volt/scss/volt/components/_body.scss b/app/src/resources/volt/scss/volt/components/_body.scss index 002688a..f02f2b8 100644 --- a/app/src/resources/volt/scss/volt/components/_body.scss +++ b/app/src/resources/volt/scss/volt/components/_body.scss @@ -28,4 +28,14 @@ button:focus { label { font-weight: $font-weight-bold; margin-bottom:.5rem; -} \ No newline at end of file +} +.label__example { + color: #6b7280; + font-size: 85%; + display: inline-block; + font-style: italic; + + strong { + font-weight: 600; + } +} diff --git a/app/src/routes/web.php b/app/src/routes/web.php index 490b229..1107c8e 100644 --- a/app/src/routes/web.php +++ b/app/src/routes/web.php @@ -17,6 +17,10 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\UserLocale::class])- Route::put('users/{id}/password', [\App\Http\Controllers\Admin\UsersController::class, 'updatePassword'])->name('users.update-password')->where(['id' => '[0-9]+']); Route::resource('roles', \App\Http\Controllers\Admin\RolesController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['role' => '[0-9]+']); + + Route::resource('projects', \App\Http\Controllers\Admin\ProjectsController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['project' => '[0-9]+']); + + Route::post('languages/new-language', [\App\Http\Controllers\Admin\LanguagesController::class, 'newLanguage'])->name('new-language'); }); Route::prefix('profile')->as('profile.')->group(function () { @@ -26,5 +30,9 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\UserLocale::class])- Route::get('settings', [\App\Http\Controllers\Private\ProfileController::class, 'settings'])->name('settings'); Route::put('settings', [\App\Http\Controllers\Private\ProfileController::class, 'updateSettings'])->name('update-settings'); }); + + Route::prefix('storage')->as('storage.')->group(function () { + Route::post('image/upload-resize', [\App\Http\Controllers\Storage\ImagesController::class, 'uploadAndResize'])->name('image_upload_and_resize'); + }); });