Compare commits

..

No commits in common. "users" and "master" have entirely different histories.

78 changed files with 692 additions and 6234 deletions

View File

@ -2,8 +2,6 @@ PROJECT_NAME=js-manager
NGINX_PORT=8010 NGINX_PORT=8010
HMR_PORT=8080
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5433 DB_PORT=5433

View File

@ -1,7 +1,5 @@
up: docker-up up: docker-up
down: docker-down down: docker-down
reload: docker-down docker-up
restart: docker-restart
init: docker-down-clear docker-pull docker-build docker-up app-init app-db-seed init: docker-down-clear docker-pull docker-build docker-up app-init app-db-seed
docker-up: docker-up:
@ -13,9 +11,6 @@ docker-down:
docker-down-clear: docker-down-clear:
docker compose down -v --remove-orphans docker compose down -v --remove-orphans
docker-restart:
docker compose up -d --force-recreate
docker-pull: docker-pull:
docker compose pull docker compose pull
@ -24,8 +19,10 @@ docker-build:
app-init: app-init:
docker compose run --rm php composer install docker compose run --rm php composer install
docker compose run --rm php chown root:www-data -R storage/
docker compose run --rm php chmod 777 -R storage/
docker compose run --rm php cp .env.example .env docker compose run --rm php cp .env.example .env
docker compose run --rm php php artisan key:generate docker compose run --rm php php artisan key:generate
app-db-seed: app-db-seed:
docker compose run --rm php php artisan migrate --seed docker compose run --rm php-cli php artisan migrate --seed

View File

@ -18,7 +18,7 @@ services:
args: args:
- UID=${UID:-1000} - UID=${UID:-1000}
- GID=${GID:-1000} - GID=${GID:-1000}
- USER=${USER:-laravel} - USER=${USER-laravel}
volumes: volumes:
- ./src:/app - ./src:/app
working_dir: /app working_dir: /app
@ -31,26 +31,6 @@ services:
aliases: aliases:
- backend - backend
node:
image: node:22-alpine
container_name: ${PROJECT_NAME}-node
working_dir: /app
volumes:
- ./src:/app
- /app/node_modules
ports:
- "${HMR_PORT}:8080"
- "5173:5173"
restart: unless-stopped
networks:
back_net:
aliases:
- node
environment:
- NODE_ENV=development
command: sh -c "npm install && npm run dev"
nginx: nginx:
container_name: ${PROJECT_NAME}-nginx-local container_name: ${PROJECT_NAME}-nginx-local
build: build:

View File

@ -38,7 +38,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=redis CACHE_STORE=database
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1

View File

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'role_uuid' => Role::where('code', 'user')->first()->uuid
]);
}
}

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
class UserCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:user-create';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Создание нового пользователя';
/**
* Execute the console command.
*/
public function handle()
{
$username = $this->validateAsk('Введите имя пользователя', ['string|max:255']);
if ($this->confirm('Хотите создать пользователя со случайным паролем?')) {
$password = Str::random(8);
$this->info('Созданный пароль: ' . $password);
} else {
$password = $this->validateAsk('Введите пароль', ['string|min:8']);
}
$email = $this->validateAsk('Введите email', ['email']);
User::create([
'name' => $username,
'email' => $email,
'password' => bcrypt($password),
'email_verified_at' => Carbon::now(),
'role_uuid' => Role::where('code', 'user')->value('uuid'),
]);
$this->info('Пользователь создан!');
}
private function validateAsk(string $question, array $rules, bool $isSecret = false)
{
if ($isSecret) {
$value = $this->secret($question);
} else {
$value = $this->ask($question);
}
$validate = $this->validateInput($rules, $value);
if ($validate !== true) {
$this->error($validate);
$value = $this->validateAsk($question, $rules);
}
return $value;
}
private function validateInput($rules, $value)
{
$validator = Validator::make([key($rules) => $value], $rules);
if ($validator->fails()) {
return $validator->errors()->first(key($rules));
}
return true;
}
}

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Casts;
use App\Domain\Shared\ValueObjects\Uuid;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\UuidInterface;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\Creation\CreationContext;
use Spatie\LaravelData\Support\DataProperty;
class UuidCast implements CastsAttributes, Cast
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Uuid
{
if (is_null($value)) {
return null;
}
return Uuid::fromString($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes)
{
if (is_null($value)) {
return [$key => null];
}
if ($value instanceof UuidInterface) {
return [$key => $value->toString()];
}
return [$key => Uuid::fromString((string) $value)->toString()];
}
public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed
{
return Uuid::fromString((string) $value);
}
}

View File

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Exceptions;
use Illuminate\Contracts\Support\Arrayable;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class AppException extends Exception implements Arrayable
{
protected string $errorSlug;
public function __construct(
string $errorSlug,
string $message = "",
int $code = 0,
Throwable|null $previous = null
) {
$this->errorSlug = $errorSlug;
parent::__construct($message, $code, $previous);
}
public function getSlug(): string
{
return $this->errorSlug;
}
public function toArray()
{
$data = [
'timestamp' => Carbon::now(),
'error' => $this->getSlug(),
'status' => $this->getCode(),
'message' => $this->getMessage(),
];
if (!app()->environment('production') || config('app.debug')) {
$data['trace'] = $this->getTrace();
if ($prev = $this->getPrevious()) {
$data['previous'] = [
'code' => $prev->getCode(),
'message' => $prev->getMessage(),
'trace' => $prev->getTrace()
];
}
}
return $data;
}
public function render(): JsonResponse
{
return response()->json(
$this->toArray(),
$this->getCode() ?: 500
);
}
public static function new(
string $slug,
string $message,
int $code = 500,
Throwable|null $previous = null
): never {
throw new self($slug, $message, $code, $previous);
}
public static function serviceUnavailable(
string $message = 'Ошибка сервера',
Throwable|null $previous = null,
): never {
throw new self(
errorSlug: 'service_unavailable',
message: $message,
code: Response::HTTP_SERVICE_UNAVAILABLE,
previous: $previous
);
}
}

View File

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Repositories;
use App\Domain\Shared\Exceptions\AppException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
abstract class BaseRepository extends Model
{
public string $model;
final public function __construct()
{
if (!isset($this->model)) {
AppException::serviceUnavailable('model должен быть определён в репозитории');
}
if (!is_subclass_of($this->model, Model::class)) {
AppException::serviceUnavailable('Модель репозитория должна быть подклассом Illuminate\Database\Eloquent\Model');
}
parent::__construct();
}
public function __get($key)
{
AppException::serviceUnavailable('Невозможно получить свойство напрямую через репозиторий');
}
/**
* Подмена возвращаемого значения не репозиторий а модель
*
* @return Builder<static>
*/
public function newQueryWithoutScopes(): Builder
{
$calledClass = get_called_class();
$instance = new $calledClass();
$model = new $instance->model();
return $model->newQuery();
}
}

View File

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\ValueObjects;
use DomainException;
use Ramsey\Uuid\Uuid as RamseyUuid;
use Ramsey\Uuid\UuidInterface;
final readonly class Uuid
{
private function __construct(
private UuidInterface $value
) {
}
public static function generate(): self
{
return new self(RamseyUuid::uuid4());
}
public static function fromString(string $value): self
{
if (!RamseyUuid::isValid($value)) {
throw new DomainException(sprintf('Недопустимая строка UUID: %s', $value));
}
return new self(RamseyUuid::fromString($value));
}
public function toString(): string
{
return $this->value->toString();
}
public function equals(self $other): bool
{
return $this->value->equals($other);
}
public function __toString(): string
{
return $this->toString();
}
public static function validate(string|array $uuid): bool
{
$uuids = is_array($uuid) ? $uuid : [$uuid];
foreach ($uuids as $u) {
if (!RamseyUuid::isValid($u)) {
return false;
}
}
return true;
}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Actions;
use App\Domain\User\Data\ShowRequest;
use App\Domain\User\Data\ShowResponseData;
use App\Domain\User\Repositories\UserRepository;
use App\Models\User;
class ShowAction
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function execute(ShowRequest $request)
{
$result = $this->userRepository->with('role')->whereUuid($request->user_uuid)->firstOrFail();
return ShowResponseData::fromModel($result);
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Actions;
use App\Domain\User\Data\UpdateRequest;
use App\Domain\User\Repositories\UserRepository;
use Illuminate\Http\JsonResponse;
class UpdateAction
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function execute(UpdateRequest $request)
{
$user = $this->userRepository->whereUuid($request->user_uuid)->firstOrFail();
$user->update($request->getFilledFields());
}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class ShowRequest extends Data
{
public function __construct(
#[StringType, FromRouteParameter('user_uuid'), Exists('users', 'uuid')]
public readonly string $user_uuid,
) {
}
public static function attributes(...$args): array
{
return [
'user_uuid' => 'Идентификатор пользователя'
];
}
}

View File

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use App\Domain\User\Data\ValueObjects\RoleData;
use App\Models\User;
use Spatie\LaravelData\Data;
class ShowResponseData extends Data
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
public readonly string $email,
public readonly RoleData $role,
) {
}
public static function fromModel(User $model): self
{
return new self(
uuid: $model->uuid->toString(),
name: $model->name,
email: $model->email,
role: new RoleData(
uuid: $model->role->uuid->toString(),
name: $model->role->name,
)
);
}
}

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class UpdateRequest extends Data
{
public function __construct(
#[StringType, FromRouteParameter('user_uuid'), Exists('users', 'uuid')]
public readonly string $user_uuid,
#[StringType, Nullable]
public readonly ?string $name,
#[StringType, Email, Nullable]
public readonly ?string $email,
#[StringType, Nullable]
public readonly ?string $password,
) {
}
public function getFilledFields(): array
{
$fields = [
'name' => $this->name,
'email' => $this->email,
'password' => $this->password ? bcrypt($this->password) : null,
];
return array_filter($fields, fn($value) => $value !== null);
}
}

View File

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data\ValueObjects;
use Spatie\LaravelData\Data;
class RoleData extends Data
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
) {
}
}

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Repositories;
use App\Domain\Shared\Repositories\BaseRepository;
use App\Models\User;
class UserRepository extends BaseRepository
{
public string $model = User::class;
}

View File

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
class MenuHelper
{
public static function getMainNavItems(): array
{
return [
[
'icon' => 'dashboard',
'name' => 'Dashboard',
'path' => route('dashboard')
],
[
'icon' => 'user-profile',
'name' => 'User Profile',
'path' => auth()->check() ? route('users.show', ['user_uuid' => auth()->user()->uuid]) : '#'
]
];
}
public static function getOterItems(): array
{
return [];
}
public static function getMenuGroups(): array
{
return [
[
'title' => 'Menu',
'items' => self::getMainNavItems(),
],
[
'title' => 'Others',
'items' => self::getOterItems()
]
];
}
public static function isActive($path)
{
return request()->is($path, '/');
}
public static function getIconSvg($iconName)
{
$icons = [
'dashboard' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>Dashboard SVG Icon</title><path fill="currentColor" d="M24 21h2v5h-2zm-4-5h2v10h-2zm-9 10a5.006 5.006 0 0 1-5-5h2a3 3 0 1 0 3-3v-2a5 5 0 0 1 0 10"/><path fill="currentColor" d="M28 2H4a2.002 2.002 0 0 0-2 2v24a2.002 2.002 0 0 0 2 2h24a2.003 2.003 0 0 0 2-2V4a2.002 2.002 0 0 0-2-2m0 9H14V4h14ZM12 4v7H4V4ZM4 28V13h24l.002 15Z"/></svg>',
'user-profile' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>User-avatar SVG Icon</title><path fill="currentColor" d="M16 8a5 5 0 1 0 5 5a5 5 0 0 0-5-5m0 8a3 3 0 1 1 3-3a3.003 3.003 0 0 1-3 3"/><path fill="currentColor" d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2m-6 24.377V25a3.003 3.003 0 0 1 3-3h6a3.003 3.003 0 0 1 3 3v1.377a11.899 11.899 0 0 1-12 0m13.993-1.451A5.002 5.002 0 0 0 19 20h-6a5.002 5.002 0 0 0-4.992 4.926a12 12 0 1 1 15.985 0"/></svg>'
];
return $icons[$iconName] ?? '<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/></svg>';
}
}

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
class DashboardController extends Controller
{
public function index()
{
return view('dashboard');
}
}

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
class RoleController extends Controller
{
//
}

View File

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Domain\User\Actions\ShowAction;
use App\Domain\User\Actions\UpdateAction;
use App\Domain\User\Data\ShowRequest;
use App\Domain\User\Data\UpdateRequest;
use Illuminate\Contracts\View\View;
class UserController extends Controller
{
public function index(): View
{
return view('pages.user.index', []);
}
public function show(ShowRequest $request, ShowAction $action): View
{
$data = $action->execute($request);
return view('pages.user.show', ['data' => $data]);
}
public function update(UpdateRequest $request, UpdateAction $action)
{
$data = $action->execute($request);
return response()->json(['success' => true, 'message' => 'Profile updated successfully']);
}
}

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\Shared\Exceptions\AppException;
use Closure;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRoleMiddleware
{
public function __construct(
private readonly Guard $auth
) {
}
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @param string ...$roles
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
/** @var User|null $user */
$user = $this->auth->user();
if (!$user) {
AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED);
}
$userRole = $user->load('role')->role();
$hasRole = $userRole->whereIn('code', $roles)
->count();
if (!$hasRole) {
AppException::new('forbidden', 'Недостаточно прав');
}
return $next($request);
}
}

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BaseModel extends Model
{
use HasUuids;
use HasFactory;
protected static function boot()
{
parent::boot();
}
public function getKeyName(): string
{
return 'uuid';
}
public function getKeyType()
{
return 'string';
}
}

View File

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Domain\Shared\Casts\UuidCast;
use App\Domain\Shared\ValueObjects\Uuid;
/**
* @property-read Uuid $uuid
* @property string $name
* @property string $description
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Role extends BaseModel
{
protected $fillable = [
'name',
'code',
'description',
];
protected $casts = [
'uuid' => UuidCast::class,
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function users()
{
return $this->hasMany(User::class);
}
}

View File

@ -5,32 +5,15 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Domain\Shared\Casts\UuidCast;
use App\Domain\Shared\ValueObjects\Uuid;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/**
* @property Uuid $uuid
* @property string $name
* @property string $email
* @property string $password
* @property string $role_uuid
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Role $role
*/
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory; use HasFactory;
use Notifiable; use Notifiable;
use HasUuids;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -41,7 +24,6 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'role_uuid',
]; ];
/** /**
@ -62,21 +44,8 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'uuid' => UuidCast::class,
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'uuid';
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'role_uuid', 'uuid');
}
} }

View File

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if (class_exists(Fortify::class)) {
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
// Custom routes defined specifically
Fortify::registerView(function () {
return view('auth.register');
});
Fortify::loginView(function () {
return view('pages.auth.login');
});
Fortify::verifyEmailView(function () {
return view('auth.verify-mail');
});
Fortify::requestPasswordResetLinkView(function () {
return view('auth.forgot-password');
});
Fortify::resetPasswordView(function (Request $request) {
return view('auth.reset-password', ['request' => $request]);
});
Fortify::confirmPasswordView(function () {
return view('auth.confirm-password');
});
Fortify::twoFactorChallengeView(function () {
return view('auth.two-factor-challange');
});
}
}
}

View File

@ -2,7 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Middleware\CheckRoleMiddleware;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@ -14,9 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([ //
'role' => CheckRoleMiddleware::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@ -4,5 +4,4 @@ declare(strict_types=1);
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
]; ];

View File

@ -7,10 +7,8 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/fortify": "^1.34",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1"
"spatie/laravel-data": "^4.19"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

1343
src/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => class_exists(Features::class) ? [
Features::registration(),
Features::resetPasswords(),
// Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
] : [],
];

View File

@ -1,25 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Role>
*/
class RoleFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->word(),
'code' => fake()->unique()->word(),
'description' => fake()->sentence(),
];
}
}

View File

@ -2,7 +2,6 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Role;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -30,7 +29,6 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'role_uuid' => Role::factory()->create()->uuid,
]; ];
} }

View File

@ -12,19 +12,28 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('users', function (Blueprint $table) { Schema::create('users', function (Blueprint $table) {
$table->uuid()->primary(); $table->id();
$table->uuid('role_uuid');
$table->string('name'); $table->string('name');
$table->string('email')->unique(); $table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password'); $table->string('password');
$table->rememberToken(); $table->rememberToken();
$table->timestamps(); $table->timestamps();
});
$table->foreign('role_uuid') Schema::create('password_reset_tokens', function (Blueprint $table) {
->references('uuid') $table->string('email')->primary();
->on('roles') $table->string('token');
->onDelete('restrict'); $table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
}); });
} }

View File

@ -1,30 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->uuid()->primary();
$table->string('name')->unique();
$table->string('code')->unique();
$table->string('description');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('roles');
}
};

View File

@ -1,42 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -16,9 +15,11 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$this->call([ // User::factory(10)->create();
RoleSeeder::class,
UserSeeder::class, User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]); ]);
} }
} }

View File

@ -1,35 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Role;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class RoleSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$roles = [
'admin' => [
'name' => 'Admin',
'code' => 'admin',
'description' => 'Admin role',
],
'user' => [
'name' => 'User',
'code' => 'user',
'description' => 'User role',
],
];
DB::transaction(static function() use ($roles) {
$codes = collect($roles)->pluck('code');
Role::whereNotIn('code', $codes)->delete();
Role::upsert($roles, ['code'], ['name','description']);
});
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Role;
use App\Models\User;
use Faker\Factory;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$faker = Factory::create();
$defaultUser = User::where('name', 'Admin')->first();
if (!$defaultUser) {
User::factory()->create([
'name' => 'Admin',
'role_uuid' => Role::where('code', 'admin')->first()->uuid,
'email' => 'admin@example.com',
'password' => bcrypt('qwaszxedc'),
]);
}
}
}

View File

@ -1,16 +0,0 @@
{
"Phone": "Телефон",
"Log in": "Войти",
"Profile": "Профиль",
"User Profile": "Профиль Пользователя",
"Edit": "Редактировать",
"Personal Information": "Персональная информация",
"Update your details to keep your profile up-to-date.": "Обновите свои данные, чтобы поддерживать актуальность вашего профиля.",
"Name": "Имя",
"Email Address": "Адрес электронной почты",
"Sign Up": "Зарегистрироваться",
"Reset Password": "Сбросить пароль",
"Password": "Пароль",
"Reset": "Сбросить",
"Verification link sent! ": ""
}

View File

@ -1,4 +0,0 @@
<?php
return [
'failed' => 'Неверный логин или пароль',
];

View File

@ -1,17 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
return [
'next' => 'Вперёд &raquo;',
'previous' => '&laquo; Назад',
];

View File

@ -1,22 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'reset' => 'Ваш пароль был сброшен.',
'sent' => 'Мы отправили вам по электронной почте ссылку для сброса пароля.',
'throttled' => 'Пожалуйста, подождите, прежде чем повторить попытку.',
'token' => 'Этот токен сброса пароля недействителен.',
'user' => "Пользователь с таким адресом электронной почты не найден.",
];

View File

@ -1,137 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
return [
'accepted' => 'Вы должны принять ":attribute".',
'accepted_if' => 'Вы должны принять ":attribute", когда :other соответствует :value.',
'active_url' => 'Значение поля ":attribute" не является действительным URL.',
'after' => 'Значение поля ":attribute" должно быть датой после :date.',
'after_or_equal' => 'Значение поля ":attribute" должно быть датой после или равной :date.',
'alpha' => 'Значение поля ":attribute" может содержать только буквы.',
'alpha_dash' => 'Значение поля ":attribute" может содержать только буквы, цифры, дефис и нижнее подчеркивание.',
'alpha_num' => 'Значение поля ":attribute" может содержать только буквы и цифры.',
'array' => 'Значение поля ":attribute" должно быть массивом.',
'before' => 'Значение поля ":attribute" должно быть датой до :date.',
'before_or_equal' => 'Значение поля ":attribute" должно быть датой до или равной :date.',
'between' => [
'array' => 'Количество элементов в поле ":attribute" должно быть между :min и :max.',
'file' => 'Размер файла в поле ":attribute" должен быть между :min и :max Килобайт(а).',
'numeric' => 'Значение поля ":attribute" должно быть между :min и :max.',
'string' => 'Количество символов в поле ":attribute" должно быть между :min и :max.',
],
'boolean' => 'Значение поля ":attribute" должно быть логического типа.',
'confirmed' => 'Значение поля ":attribute" не совпадает с подтверждаемым.',
'current_password' => 'Неверный пароль.',
'date' => 'Значение поля ":attribute" не является датой.',
'date_equals' => 'Значение поля ":attribute" должно быть датой равной :date.',
'date_format' => 'Значение поля ":attribute" не соответствует формату даты :format.',
'declined' => 'Поле ":attribute" должно быть отклонено.',
'declined_if' => 'Поле ":attribute" должно быть отклонено, когда :other равно :value.',
'different' => 'Значения полей ":attribute" и :other должны различаться.',
'digits' => 'Длина значения цифрового поля ":attribute" должна быть :digits.',
'digits_between' => 'Длина значения цифрового поля ":attribute" должна быть между :min и :max.',
'dimensions' => 'Изображение в поле ":attribute" имеет недопустимые размеры.',
'distinct' => 'Значения поля ":attribute" не должны повторяться.',
'email' => 'Значение поля ":attribute" должно быть действительным электронным адресом.',
'ends_with' => 'Поле ":attribute" должно заканчиваться одним из следующих значений: :values',
'enum' => 'Выбранное значение для ":attribute" некорректно.',
'exists' => 'Выбранное значение для ":attribute" некорректно.',
'file' => 'В поле ":attribute" должен быть указан файл.',
'filled' => 'Поле ":attribute" обязательно для заполнения.',
'gt' => [
'array' => 'Количество элементов в поле ":attribute" должно быть больше :value.',
'file' => 'Размер файла в поле ":attribute" должен быть больше :value Килобайт(а).',
'numeric' => 'Значение поля ":attribute" должно быть больше :value.',
'string' => 'Количество символов в поле ":attribute" должно быть больше :value.',
],
'gte' => [
'array' => 'Количество элементов в поле ":attribute" должно быть :value или больше.',
'file' => 'Размер файла в поле ":attribute" должен быть :value Килобайт(а) или больше.',
'numeric' => 'Значение поля ":attribute" должно быть :value или больше.',
'string' => 'Количество символов в поле ":attribute" должно быть :value или больше.',
],
'image' => 'Файл в поле ":attribute" должен быть изображением.',
'in' => 'Выбранное значение для ":attribute" некорректно.',
'in_array' => 'Значение поля ":attribute" не существует в :other.',
'integer' => 'Значение поля ":attribute" должно быть целым числом.',
'ip' => 'Значение поля ":attribute" должно быть действительным IP-адресом.',
'ipv4' => 'Значение поля ":attribute" должно быть действительным IPv4-адресом.',
'ipv6' => 'Значение поля ":attribute" должно быть действительным IPv6-адресом.',
'json' => 'Значение поля ":attribute" должно быть JSON строкой.',
'lt' => [
'array' => 'Количество элементов в поле ":attribute" должно быть меньше :value.',
'file' => 'Размер файла в поле ":attribute" должен быть меньше :value Килобайт(а).',
'numeric' => 'Значение поля ":attribute" должно быть меньше :value.',
'string' => 'Количество символов в поле ":attribute" должно быть меньше :value.',
],
'lte' => [
'array' => 'Количество элементов в поле ":attribute" должно быть :value или меньше.',
'file' => 'Размер файла в поле ":attribute" должен быть :value Килобайт(а) или меньше.',
'numeric' => 'Значение поля ":attribute" должно быть :value или меньше.',
'string' => 'Количество символов в поле ":attribute" должно быть :value или меньше.',
],
'mac_address' => 'Значение поля ":attribute" должно быть корректным MAC-адресом.',
'max' => [
'array' => 'Количество элементов в поле ":attribute" не может превышать :max.',
'file' => 'Размер файла в поле ":attribute" не может быть больше :max Килобайт(а).',
'numeric' => 'Значение поля ":attribute" не может быть больше :max.',
'string' => 'Количество символов в поле ":attribute" не может превышать :max.',
],
'mimes' => 'Файл в поле ":attribute" должен быть одного из следующих типов: :values.',
'mimetypes' => 'Файл в поле ":attribute" должен быть одного из следующих типов: :values.',
'min' => [
'array' => 'Количество элементов в поле ":attribute" должно быть не меньше :min.',
'file' => 'Размер файла в поле ":attribute" должен быть не меньше :min Килобайт(а).',
'numeric' => 'Значение поля ":attribute" должно быть не меньше :min.',
'string' => 'Количество символов в поле ":attribute" должно быть не меньше :min.',
],
'multiple_of' => 'Значение поля ":attribute" должно быть кратным :value',
'not_in' => 'Выбранное значение для ":attribute" некорректно.',
'not_regex' => 'Значение поля ":attribute" некорректно.',
'numeric' => 'Значение поля ":attribute" должно быть числом.',
'password' => 'Некорректный пароль.',
'present' => 'Значение поля ":attribute" должно присутствовать.',
'prohibited' => 'Значение поля ":attribute" запрещено.',
'prohibited_if' => 'Значение поля ":attribute" запрещено, когда :other равно :value.',
'prohibited_unless' => 'Значение поля ":attribute" запрещено, если :other не состоит в :values.',
'prohibits' => 'Значение поля ":attribute" запрещает присутствие :other.',
'regex' => 'Значение поля ":attribute" некорректно.',
'required' => 'Поле ":attribute" обязательно для заполнения.',
'required_array_keys' => 'Массив в поле ":attribute" обязательно должен иметь ключи: :values',
'required_if' => 'Поле ":attribute" обязательно для заполнения, когда ":other" равно :value.',
'required_unless' => 'Поле ":attribute" обязательно для заполнения, когда ":other" не равно :values.',
'required_with' => 'Поле ":attribute" обязательно для заполнения, когда :values указано.',
'required_with_all' => 'Поле ":attribute" обязательно для заполнения, когда :values указано.',
'required_without' => 'Поле ":attribute" обязательно для заполнения, когда :values не указано.',
'required_without_all' => 'Поле ":attribute" обязательно для заполнения, когда ни одно из :values не указано.',
'same' => 'Значения полей ":attribute" и ":other" должны совпадать.',
'size' => [
'array' => 'Количество элементов в поле ":attribute" должно быть равным :size.',
'file' => 'Размер файла в поле ":attribute" должен быть равен :size Килобайт(а).',
'numeric' => 'Значение поля ":attribute" должно быть равным :size.',
'string' => 'Количество символов в поле ":attribute" должно быть равным :size.',
],
'starts_with' => 'Поле ":attribute" должно начинаться с одного из следующих значений: :values',
'string' => 'Значение поля ":attribute" должно быть строкой.',
'timezone' => 'Значение поля ":attribute" должно быть действительным часовым поясом.',
'unique' => 'Такое значение поля ":attribute" уже существует.',
'uploaded' => 'Загрузка поля ":attribute" не удалась.',
'url' => 'Значение поля ":attribute" имеет ошибочный формат URL.',
'uuid' => 'Значение поля ":attribute" должно быть корректным UUID.',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'cron' => 'Значение поля ":attribute" не корректно.'
];

2448
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,5 @@
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"alpinejs": "^3.15.8"
} }
} }

View File

@ -5,15 +5,6 @@
], ],
"rules": { "rules": {
"declare_strict_types": true, "declare_strict_types": true,
"concat_space": {"spacing": "one"}, "concat_space": {"spacing": "one"}
"braces_position": {
"control_structures_opening_brace": "same_line",
"functions_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_functions_opening_brace": "same_line",
"classes_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end",
"allow_single_line_empty_anonymous_classes": false,
"allow_single_line_anonymous_functions": false
}
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 0 B

View File

@ -6,282 +6,6 @@
@source '../**/*.js'; @source '../**/*.js';
@theme { @theme {
--font-*: initial; --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
--font-outfit: Outfit, sans-serif; 'Segoe UI Symbol', 'Noto Color Emoji';
--breakpoint-*: initial;
--breakpoint-2xsm: 375px;
--breakpoint-xsm: 425px;
--breakpoint-3xl: 2000px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
--text-title-2xl: 72px;
--text-title-2xl--line-height: 90px;
--text-title-xl: 60px;
--text-title-xl--line-height: 72px;
--text-title-lg: 48px;
--text-title-lg--line-height: 60px;
--text-title-md: 36px;
--text-title-md--line-height: 44px;
--text-title-sm: 30px;
--text-title-sm--line-height: 38px;
--text-theme-xl: 20px;
--text-theme-xl--line-height: 30px;
--text-theme-sm: 14px;
--text-theme-sm--line-height: 20px;
--text-theme-xs: 12px;
--text-theme-xs--line-height: 18px;
--color-current: currentColor;
--color-transparent: transparent;
--color-white: #ffffff;
--color-black: #101828;
--color-brand-25: #f2f7ff;
--color-brand-50: #ecf3ff;
--color-brand-100: #dde9ff;
--color-brand-200: #c2d6ff;
--color-brand-300: #9cb9ff;
--color-brand-400: #7592ff;
--color-brand-500: #465fff;
--color-brand-600: #3641f5;
--color-brand-700: #2a31d8;
--color-brand-800: #252dae;
--color-brand-900: #262e89;
--color-brand-950: #161950;
--color-blue-light-25: #f5fbff;
--color-blue-light-50: #f0f9ff;
--color-blue-light-100: #e0f2fe;
--color-blue-light-200: #b9e6fe;
--color-blue-light-300: #7cd4fd;
--color-blue-light-400: #36bffa;
--color-blue-light-500: #0ba5ec;
--color-blue-light-600: #0086c9;
--color-blue-light-700: #026aa2;
--color-blue-light-800: #065986;
--color-blue-light-900: #0b4a6f;
--color-blue-light-950: #062c41;
--color-gray-25: #fcfcfd;
--color-gray-50: #f9fafb;
--color-gray-100: #f2f4f7;
--color-gray-200: #e4e7ec;
--color-gray-300: #d0d5dd;
--color-gray-400: #98a2b3;
--color-gray-500: #667085;
--color-gray-600: #475467;
--color-gray-700: #344054;
--color-gray-800: #1d2939;
--color-gray-900: #101828;
--color-gray-950: #0c111d;
--color-gray-dark: #1a2231;
--color-orange-25: #fffaf5;
--color-orange-50: #fff6ed;
--color-orange-100: #ffead5;
--color-orange-200: #fddcab;
--color-orange-300: #feb273;
--color-orange-400: #fd853a;
--color-orange-500: #fb6514;
--color-orange-600: #ec4a0a;
--color-orange-700: #c4320a;
--color-orange-800: #9c2a10;
--color-orange-900: #7e2410;
--color-orange-950: #511c10;
--color-success-25: #f6fef9;
--color-success-50: #ecfdf3;
--color-success-100: #d1fadf;
--color-success-200: #a6f4c5;
--color-success-300: #6ce9a6;
--color-success-400: #32d583;
--color-success-500: #12b76a;
--color-success-600: #039855;
--color-success-700: #027a48;
--color-success-800: #05603a;
--color-success-900: #054f31;
--color-success-950: #053321;
--color-error-25: #fffbfa;
--color-error-50: #fef3f2;
--color-error-100: #fee4e2;
--color-error-200: #fecdca;
--color-error-300: #fda29b;
--color-error-400: #f97066;
--color-error-500: #f04438;
--color-error-600: #d92d20;
--color-error-700: #b42318;
--color-error-800: #912018;
--color-error-900: #7a271a;
--color-error-950: #55160c;
--color-warning-25: #fffcf5;
--color-warning-50: #fffaeb;
--color-warning-100: #fef0c7;
--color-warning-200: #fedf89;
--color-warning-300: #fec84b;
--color-warning-400: #fdb022;
--color-warning-500: #f79009;
--color-warning-600: #dc6803;
--color-warning-700: #b54708;
--color-warning-800: #93370d;
--color-warning-900: #7a2e0e;
--color-warning-950: #4e1d09;
--color-theme-pink-500: #ee46bc;
--color-theme-purple-500: #7a5af8;
--shadow-theme-md: 0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06);
--shadow-theme-lg: 0px 12px 16px -4px rgba(16, 24, 40, 0.08),
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
--shadow-theme-sm: 0px 1px 3px 0px rgba(16, 24, 40, 0.1), 0px 1px 2px 0px rgba(16, 24, 40, 0.06);
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--shadow-theme-xl: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
--shadow-slider-navigation: 0px 1px 2px 0px rgba(16, 24, 40, 0.1),
0px 1px 3px 0px rgba(16, 24, 40, 0.1);
--shadow-tooltip: 0px 4px 6px -2px rgba(16, 24, 40, 0.05),
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
--drop-shadow-4xl: 0 35px 35px rgba(0, 0, 0, 0.25), 0 45px 65px rgba(0, 0, 0, 0.15);
--z-index-1: 1;
--z-index-9: 9;
--z-index-99: 99;
--z-index-999: 999;
--z-index-9999: 9999;
--z-index-99999: 99999;
--z-index-999999: 999999;
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
body {
@apply relative font-normal font-outfit z-1 bg-gray-50;
}
}
@utility menu-item {
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
}
@utility menu-item-active {
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
}
@utility menu-item-inactive {
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
}
@utility menu-item-icon {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
}
@utility menu-item-icon-active {
@apply text-brand-500 dark:text-brand-400;
}
@utility menu-item-icon-inactive {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
}
@utility menu-item-arrow {
@apply relative;
}
@utility menu-item-arrow-active {
@apply rotate-180 text-brand-500 dark:text-brand-400;
}
@utility menu-item-arrow-inactive {
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
}
@utility menu-dropdown-item {
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
}
@utility menu-dropdown-item-active {
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
}
@utility menu-dropdown-item-inactive {
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
}
@utility menu-dropdown-item {
@apply text-theme-sm relative flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium;
}
@utility menu-dropdown-item-active {
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
}
@utility menu-dropdown-item-inactive {
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
}
@utility menu-dropdown-badge {
@apply text-success-600 dark:text-success-500 block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase;
}
@utility menu-dropdown-badge-active {
@apply bg-success-100 dark:bg-success-500/20;
}
@utility menu-dropdown-badge-inactive {
@apply bg-success-50 group-hover:bg-success-100 dark:bg-success-500/15 dark:group-hover:bg-success-500/20;
}
@utility menu-dropdown-badge-pro {
@apply text-brand-600 dark:text-brand-500 block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase;
}
@utility menu-dropdown-badge-pro-active {
@apply bg-brand-100 dark:bg-brand-500/20;
}
@utility menu-dropdown-badge-pro-inactive {
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
}
@utility no-scrollbar {
/* Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@utility custom-scrollbar {
&::-webkit-scrollbar {
@apply size-1.5;
}
&::-webkit-scrollbar-track {
@apply rounded-full;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-200 rounded-full dark:bg-gray-700;
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,11 +1 @@
import './bootstrap'; import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
document.addEventListener('DOMContentLoaded', () => {
// console.log($store.isExpande)
});

View File

@ -1,38 +0,0 @@
@props(['pageTitle' => 'Page'])
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white/90">
{{ $pageTitle }}
</h2>
<nav>
<ol class="flex items-center gap-1.5">
<li>
<a
class="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
href="{{ url('/') }}"
>
Home
<svg
class="stroke-current"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
</li>
<li class="text-sm text-gray-800 dark:text-white/23">
{{ $pageTitle }}
</li>
</ol>
</nav>
</div>

View File

@ -1,212 +0,0 @@
@props(['user'])
<div x-data="{
name: '{{ $user->name }}',
email: '{{ $user->email }}',
password: '{{ $user->password ?? null }}',
errors: {},
saveProfile() {
// Очищаем предыдущие ошибки
this.errors = {};
// Формируем объект данных только с заполненными полями
const data = {
_token: document.querySelector('meta[name=\'csrf-token\']').getAttribute('content')
};
// Отправляем только заполненные поля
if (this.name) {
data.name = this.name;
}
if (this.email) {
data.email = this.email;
}
if (this.password) {
data.password = this.password;
}
fetch('{{route('users.update', ['user_uuid' => $user->uuid])}}', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').getAttribute('content')
},
body: JSON.stringify(data)
})
.then(response => {
// Выводим ответ сервера в консоль для диагностики
console.log('Response status:', response.status);
console.log('Response headers:', response.headers);
// Проверяем, является ли ответ редиректом
if (response.status === 302) {
const location = response.headers.get('location');
console.log('Redirect location:', location);
throw new Error('Server responded with redirect to: ' + location);
}
// Проверяем, является ли ответ JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
console.log('Response text (non-JSON):', text);
throw new Error('Response is not JSON: ' + text);
});
}
if (!response.ok) {
// Обрабатываем ошибки валидации (статус 422)
if (response.status === 422) {
return response.json().then(errorData => {
console.log('Validation errors:', errorData);
// Сохраняем ошибки для отображения
this.errors = errorData.errors;
throw new Error('Validation failed');
});
}
return response.text().then(text => {
console.log('Error response text:', text);
throw new Error('Network response was not ok: ' + response.status + ' ' + text);
});
}
return response.json();
})
.then(data => {
if (data.success) {
// Очищаем ошибки при успешном обновлении
this.errors = {};
// Обновляем отображаемые данные
document.querySelector('h4').textContent = this.name;
// Закрываем модальное окно
this.open = false;
} else {
console.error('Error updating profile:', data.message);
}
})
.catch(error => {
console.error('Error:', error);
// Ошибки валидации уже сохранены в this.errors
if (error.message !== 'Validation failed') {
console.error('An error occurred while updating the profile:', error.message);
}
});
}
}">
<div class="mb-6 rounded-2xl border border-gray-200 p-5 lg:p-6 dark:border-gray-800">
<div class="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div class="flex w-full flex-col items-center gap-6 xl:flex-row">
<div class="h-20 w-20 overflow-hidden rounded-full border border-gray-200 dark:border-gray-800">
<img src="{{ $user->avatar ?? Vite::asset('resources/img/avatar.png') }}" alt="user" onerror="this.src='{{ Vite::asset('resources/img/avatar.png') }}'; this.onerror=null;" />
</div>
<div class="order-3 xl:order-2">
<h4 class="mb-2 text-center text-lg font-semibold text-gray-800 xl:text-left dark:text-white/90">
{{ $user->name }}
</h4>
<div class="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $user->role->name }}
</p>
{{-- <div class="hidden h-3.5 w-px bg-gray-300 xl:block dark:bg-gray-700"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Arizona, United States.
</p>--}}
</div>
</div>
</div>
<button @click="$dispatch('open-profile-info-modal')"
class="shadow-theme-xs flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-800 lg:inline-flex lg:w-auto dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200">
<svg class="fill-current" width="18" height="18" viewBox="0 0 18 18" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill="" />
</svg>
{{ __('Edit') }}
</button>
</div>
</div>
<!-- Profile Info Modal -->
<x-ui.modal x-data="{ open: false }" @open-profile-info-modal.window="open = true" :isOpen="false" class="max-w-[700px]">
<div
class="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
<div class="px-2 pr-14">
<h4 class="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
{{ __('Edit') }}
</h4>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
{{ __('Update your details to keep your profile up-to-date.') }}
</p>
</div>
<form class="flex flex-col">
<div class="custom-scrollbar h-[458px] overflow-y-auto p-2">
<div class="mt-7">
<h5 class="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
{{ __('Personal Information') }}
</h5>
<div class="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div class="col-span-2 lg:col-span-1">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
{{ __('Name') }}
</label>
<input type="text" x-model="name"
class="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800" />
<template x-if="errors.name">
<div class="mt-1 text-sm text-red-500" x-text="errors.name[0]"></div>
</template>
</div>
<div class="col-span-2 lg:col-span-1">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
{{ __('Email Address') }}
</label>
<input type="text" x-model="email"
class="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800" />
<template x-if="errors.email">
<div class="mt-1 text-sm text-red-500" x-text="errors.email[0]"></div>
</template>
</div>
<div class="col-span-2 lg:col-span-1">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
{{ __('Password') }}
</label>
<input type="password" x-model="password"
class="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800" />
<template x-if="errors.password">
<div class="mt-1 text-sm text-red-500" x-text="errors.password[0]"></div>
</template>
</div>
@if(auth()->user()->role->code === 'admin')
<div class="col-span-2">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Bio
</label>
<input type="text" value="{{ $user->role->name }}"
class="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800" />
</div>
@endif
</div>
</div>
</div>
<div class="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<button @click="open = false" type="button"
class="flex w-full justify-center rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] sm:w-auto">
Close
</button>
<button @click="saveProfile" type="button"
class="flex w-full justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-600 sm:w-auto">
Save Changes
</button>
</div>
</form>
</div>
</x-ui.modal>
</div>

View File

@ -1,59 +0,0 @@
@props([
'isOpen' => false,
'showCloseButton' => true,
])
<div x-data="{
open: @js($isOpen),
init() {
this.$watch('open', value => {
if (value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
});
}
}" x-show="open" x-cloak @keydown.escape.window="open = false"
class="modal fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto p-5"
{{ $attributes->except('class') }}>
<!-- Backdrop -->
<div @click="open = false" class="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
</div>
<!-- Modal Content -->
<div @click.stop class="relative w-full rounded-3xl bg-white dark:bg-gray-900 {{ $attributes->get('class') }}"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<!-- Close Button -->
@if ($showCloseButton)
<button @click="open = false"
class="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor" />
</svg>
</button>
@endif
<!-- Modal Body -->
<div>
{{ $slot }}
</div>
</div>
</div>
<style>
[x-cloak] {
display: none;
}
</style>

View File

@ -1,5 +0,0 @@
@extends ('layouts.app')
@section ('content')
<h1>dashboard</h1>
@endsection

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('layouts.partials.head')
</head>
<body
x-data="{ 'loaded': true}"
x-init="$store.sidebar.isExpanded = window.innerWidth >= 1280;
const checkMobile = () => {
if (window.innerWidth < 1280) {
$store.sidebar.setMobileOpen(false);
$store.sidebar.isExpanded = false;
} else {
$store.sidebar.isMobileOpen = false;
$store.sidebar.isExpanded = true;
}
};
window.addEventListener('resize', checkMobile);">
<div class="min-h-screen">
@include('layouts.partials.sidebar')
@include('layouts.partials.app-header')
<div class="flex transition-all duration-300 ease-in-out pt-16 "
:class="{
'xl:ml-[290px]': $store.sidebar.isExpanded || $store.sidebar.isHovered,
'xl:ml-[90px]': !$store.sidebar.isExpanded && !$store.sidebar.isHovered
}">
<div class="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6 w-full">
@yield('content')
</div>
</div>
</div>
</body>
</html>

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('layouts.partials.head')
</head>
<body class="bg-gray-50">
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
@yield('content')
</div>
</body>

View File

@ -1,108 +0,0 @@
<header
class="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 xl:border-b"
x-data="{
isApplicationMenuOpen: false,
toggleApplicationMenu() {
this.isApplicationMenuOpen = !this.isApplicationMenuOpen;
}
}">
<div class="flex flex-col items-center justify-between grow xl:flex-row xl:px-6">
<div
class="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 xl:justify-normal xl:border-b-0 xl:px-0 lg:py-4">
<!-- Desktop Sidebar Toggle Button (visible on xl and up) -->
<button
class="hidden xl:flex items-center justify-center w-10 h-10 text-gray-500 border border-gray-200 rounded-lg dark:border-gray-800 dark:text-gray-400 lg:h-11 lg:w-11"
:class="{ 'bg-gray-100 dark:bg-white/[0.03]': !$store.sidebar.isExpanded }"
@click="$store.sidebar.toggleExpanded()" aria-label="Toggle Sidebar">
<svg x-show="!$store.sidebar.isMobileOpen" width="16" height="12" viewBox="0 0 16 12" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"></path>
</svg>
<svg x-show="$store.sidebar.isMobileOpen" class="fill-current" width="24" height="24" viewBox="0 0 24 24"
fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="" />
</svg>
</button>
<!-- Mobile Menu Toggle Button (visible below xl) -->
<button
class="flex xl:hidden items-center justify-center w-10 h-10 text-gray-500 rounded-lg dark:text-gray-400 lg:h-11 lg:w-11"
:class="{ 'bg-gray-100 dark:bg-white/[0.03]': $store.sidebar.isMobileOpen }"
@click="$store.sidebar.toggleMobileOpen()" aria-label="Toggle Mobile Menu">
<svg x-show="!$store.sidebar.isMobileOpen" width="16" height="12" viewBox="0 0 16 12" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"></path>
</svg>
<svg x-show="$store.sidebar.isMobileOpen" class="fill-current" width="24" height="24" viewBox="0 0 24 24"
fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="" />
</svg>
</button>
<!-- Logo (mobile only) -->
<a href="/" class="xl:hidden">
<img class="dark:hidden" src="{{ Vite::asset('resources/img/logo.png') }}" alt="Logo" width="32" height="32" />
<img class="hidden dark:block" src="{{ Vite::asset('resources/img/logo.png') }}" alt="Logo" width="32" height="32" />
</a>
<!-- Application Menu Toggle (mobile only) -->
<button @click="toggleApplicationMenu()"
class="flex items-center justify-center w-10 h-10 text-gray-700 rounded-lg z-99999 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 xl:hidden">
<!-- Dots Icon -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.99902 10.4951C6.82745 10.4951 7.49902 11.1667 7.49902 11.9951V12.0051C7.49902 12.8335 6.82745 13.5051 5.99902 13.5051C5.1706 13.5051 4.49902 12.8335 4.49902 12.0051V11.9951C4.49902 11.1667 5.1706 10.4951 5.99902 10.4951ZM17.999 10.4951C18.8275 10.4951 19.499 11.1667 19.499 11.9951V12.0051C19.499 12.8335 18.8275 13.5051 17.999 13.5051C17.1706 13.5051 16.499 12.8335 16.499 12.0051V11.9951C16.499 11.1667 17.1706 10.4951 17.999 10.4951ZM13.499 11.9951C13.499 11.1667 12.8275 10.4951 11.999 10.4951C11.1706 10.4951 10.499 11.1667 10.499 11.9951V12.0051C10.499 12.8335 11.1706 13.5051 11.999 13.5051C12.8275 13.5051 13.499 12.8335 13.499 12.0051V11.9951Z"
fill="currentColor" />
</svg>
</button>
<!-- Search Bar (desktop only) -->
<div class="hidden xl:block">
<form>
<div class="relative">
<span class="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
<!-- Search Icon -->
<svg class="fill-gray-500 dark:fill-gray-400" width="20" height="20"
viewBox="0 0 20 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
fill="" />
</svg>
</span>
<input type="text" placeholder="Search or type command..."
class="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/3 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]" />
<button
class="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
<span> </span>
<span> K </span>
</button>
</div>
</form>
</div>
</div>
<div :class="isApplicationMenuOpen ? 'flex' : 'hidden'"
class="items-center justify-between w-full gap-4 px-5 py-4 xl:flex shadow-theme-md xl:justify-end xl:px-0 xl:shadow-none">
<nav class="flex-1">
<ul class="flex justify-center space-x-4">
<li><a href="{{ route('users.index') }}">Пользователи</a></li>
</ul>
</nav>
<form method="POST" action="{{ route('logout')}}">
@csrf
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Выйти</button>
</form>
</div>
</div>
</header>

View File

@ -1,38 +0,0 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<title>{{ config('app.name', 'Laravel') }} | @yield('title')</title>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('sidebar', {
// Initialize based on screen size
isExpanded: window.innerWidth >= 1280, // true for desktop, false for mobile
isMobileOpen: false,
isHovered: false,
toggleExpanded() {
this.isExpanded = !this.isExpanded;
// When toggling desktop sidebar, ensure mobile menu is closed
this.isMobileOpen = false;
},
toggleMobileOpen() {
this.isMobileOpen = !this.isMobileOpen;
// Don't modify isExpanded when toggling mobile menu
},
setMobileOpen(val) {
this.isMobileOpen = val;
},
setHovered(val) {
// Only allow hover effects on desktop when sidebar is collapsed
if (window.innerWidth >= 1280 && !this.isExpanded) {
this.isHovered = val;
}
}
});
});
</script>

View File

@ -1,240 +0,0 @@
@php
use App\Helpers\MenuHelper;
$menuGroups = MenuHelper::getMenuGroups();
// Get current path
$currentPath = request()->path();
@endphp
<aside id="sidebar"
class="fixed flex flex-col mt-19 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-99999 border-r border-gray-200 p-t-80"
x-data="{
openSubmenus: {},
init() {
// Auto-open Dashboard menu on page load
this.initializeActiveMenus();
},
initializeActiveMenus() {
const currentPath = '{{ $currentPath }}';
@foreach ($menuGroups as $groupIndex => $menuGroup)
@foreach ($menuGroup['items'] as $itemIndex => $item)
@if (isset($item['subItems']))
// Check if any submenu item matches current path
@foreach ($item['subItems'] as $subItem)
if (currentPath === '{{ ltrim($subItem['path'], '/') }}' ||
window.location.pathname === '{{ $subItem['path'] }}') {
this.openSubmenus['{{ $groupIndex }}-{{ $itemIndex }}'] = true;
} @endforeach
@endif
@endforeach
@endforeach
},
toggleSubmenu(groupIndex, itemIndex) {
const key = groupIndex + '-' + itemIndex;
const newState = !this.openSubmenus[key];
// Close all other submenus when opening a new one
if (newState) {
this.openSubmenus = {};
}
this.openSubmenus[key] = newState;
},
isSubmenuOpen(groupIndex, itemIndex) {
const key = groupIndex + '-' + itemIndex;
return this.openSubmenus[key] || false;
},
isActive(path) {
let relativePath = path;
try {
if (path.startsWith('http')) {
relativePath = new URL(path).pathname;
}
} catch (e) {
}
const currentPath = window.location.pathname;
return currentPath === relativePath ||
currentPath === relativePath.replace(/^\/+/, '') ||
'/' + currentPath === relativePath;
}
}"
:class="{
'w-[290px]': $store.sidebar.isExpanded || $store.sidebar.isMobileOpen || $store.sidebar.isHovered,
'w-[90px]': !$store.sidebar.isExpanded && !$store.sidebar.isHovered,
'translate-x-0': $store.sidebar.isMobileOpen,
'-translate-x-full xl:translate-x-0': !$store.sidebar.isMobileOpen
}"
@mouseenter="if (!$store.sidebar.isExpanded) $store.sidebar.setHovered(true)"
@mouseleave="$store.sidebar.setHovered(false)">
<!-- Logo Section ??? -->
{{-- <div class="pt-8 pb-7 flex"
:class="(!$store.sidebar.isExpanded && !$store.sidebar.isHovered && !$store.sidebar.isMobileOpen) ?
'xl:justify-center' :
'justify-start'">
<a href="{{ route('dashboard') }}">
<img x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen"
class="dark:hidden" src="{{ Vite::asset('resources/img/logo.png') }}" alt="Logo" width="150" height="40" />
<img x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen"
class="hidden dark:block" src="{{ Vite::asset('resources/img/logo.png') }}" alt="Logo" width="150"
height="40" />
<img x-show="!$store.sidebar.isExpanded && !$store.sidebar.isHovered && !$store.sidebar.isMobileOpen"
src="{{ Vite::asset('resources/img/logo.png') }}" alt="Logo" width="32" height="32" />
</a>
</div> --}}
<!-- Navigation Menu -->
<div class="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav class="mb-6">
<div class="flex flex-col gap-4">
@foreach ($menuGroups as $groupIndex => $menuGroup)
<div>
<!-- Menu Group Title -->
<h2 class="mb-4 text-xs uppercase flex leading-[20px] text-gray-400"
:class="(!$store.sidebar.isExpanded && !$store.sidebar.isHovered && !$store.sidebar.isMobileOpen) ?
'lg:justify-center' : 'justify-start'">
<template
x-if="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen">
<span>{{ $menuGroup['title'] }}</span>
</template>
<template x-if="!$store.sidebar.isExpanded && !$store.sidebar.isHovered && !$store.sidebar.isMobileOpen">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.99915 10.2451C6.96564 10.2451 7.74915 11.0286 7.74915 11.9951V12.0051C7.74915 12.9716 6.96564 13.7551 5.99915 13.7551C5.03265 13.7551 4.24915 12.9716 4.24915 12.0051V11.9951C4.24915 11.0286 5.03265 10.2451 5.99915 10.2451ZM17.9991 10.2451C18.9656 10.2451 19.7491 11.0286 19.7491 11.9951V12.0051C19.7491 12.9716 18.9656 13.7551 17.9991 13.7551C17.0326 13.7551 16.2491 12.9716 16.2491 12.0051V11.9951C16.2491 11.0286 17.0326 10.2451 17.9991 10.2451ZM13.7491 11.9951C13.7491 11.0286 12.9656 10.2451 11.9991 10.2451C11.0326 10.2451 10.2491 11.0286 10.2491 11.9951V12.0051C10.2491 12.9716 11.0326 13.7551 11.9991 13.7551C12.9656 13.7551 13.7491 12.9716 13.7491 12.0051V11.9951Z" fill="currentColor"/>
</svg>
</template>
</h2>
<!-- Menu Items -->
<ul class="flex flex-col gap-1">
@foreach ($menuGroup['items'] as $itemIndex => $item)
<li>
@if (isset($item['subItems']))
<!-- Menu Item with Submenu -->
<button @click="toggleSubmenu({{ $groupIndex }}, {{ $itemIndex }})"
class="menu-item group w-full"
:class="[
isSubmenuOpen({{ $groupIndex }}, {{ $itemIndex }}) ?
'menu-item-active' : 'menu-item-inactive',
!$store.sidebar.isExpanded && !$store.sidebar.isHovered ?
'xl:justify-center' : 'xl:justify-start'
]">
<!-- Icon -->
<span :class="isSubmenuOpen({{ $groupIndex }}, {{ $itemIndex }}) ?
'menu-item-icon-active' : 'menu-item-icon-inactive'">
{!! MenuHelper::getIconSvg($item['icon']) !!}
</span>
<!-- Text -->
<span
x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen"
class="menu-item-text flex items-center gap-2">
{{ $item['name'] }}
@if (!empty($item['new']))
<span class="absolute right-10"
:class="isActive('{{ $item['path'] ?? '' }}') ?
'menu-dropdown-badge menu-dropdown-badge-active' :
'menu-dropdown-badge menu-dropdown-badge-inactive'">
new
</span>
@endif
</span>
<!-- Chevron Down Icon -->
<svg x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen"
class="ml-auto w-5 h-5 transition-transform duration-200"
:class="{
'rotate-180 text-brand-500': isSubmenuOpen({{ $groupIndex }},
{{ $itemIndex }})
}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Submenu -->
<div x-show="isSubmenuOpen({{ $groupIndex }}, {{ $itemIndex }}) && ($store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen)">
<ul class="mt-2 space-y-1 ml-9">
@foreach ($item['subItems'] as $subItem)
<li>
<a href="{{ $subItem['path'] }}" class="menu-dropdown-item"
:class="isActive('{{ $subItem['path'] }}') ?
'menu-dropdown-item-active' :
'menu-dropdown-item-inactive'">
{{ $subItem['name'] }}
<span class="flex items-center gap-1 ml-auto">
@if (!empty($subItem['new']))
<span
:class="isActive('{{ $subItem['path'] }}') ?
'menu-dropdown-badge menu-dropdown-badge-active' :
'menu-dropdown-badge menu-dropdown-badge-inactive'">
new
</span>
@endif
@if (!empty($subItem['pro']))
<span
:class="isActive('{{ $subItem['path'] }}') ?
'menu-dropdown-badge-pro menu-dropdown-badge-pro-active' :
'menu-dropdown-badge-pro menu-dropdown-badge-pro-inactive'">
pro
</span>
@endif
</span>
</a>
</li>
@endforeach
</ul>
</div>
@else
<!-- Simple Menu Item -->
<a href="{{ $item['path'] }}" class="menu-item group"
:class="[
isActive('{{ $item['path'] }}') ? 'menu-item-active' :
'menu-item-inactive',
(!$store.sidebar.isExpanded && !$store.sidebar.isHovered && !$store.sidebar.isMobileOpen) ?
'xl:justify-center' :
'justify-start'
]">
<!-- Icon -->
<span
:class="isActive('{{ $item['path'] }}') ? 'menu-item-icon-active' :
'menu-item-icon-inactive'">
{!! MenuHelper::getIconSvg($item['icon']) !!}
</span>
<!-- Text -->
<span
x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen"
class="menu-item-text flex items-center gap-2">
{{ $item['name'] }}
@if (!empty($item['new']))
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-brand-500 text-white">
new
</span>
@endif
</span>
</a>
@endif
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
</nav>
<!-- Sidebar Widget -->
<div x-data x-show="$store.sidebar.isExpanded || $store.sidebar.isHovered || $store.sidebar.isMobileOpen" x-transition class="mt-auto">
</div>
</div>
</aside>
<!-- Mobile Overlay -->
<div x-show="$store.sidebar.isMobileOpen" @click="$store.sidebar.setMobileOpen(false)"
class="fixed z-50 h-screen w-full bg-gray-900/50"></div>

View File

@ -1,5 +0,0 @@
@extends ('layouts.default')
@section ('content')
<a href="{{route('login')}}">login</a>
@endsection

View File

@ -1,56 +0,0 @@
@extends('layouts.default')
@section('content')
<div class="max-w-md w-full space-y-8 bg-white p-8 rounded-lg shadow-md">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Вход в систему
</h2>
</div>
<form class="mt-8 space-y-6" method="POST" action="{{ route('login') }}">
@csrf
<!-- Поле Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email:</label>
<input
type="email"
id="email"
name="email"
value="{{ old('email') }}"
placeholder="Enter your email"
required
autocomplete="username"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Поле Пароль -->
<div class="mt-4">
<label for="password" class="block text-sm font-medium text-gray-700">Password:</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
required
autocomplete="current-password"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
@error('password')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Кнопка входа -->
<div class="mt-6">
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Войти
</button>
</div>
</form>
</div>
@endsection

View File

@ -1,5 +0,0 @@
@extends ('layouts.app')
@section ('content')
<h1>Users index</h1>
@endsection

View File

@ -1,11 +0,0 @@
@extends ('layouts.app')
@section ('content')
<x-common.page-breadcrumb pageTitle="{{ __('User Profile') }}" />
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<h3 class="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">{{ __('Profile') }}</h3>
<x-profile.profile-card :user="$data"/>
<x-profile.personal-info-card />
<x-profile.address-card />
</div>
@endsection

File diff suppressed because one or more lines are too long

View File

@ -2,30 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return view('main'); return view('welcome');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::controller(UserController::class)
->prefix('/users')
->as('users.')
->group(static function (): void {
Route::get('/', 'index')
->name('index')
->middleware('role:admin');
Route::get('/{user_uuid}', 'show')
->name('show')
->whereUuid('user_uuid');
Route::patch('/{user_uuid}/update', 'update')
->name('update')
->whereUuid('user_uuid');
});
}); });

View File

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\get;
use function Pest\Laravel\post;
uses(RefreshDatabase::class);
it('can display the login page', function () {
$response = get('/login');
$response->assertStatus(200);
$response->assertSee('Email:');
$response->assertSee('Password:');
$response->assertSee('Войти');
});
it('can authenticate a user', function () {
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$response = post('/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertRedirect('/dashboard');
expect(auth()->check())->toBeTrue();
});
it('cannot authenticate a user with invalid credentials', function () {
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$response = post('/login', [
'email' => $user->email,
'password' => 'wrongpassword',
]);
$response->assertSessionHasErrors();
expect(auth()->check())->toBeFalse();
});
it('can logout a user', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$response->assertRedirect('/');
expect(auth()->check())->toBeFalse();
});

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
it('получение профиля пользователя', function () {
$user = User::factory()->create();
$response = actingAs($user)
->get(route('users.show', ['user_uuid' => $user->uuid]));
$response->assertOk();
$response->assertViewIs('pages.user.show');
$response->assertViewHas('data', $user);
});

View File

@ -8,5 +8,5 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
use CreateApplication; //
} }

View File

@ -14,11 +14,5 @@ export default defineConfig({
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],
}, },
host: '0.0.0.0',
port: 5173,
hmr: {
host: '127.0.0.1',
port: 5173,
},
}, },
}); });