init commit
This commit is contained in:
268
README.md
268
README.md
@@ -1,2 +1,268 @@
|
||||
# Laravel
|
||||
/**
|
||||
* Professional Resume Builder - Laravel Application
|
||||
* Created by David Valera Melendez
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
# Professional Laravel Resume Builder
|
||||
|
||||
**Created by David Valera Melendez** | david@valera-melendez.de | Made in Germany 🇩🇪
|
||||
|
||||
A modern, professional resume builder application built with Laravel 10, Bootstrap 5, and Blade templates. This project follows enterprise-grade architecture patterns and design principles, inspired by the Angular Material UI version.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Professional Design**: Clean, modern interface with Bootstrap 5
|
||||
- **Enterprise Authentication**: Secure JWT-based authentication system
|
||||
- **Real-time Validation**: Client-side and server-side form validation
|
||||
- **Responsive Design**: Mobile-first responsive layout
|
||||
- **Multi-step Forms**: Intuitive step-by-step resume building process
|
||||
- **PDF Export**: Generate professional PDF resumes
|
||||
- **Admin Dashboard**: Administrative interface for user management
|
||||
- **Security Features**: CSRF protection, rate limiting, and secure sessions
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Framework**: Laravel 10.x
|
||||
- **Frontend**: Bootstrap 5, Sass, Vite
|
||||
- **Database**: MySQL/PostgreSQL
|
||||
- **Authentication**: Laravel Sanctum
|
||||
- **Templating**: Blade Templates
|
||||
- **Icons**: Bootstrap Icons
|
||||
- **PDF Generation**: Laravel DomPDF/Snappy
|
||||
- **Asset Building**: Vite
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. **Prerequisites**: PHP 8.1+, Composer, Node.js 18+, MySQL/PostgreSQL
|
||||
|
||||
2. **Clone and setup**:
|
||||
```bash
|
||||
# Navigate to project directory
|
||||
cd Laravel
|
||||
|
||||
# Install PHP dependencies
|
||||
composer install
|
||||
|
||||
# Install Node.js dependencies
|
||||
npm install
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Generate application key
|
||||
php artisan key:generate
|
||||
|
||||
# Configure database in .env file
|
||||
# Run migrations
|
||||
php artisan migrate
|
||||
|
||||
# Seed database
|
||||
php artisan db:seed
|
||||
|
||||
# Build assets
|
||||
npm run build
|
||||
|
||||
# Start development server
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
## 🚀 Available Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
php artisan serve # Start development server
|
||||
npm run dev # Start Vite development server
|
||||
npm run build # Build assets for production
|
||||
|
||||
# Database
|
||||
php artisan migrate # Run migrations
|
||||
php artisan db:seed # Seed database
|
||||
php artisan migrate:fresh --seed # Fresh migration with seeding
|
||||
|
||||
# Cache
|
||||
php artisan config:cache # Cache configuration
|
||||
php artisan route:cache # Cache routes
|
||||
php artisan view:cache # Cache views
|
||||
|
||||
# Testing
|
||||
php artisan test # Run PHPUnit tests
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
Laravel/
|
||||
├── app/
|
||||
│ ├── Http/
|
||||
│ │ ├── Controllers/ # Application controllers
|
||||
│ │ │ ├── Auth/ # Authentication controllers
|
||||
│ │ │ ├── Dashboard/ # Dashboard controllers
|
||||
│ │ │ └── Api/ # API controllers
|
||||
│ │ ├── Middleware/ # Custom middleware
|
||||
│ │ ├── Requests/ # Form request validation
|
||||
│ │ └── Resources/ # API resources
|
||||
│ ├── Models/ # Eloquent models
|
||||
│ ├── Services/ # Business logic services
|
||||
│ └── Providers/ # Service providers
|
||||
├── database/
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ ├── seeders/ # Database seeders
|
||||
│ └── factories/ # Model factories
|
||||
├── resources/
|
||||
│ ├── views/ # Blade templates
|
||||
│ │ ├── layouts/ # Layout templates
|
||||
│ │ ├── auth/ # Authentication views
|
||||
│ │ ├── dashboard/ # Dashboard views
|
||||
│ │ └── components/ # Reusable components
|
||||
│ ├── js/ # JavaScript files
|
||||
│ └── sass/ # Sass stylesheets
|
||||
├── routes/
|
||||
│ ├── web.php # Web routes
|
||||
│ ├── api.php # API routes
|
||||
│ └── auth.php # Authentication routes
|
||||
├── storage/ # File storage
|
||||
├── tests/ # Test files
|
||||
├── public/ # Public assets
|
||||
└── config/ # Configuration files
|
||||
```
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Bootstrap Theme
|
||||
|
||||
- **Primary**: Professional blue (#0d6efd) - Bootstrap primary
|
||||
- **Secondary**: Complementary colors for UI elements
|
||||
- **Success/Warning/Danger**: Standard Bootstrap states
|
||||
- **Typography**: System font stack with fallbacks
|
||||
|
||||
### Component Architecture
|
||||
|
||||
- **Bootstrap Components**: Utilizing Bootstrap 5's comprehensive component library
|
||||
- **Custom Components**: Blade components for reusable UI elements
|
||||
- **Responsive Layout**: CSS Grid and Flexbox with Bootstrap's grid system
|
||||
- **Consistent Spacing**: Bootstrap spacing utilities
|
||||
|
||||
## 🎯 Usage
|
||||
|
||||
1. **Start the application**: `php artisan serve`
|
||||
2. **Build assets**: `npm run dev` or `npm run build`
|
||||
3. **Register/Login** to access the resume builder
|
||||
4. **Fill in your information** using the multi-step form
|
||||
5. **Preview your resume** in real-time
|
||||
6. **Export to PDF** when ready
|
||||
|
||||
### Resume Sections
|
||||
|
||||
- **Personal Information**: Contact details and professional summary
|
||||
- **Work Experience**: Job history with achievements
|
||||
- **Education**: Academic background
|
||||
- **Skills**: Technical and soft skills with proficiency levels
|
||||
- **Languages**: Language skills with proficiency
|
||||
- **Certifications**: Professional certifications
|
||||
- **Projects**: Notable projects and achievements
|
||||
|
||||
## 🔧 Development Guidelines
|
||||
|
||||
### Code Standards
|
||||
|
||||
- **PSR-4**: Following PSR-4 autoloading standard
|
||||
- **Laravel Best Practices**: Following Laravel coding standards
|
||||
- **Bootstrap Conventions**: Consistent with Bootstrap design patterns
|
||||
- **Professional Quality**: Production-ready code with proper documentation
|
||||
- **Author Attribution**: David Valera Melendez signature on all files
|
||||
|
||||
### Architecture Principles
|
||||
|
||||
- **MVC Pattern**: Model-View-Controller architecture
|
||||
- **Service Layer**: Business logic in dedicated service classes
|
||||
- **Repository Pattern**: Data access abstraction
|
||||
- **Dependency Injection**: Laravel's service container
|
||||
- **Form Validation**: Request validation with custom rules
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
- **Authentication**: Laravel Sanctum for API authentication
|
||||
- **Authorization**: Policy-based authorization
|
||||
- **CSRF Protection**: Cross-site request forgery protection
|
||||
- **Rate Limiting**: API and form submission rate limiting
|
||||
- **Input Validation**: Comprehensive input validation and sanitization
|
||||
- **Secure Sessions**: Encrypted session storage
|
||||
|
||||
## 📱 Browser Support
|
||||
|
||||
- Chrome (recommended)
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Mobile browsers (iOS Safari, Chrome Mobile)
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
composer install --optimize-autoloader --no-dev
|
||||
npm run build
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
```
|
||||
|
||||
### Deploy to:
|
||||
- DigitalOcean
|
||||
- AWS EC2
|
||||
- Laravel Forge
|
||||
- Heroku
|
||||
- Any VPS with PHP support
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
### Optimization Features
|
||||
- **Asset Bundling**: Vite for optimized asset compilation
|
||||
- **Database Optimization**: Query optimization and indexing
|
||||
- **Caching**: Redis/Memcached support for sessions and cache
|
||||
- **CDN Ready**: Asset URLs configurable for CDN delivery
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
php artisan test
|
||||
|
||||
# Run specific test suite
|
||||
php artisan test --testsuite=Feature
|
||||
php artisan test --testsuite=Unit
|
||||
|
||||
# Run with coverage
|
||||
php artisan test --coverage
|
||||
```
|
||||
|
||||
## 🌍 Internationalization
|
||||
|
||||
- **Multi-language Support**: Laravel's localization system
|
||||
- **German Localization**: Primary language support
|
||||
- **English Support**: Secondary language
|
||||
- **RTL Support**: Right-to-left language compatibility
|
||||
|
||||
## 👨💻 Author
|
||||
|
||||
**David Valera Melendez**
|
||||
|
||||
- Email: david@valera-melendez.de
|
||||
- Location: Germany 🇩🇪
|
||||
- Created: August 8, 2025
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - Feel free to use this project for your professional resume needs.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This project is designed as a professional showcase. If you'd like to contribute or suggest improvements, please reach out via email.
|
||||
|
||||
---
|
||||
|
||||
**Professional Laravel Resume Builder** - Helping professionals create outstanding resumes with modern web technologies | Made in Germany 🇩🇪
|
||||
|
||||
27
app/Console/Kernel.php
Normal file
27
app/Console/Kernel.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*/
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
51
app/DTOs/UserDTO.php
Normal file
51
app/DTOs/UserDTO.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
/**
|
||||
* User Data Transfer Object
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
class UserDTO
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $first_name,
|
||||
public readonly string $last_name,
|
||||
public readonly string $email,
|
||||
public readonly ?string $phone = null,
|
||||
public readonly ?string $profession = null,
|
||||
public readonly ?string $location = null,
|
||||
public readonly ?string $status = 'active'
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => $this->first_name,
|
||||
'last_name' => $this->last_name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'profession' => $this->profession,
|
||||
'location' => $this->location,
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
first_name: $data['first_name'],
|
||||
last_name: $data['last_name'],
|
||||
email: $data['email'],
|
||||
phone: $data['phone'] ?? null,
|
||||
profession: $data['profession'] ?? null,
|
||||
location: $data['location'] ?? null,
|
||||
status: $data['status'] ?? 'active'
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/Exceptions/Handler.php
Normal file
30
app/Exceptions/Handler.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
/**
|
||||
* The list of the inputs that are never flashed to the session on validation exceptions.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register the exception handling callbacks for the application.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
//
|
||||
});
|
||||
}
|
||||
}
|
||||
117
app/Http/Controllers/Api/AuthController.php
Normal file
117
app/Http/Controllers/Api/AuthController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* API Authentication Controller
|
||||
* Handles API authentication endpoints
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-09
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle API login
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API login not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API registration
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API registration not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle forgot password
|
||||
*/
|
||||
public function forgotPassword(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API forgot password not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle password reset
|
||||
*/
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API password reset not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token
|
||||
*/
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API token refresh not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API logout not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API user profile not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API profile update not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update password
|
||||
*/
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'API password update not implemented yet',
|
||||
'status' => 'error'
|
||||
], 501);
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Api/V1/ApiController.php
Normal file
55
app/Http/Controllers/Api/V1/ApiController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Base API Controller for V1
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
abstract class ApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* Success response
|
||||
*/
|
||||
protected function successResponse($data = null, string $message = 'Success', int $code = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response
|
||||
*/
|
||||
protected function errorResponse(string $message = 'Error', int $code = 400, $errors = null): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error response
|
||||
*/
|
||||
protected function validationErrorResponse($errors): JsonResponse
|
||||
{
|
||||
return $this->errorResponse('Validation failed', 422, $errors);
|
||||
}
|
||||
}
|
||||
586
app/Http/Controllers/Auth/AuthController.php
Normal file
586
app/Http/Controllers/Auth/AuthController.php
Normal file
@@ -0,0 +1,586 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Professional Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\UpdateProfileRequest;
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Services\AuthService;
|
||||
use App\Services\ProfileCompletionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Handles user authentication, registration, and session management with repository pattern
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* AuthService instance
|
||||
*/
|
||||
private AuthService $authService;
|
||||
|
||||
/**
|
||||
* Profile completion service instance
|
||||
*/
|
||||
private ProfileCompletionService $profileCompletionService;
|
||||
|
||||
/**
|
||||
* Maximum login attempts before lockout
|
||||
*/
|
||||
private const MAX_LOGIN_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Account lockout duration in minutes
|
||||
*/
|
||||
private const LOCKOUT_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Create a new controller instance with dependency injection
|
||||
*/
|
||||
public function __construct(
|
||||
UserRepositoryInterface $userRepository,
|
||||
AuthService $authService,
|
||||
ProfileCompletionService $profileCompletionService
|
||||
) {
|
||||
$this->userRepository = $userRepository;
|
||||
$this->authService = $authService;
|
||||
$this->profileCompletionService = $profileCompletionService;
|
||||
$this->middleware('guest')->except(['logout', 'profile', 'updateProfile', 'activity']);
|
||||
$this->middleware('auth')->only(['logout', 'profile', 'updateProfile', 'activity']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the login form
|
||||
*/
|
||||
public function showLoginForm(): View
|
||||
{
|
||||
return view('auth.login', [
|
||||
'title' => 'Sign In - Professional Resume Builder',
|
||||
'description' => 'Access your professional resume builder account'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a login request with enhanced maintainability
|
||||
*
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
public function login(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$credentials = $request->validated();
|
||||
$loginResult = $this->authService->attemptLogin($credentials);
|
||||
|
||||
return $this->handleLoginResult($loginResult, $request);
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return $this->handleLoginValidationError($e, $request);
|
||||
} catch (Exception $e) {
|
||||
return $this->handleLoginSystemError($e, $request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the result of a login attempt
|
||||
*
|
||||
* @param array $loginResult
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleLoginResult(array $loginResult, LoginRequest $request): RedirectResponse
|
||||
{
|
||||
if ($loginResult['success']) {
|
||||
return $this->handleSuccessfulLogin($loginResult, $request);
|
||||
}
|
||||
|
||||
return $this->handleFailedLogin($loginResult, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful login response
|
||||
*
|
||||
* @param array $loginResult
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleSuccessfulLogin(array $loginResult, LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->session()->regenerate();
|
||||
|
||||
Log::info('User logged in successfully', [
|
||||
'user_id' => $loginResult['user']->id,
|
||||
'email' => $loginResult['user']->email,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'login_method' => 'standard'
|
||||
]);
|
||||
|
||||
$welcomeMessage = $this->getWelcomeMessage($loginResult['user']);
|
||||
$redirectUrl = $this->getPostLoginRedirectUrl($loginResult['user']);
|
||||
|
||||
return redirect()->intended($redirectUrl)->with('success', $welcomeMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed login response
|
||||
*
|
||||
* @param array $loginResult
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleFailedLogin(array $loginResult, LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$errorMessage = $this->getLoginErrorMessage($loginResult['reason']);
|
||||
|
||||
Log::warning('Login attempt failed', [
|
||||
'email' => $request->input('email'),
|
||||
'reason' => $loginResult['reason'],
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent()
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withErrors(['email' => $errorMessage])
|
||||
->onlyInput('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle validation errors during login
|
||||
*
|
||||
* @param ValidationException $e
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleLoginValidationError(ValidationException $e, LoginRequest $request): RedirectResponse
|
||||
{
|
||||
Log::warning('Login validation error', [
|
||||
'email' => $request->input('email'),
|
||||
'errors' => $e->errors(),
|
||||
'ip_address' => $request->ip()
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withErrors($e->errors())
|
||||
->onlyInput('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system errors during login
|
||||
*
|
||||
* @param Exception $e
|
||||
* @param LoginRequest $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleLoginSystemError(Exception $e, LoginRequest $request): RedirectResponse
|
||||
{
|
||||
Log::error('Login system error', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_trace' => $e->getTraceAsString(),
|
||||
'email' => $request->input('email'),
|
||||
'ip_address' => $request->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withErrors(['email' => 'A system error occurred. Please try again or contact support.'])
|
||||
->onlyInput('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personalized welcome message
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*/
|
||||
private function getWelcomeMessage(User $user): string
|
||||
{
|
||||
$timeOfDay = $this->getTimeOfDay();
|
||||
$firstName = $user->first_name ?? 'there';
|
||||
|
||||
return "Good {$timeOfDay}, {$firstName}! Welcome back to your professional resume builder.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post-login redirect URL based on user profile and activity state
|
||||
*
|
||||
* Intelligently determines the most appropriate landing page based on:
|
||||
* - Profile completion percentage (incomplete profiles → profile page)
|
||||
* - Resume creation status (no resumes → dashboard for guidance)
|
||||
* - Default → main dashboard
|
||||
*
|
||||
* @param User $user The authenticated user
|
||||
* @return string The route name for redirection
|
||||
*/
|
||||
private function getPostLoginRedirectUrl(User $user): string
|
||||
{
|
||||
$profileCompletion = $this->profileCompletionService->calculateCompletion($user);
|
||||
|
||||
if ($profileCompletion < 50) {
|
||||
return route('profile');
|
||||
}
|
||||
|
||||
$userStats = $this->userRepository->getUserStatistics($user->id);
|
||||
if (($userStats['resumes_count'] ?? 0) === 0) {
|
||||
return route('dashboard');
|
||||
}
|
||||
|
||||
return route('dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate error message based on failure reason
|
||||
*
|
||||
* @param string $reason
|
||||
* @return string
|
||||
*/
|
||||
private function getLoginErrorMessage(string $reason): string
|
||||
{
|
||||
switch ($reason) {
|
||||
case 'account_locked':
|
||||
return 'Account is temporarily locked due to too many failed attempts. Please try again later.';
|
||||
case 'account_inactive':
|
||||
return 'Your account has been deactivated. Please contact support.';
|
||||
case 'invalid_credentials':
|
||||
return 'The provided credentials do not match our records.';
|
||||
case 'too_many_attempts':
|
||||
return 'Too many failed attempts. Account has been locked for security.';
|
||||
case 'rate_limited':
|
||||
return 'Too many login attempts. Please wait before trying again.';
|
||||
default:
|
||||
return 'Login failed. Please check your credentials and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time of day greeting
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getTimeOfDay(): string
|
||||
{
|
||||
$hour = now()->hour;
|
||||
|
||||
if ($hour < 12) {
|
||||
return 'morning';
|
||||
} elseif ($hour < 17) {
|
||||
return 'afternoon';
|
||||
} else {
|
||||
return 'evening';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the registration form
|
||||
*/
|
||||
public function showRegistrationForm(): View
|
||||
{
|
||||
return view('auth.register', [
|
||||
'title' => 'Create Account - Professional Resume Builder',
|
||||
'description' => 'Join thousands of professionals creating outstanding resumes'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a registration request
|
||||
*/
|
||||
public function register(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$userData = $request->validated();
|
||||
|
||||
$user = $this->authService->createUser($userData);
|
||||
|
||||
if ($user) {
|
||||
Auth::login($user);
|
||||
|
||||
// Log successful registration
|
||||
logger()->info('User registered successfully', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent()
|
||||
]);
|
||||
|
||||
return redirect()->route('dashboard')
|
||||
->with('success', 'Welcome! Your account has been created successfully.');
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'Unable to create account. Please try again.',
|
||||
])->withInput();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Registration error', [
|
||||
'error' => $e->getMessage(),
|
||||
'email' => $request->input('email'),
|
||||
'ip' => $request->ip()
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'An error occurred during registration. Please try again.',
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout request
|
||||
*/
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
// Log the logout
|
||||
logger()->info('User logged out', [
|
||||
'user_id' => Auth::id(),
|
||||
'ip' => $request->ip()
|
||||
]);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('success', 'You have been successfully logged out.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user profile with comprehensive analytics
|
||||
*/
|
||||
public function profile(): View
|
||||
{
|
||||
try {
|
||||
$user = Auth::user();
|
||||
|
||||
// Get user statistics from repository
|
||||
$userStats = $this->userRepository->getUserStatistics($user->id);
|
||||
|
||||
// Get profile completion analysis from service
|
||||
$profileCompletion = $this->profileCompletionService->calculateCompletion($user);
|
||||
$profileAnalysis = $this->profileCompletionService->getDetailedAnalysis($user);
|
||||
|
||||
// Prepare view data
|
||||
$viewData = [
|
||||
'title' => 'Profile Settings - Professional Resume Builder',
|
||||
'user' => $user,
|
||||
'userStats' => $userStats,
|
||||
'profileCompletion' => $profileCompletion,
|
||||
'profileAnalysis' => $profileAnalysis,
|
||||
'accountSecurity' => [
|
||||
'two_factor_enabled' => $user->two_factor_secret !== null,
|
||||
'email_verified' => $user->email_verified_at !== null,
|
||||
'is_locked' => $this->userRepository->isAccountLocked($user->email)
|
||||
]
|
||||
];
|
||||
|
||||
// Log profile view for analytics
|
||||
Log::info('User profile viewed', [
|
||||
'user_id' => $user->id,
|
||||
'profile_completion' => $profileCompletion,
|
||||
'ip_address' => request()->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
return view('auth.profile', $viewData);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error loading user profile', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'ip_address' => request()->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
// Fallback with minimal data
|
||||
return view('auth.profile', [
|
||||
'title' => 'Profile Settings',
|
||||
'user' => Auth::user(),
|
||||
'userStats' => [],
|
||||
'profileCompletion' => 0,
|
||||
'error' => 'Unable to load profile data completely'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile using professional form request validation
|
||||
*
|
||||
* Handles comprehensive profile updates with:
|
||||
* - Repository pattern for data persistence
|
||||
* - Structured logging for security audit trails
|
||||
* - Real-time profile completion calculation
|
||||
* - User-friendly completion status messaging
|
||||
*
|
||||
* @param UpdateProfileRequest $request Validated profile update data
|
||||
* @return RedirectResponse Response with success message or error state
|
||||
* @throws Exception When profile update fails due to system errors
|
||||
*/
|
||||
public function updateProfile(UpdateProfileRequest $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$user = Auth::user();
|
||||
$updateData = $request->validated();
|
||||
|
||||
$updatedUser = $this->userRepository->update($user->id, $updateData);
|
||||
|
||||
Log::info('User profile updated successfully', [
|
||||
'user_id' => $user->id,
|
||||
'updated_fields' => array_keys($updateData),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
$completionPercentage = $this->profileCompletionService
|
||||
->calculateCompletion($updatedUser);
|
||||
|
||||
$message = "Profile updated successfully! ";
|
||||
if ($completionPercentage < 100) {
|
||||
$message .= "Your profile is {$completionPercentage}% complete.";
|
||||
} else {
|
||||
$message .= "Your profile is now 100% complete!";
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Profile update failed', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_trace' => $e->getTraceAsString(),
|
||||
'user_id' => Auth::id(),
|
||||
'request_data' => $request->except(['password', 'password_confirmation']),
|
||||
'ip_address' => $request->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withErrors(['error' => 'Unable to update profile. Please try again.'])
|
||||
->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve comprehensive user activity and analytics data
|
||||
*
|
||||
* Provides detailed activity analytics including:
|
||||
* - User profile and authentication information
|
||||
* - Login activity patterns and security metrics
|
||||
* - Profile completion analysis with detailed breakdown
|
||||
* - Account security status and recommendations
|
||||
* - Usage statistics (resumes, templates, exports)
|
||||
*
|
||||
* @return JsonResponse Structured activity data with metadata
|
||||
* @throws Exception When analytics data retrieval fails
|
||||
*/
|
||||
public function activity(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = Auth::user();
|
||||
|
||||
$userStats = $this->userRepository->getUserStatistics($user->id);
|
||||
|
||||
$profileCompletion = $this->profileCompletionService
|
||||
->calculateCompletion($user);
|
||||
|
||||
$profileAnalysis = $this->profileCompletionService
|
||||
->getDetailedAnalysis($user);
|
||||
|
||||
$activityData = [
|
||||
'user_info' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->first_name . ' ' . $user->last_name,
|
||||
'email' => $user->email,
|
||||
'member_since' => $user->created_at->format('F Y'),
|
||||
'account_status' => $user->status ?? 'active'
|
||||
],
|
||||
'login_activity' => [
|
||||
'last_login' => $user->last_login_at ? $user->last_login_at->format('Y-m-d H:i:s') : null,
|
||||
'last_login_ip' => $user->last_login_ip,
|
||||
'total_logins' => $userStats['total_logins'] ?? 0,
|
||||
'failed_attempts' => $user->login_attempts ?? 0
|
||||
],
|
||||
'profile_metrics' => [
|
||||
'completion_percentage' => $profileCompletion,
|
||||
'completed_fields' => $profileAnalysis['completed_fields'],
|
||||
'missing_fields' => $profileAnalysis['missing_fields'],
|
||||
'completion_score' => $profileAnalysis['weighted_score']
|
||||
],
|
||||
'account_security' => [
|
||||
'two_factor_enabled' => $user->two_factor_secret !== null,
|
||||
'email_verified' => $user->email_verified_at !== null,
|
||||
'password_updated' => $user->password_updated_at ? $user->password_updated_at->format('Y-m-d') : null,
|
||||
'is_locked' => $this->userRepository->isAccountLocked($user->email)
|
||||
],
|
||||
'activity_summary' => [
|
||||
'resumes_created' => $userStats['resumes_count'] ?? 0,
|
||||
'templates_used' => $userStats['templates_used'] ?? 0,
|
||||
'exports_generated' => $userStats['exports_count'] ?? 0,
|
||||
'profile_views' => $userStats['profile_views'] ?? 0
|
||||
]
|
||||
];
|
||||
|
||||
Log::info('User activity data requested', [
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $activityData,
|
||||
'meta' => [
|
||||
'generated_at' => now()->toISOString(),
|
||||
'version' => '1.0'
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to retrieve user activity data', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'ip_address' => request()->ip(),
|
||||
'timestamp' => now()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unable to retrieve activity data',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show forgot password form
|
||||
*/
|
||||
public function showForgotPasswordForm(): View
|
||||
{
|
||||
return view('auth.forgot-password', [
|
||||
'title' => 'Reset Password - Professional Resume Builder',
|
||||
'description' => 'Enter your email to receive password reset instructions'
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Controller.php
Normal file
25
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Base Controller Class
|
||||
* Professional Resume Builder - Laravel Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
/**
|
||||
* Base Controller
|
||||
* Parent class for all application controllers
|
||||
*/
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
154
app/Http/Controllers/DashboardController.php
Normal file
154
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dashboard Controller
|
||||
* Professional Resume Builder - Main Dashboard
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Interfaces\ResumeRepositoryInterface;
|
||||
use App\Services\ResumeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Dashboard Controller
|
||||
* Handles the main dashboard functionality with repository pattern
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
protected UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* Resume repository instance
|
||||
*/
|
||||
protected ResumeRepositoryInterface $resumeRepository;
|
||||
|
||||
/**
|
||||
* Resume service instance
|
||||
*/
|
||||
protected ResumeService $resumeService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance with dependency injection
|
||||
*/
|
||||
public function __construct(
|
||||
UserRepositoryInterface $userRepository,
|
||||
ResumeRepositoryInterface $resumeRepository,
|
||||
ResumeService $resumeService
|
||||
) {
|
||||
$this->userRepository = $userRepository;
|
||||
$this->resumeRepository = $resumeRepository;
|
||||
$this->resumeService = $resumeService;
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the application dashboard
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get user's resumes using repository
|
||||
$resumes = $this->resumeRepository->getUserResumes($user->id);
|
||||
|
||||
// Get resume statistics using repository
|
||||
$resumeStats = $this->resumeRepository->getResumeStatistics($user->id);
|
||||
|
||||
// Get recent resumes (last 5)
|
||||
$recentResumes = $this->resumeRepository
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(5)
|
||||
->getUserResumes($user->id);
|
||||
|
||||
// Calculate profile completion
|
||||
$profileCompletion = $this->calculateProfileCompletion($user);
|
||||
|
||||
return view('dashboard.index', [
|
||||
'title' => 'Dashboard - Professional Resume Builder',
|
||||
'user' => $user,
|
||||
'resumes' => $resumes,
|
||||
'resumeStats' => $resumeStats,
|
||||
'recentResumes' => $recentResumes,
|
||||
'profileCompletion' => $profileCompletion,
|
||||
'stats' => [
|
||||
'total_resumes' => $resumeStats['total_resumes'],
|
||||
'published_resumes' => $resumeStats['published_resumes'],
|
||||
'draft_resumes' => $resumeStats['draft_resumes'],
|
||||
'completed_resumes' => $resumeStats['completed_resumes'],
|
||||
'avg_completion' => $resumeStats['avg_completion'],
|
||||
'total_views' => $resumeStats['total_views'],
|
||||
'total_downloads' => $resumeStats['total_downloads'],
|
||||
'profile_completion' => $profileCompletion
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate profile completion percentage
|
||||
*/
|
||||
private function calculateProfileCompletion($user): int
|
||||
{
|
||||
$requiredFields = [
|
||||
'first_name', 'last_name', 'email', 'phone',
|
||||
'bio', 'website', 'linkedin'
|
||||
];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!empty($user->$field)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($completed / count($requiredFields)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard analytics data
|
||||
*/
|
||||
public function analytics(): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$analytics = [
|
||||
'user_stats' => $this->userRepository->getUserStatistics(),
|
||||
'resume_stats' => $this->resumeRepository->getResumeStatistics($user->id),
|
||||
'recent_activity' => $this->getRecentActivity($user->id),
|
||||
];
|
||||
|
||||
return response()->json($analytics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent user activity
|
||||
*/
|
||||
private function getRecentActivity(int $userId): array
|
||||
{
|
||||
$recentResumes = $this->resumeRepository
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->getUserResumes($userId);
|
||||
|
||||
return $recentResumes->map(function ($resume) {
|
||||
return [
|
||||
'id' => $resume->id,
|
||||
'title' => $resume->title,
|
||||
'action' => 'updated',
|
||||
'timestamp' => $resume->updated_at,
|
||||
'status' => $resume->status,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
99
app/Http/Controllers/HomeController.php
Normal file
99
app/Http/Controllers/HomeController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(): View|RedirectResponse
|
||||
{
|
||||
// If user is authenticated, redirect to dashboard
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to login
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the features page.
|
||||
*/
|
||||
public function features(): View
|
||||
{
|
||||
return view('home.features');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the pricing page.
|
||||
*/
|
||||
public function pricing(): View
|
||||
{
|
||||
return view('home.pricing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the help page.
|
||||
*/
|
||||
public function help(): View
|
||||
{
|
||||
return view('home.help');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the contact page.
|
||||
*/
|
||||
public function contact(): View
|
||||
{
|
||||
return view('home.contact');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle contact form submission.
|
||||
*/
|
||||
public function submitContact(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'subject' => 'required|string|max:255',
|
||||
'message' => 'required|string',
|
||||
]);
|
||||
|
||||
// Handle contact form logic here
|
||||
// You can send email, save to database, etc.
|
||||
|
||||
return redirect()->route('contact')->with('success', 'Your message has been sent successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the privacy policy page.
|
||||
*/
|
||||
public function privacy(): View
|
||||
{
|
||||
return view('home.privacy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the terms of service page.
|
||||
*/
|
||||
public function terms(): View
|
||||
{
|
||||
return view('home.terms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the support page.
|
||||
*/
|
||||
public function support(): View
|
||||
{
|
||||
return view('home.support');
|
||||
}
|
||||
}
|
||||
169
app/Http/Controllers/ProfileController.php
Normal file
169
app/Http/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
return view('profile.show', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the user's profile.
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
return view('profile.edit', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'bio' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$user->update($request->only(['name', 'email', 'phone', 'bio']));
|
||||
|
||||
return redirect()->route('profile.show')->with('success', 'Profile updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required|current_password',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/')->with('success', 'Your account has been deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the settings page.
|
||||
*/
|
||||
public function settings(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
return view('profile.settings', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function updatePassword(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'current_password' => 'required|current_password',
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
Auth::user()->update([
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
return redirect()->route('profile.settings')->with('success', 'Password updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences.
|
||||
*/
|
||||
public function updatePreferences(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'theme' => 'required|in:light,dark,auto',
|
||||
'notifications_email' => 'boolean',
|
||||
'notifications_browser' => 'boolean',
|
||||
'language' => 'required|string|max:5',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
// If your User model has a preferences JSON column
|
||||
$preferences = $user->preferences ?? [];
|
||||
$preferences['theme'] = $request->theme;
|
||||
$preferences['notifications_email'] = $request->boolean('notifications_email');
|
||||
$preferences['notifications_browser'] = $request->boolean('notifications_browser');
|
||||
$preferences['language'] = $request->language;
|
||||
|
||||
$user->update(['preferences' => $preferences]);
|
||||
|
||||
return redirect()->route('profile.settings')->with('success', 'Preferences updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user avatar.
|
||||
*/
|
||||
public function updateAvatar(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
// Delete old avatar if exists
|
||||
if ($user->avatar && Storage::exists('public/' . $user->avatar)) {
|
||||
Storage::delete('public/' . $user->avatar);
|
||||
}
|
||||
|
||||
// Store new avatar
|
||||
$avatarPath = $request->file('avatar')->store('avatars', 'public');
|
||||
|
||||
$user->update(['avatar' => $avatarPath]);
|
||||
|
||||
return redirect()->route('profile.settings')->with('success', 'Avatar updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show profile completion status.
|
||||
*/
|
||||
public function completion(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Calculate completion percentage
|
||||
$fields = ['name', 'email', 'phone', 'bio', 'avatar'];
|
||||
$completedFields = 0;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($user->$field)) {
|
||||
$completedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$completionPercentage = round(($completedFields / count($fields)) * 100);
|
||||
|
||||
return view('profile.completion', compact('user', 'completionPercentage'));
|
||||
}
|
||||
}
|
||||
356
app/Http/Controllers/ResumeBuilderController.php
Normal file
356
app/Http/Controllers/ResumeBuilderController.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Resume Builder Controller
|
||||
* Professional Resume Builder - Resume Creation and Management
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Resume\StoreResumeRequest;
|
||||
use App\Http\Requests\Resume\UpdateResumeRequest;
|
||||
use App\Models\Resume;
|
||||
use App\Interfaces\ResumeRepositoryInterface;
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Services\ResumeService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Resume Builder Controller
|
||||
* Handles resume creation, editing, and management with repository pattern
|
||||
*/
|
||||
class ResumeBuilderController extends Controller
|
||||
{
|
||||
/**
|
||||
* Resume repository instance
|
||||
*/
|
||||
protected ResumeRepositoryInterface $resumeRepository;
|
||||
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
protected UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* ResumeService instance
|
||||
*/
|
||||
protected ResumeService $resumeService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance with dependency injection
|
||||
*/
|
||||
public function __construct(
|
||||
ResumeRepositoryInterface $resumeRepository,
|
||||
UserRepositoryInterface $userRepository,
|
||||
ResumeService $resumeService
|
||||
) {
|
||||
$this->resumeRepository = $resumeRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->resumeService = $resumeService;
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of user's resumes
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
// Get paginated resumes using repository
|
||||
$resumes = $this->resumeRepository->getPaginatedUserResumes($userId, 12);
|
||||
|
||||
// Get resume statistics using repository
|
||||
$resumeStats = $this->resumeRepository->getResumeStatistics($userId);
|
||||
|
||||
return view('resume-builder.index', [
|
||||
'title' => 'My Resumes - Resume Builder',
|
||||
'resumes' => $resumes,
|
||||
'resumeStats' => $resumeStats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resume
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
// Check if user can create more resumes
|
||||
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
|
||||
return redirect()->route('resume-builder.index')
|
||||
->with('error', 'You have reached the maximum number of resumes allowed.');
|
||||
}
|
||||
|
||||
$templates = $this->resumeService->getAvailableTemplates();
|
||||
|
||||
return view('resume-builder.create', [
|
||||
'title' => 'Create New Resume',
|
||||
'templates' => $templates
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resume
|
||||
*/
|
||||
public function store(StoreResumeRequest $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$userId = Auth::id();
|
||||
|
||||
// Check if user can create more resumes
|
||||
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
|
||||
return back()->withErrors([
|
||||
'title' => 'You have reached the maximum number of resumes allowed.'
|
||||
])->withInput();
|
||||
}
|
||||
|
||||
$resumeData = $request->validated();
|
||||
$resumeData['user_id'] = $userId;
|
||||
|
||||
// Create resume using repository
|
||||
$resume = $this->resumeRepository->create($resumeData);
|
||||
|
||||
// Update completion percentage
|
||||
$this->resumeRepository->updateCompletionPercentage($resume->id);
|
||||
|
||||
logger()->info('Resume created', [
|
||||
'user_id' => $userId,
|
||||
'resume_id' => $resume->id,
|
||||
'title' => $resume->title
|
||||
]);
|
||||
|
||||
return redirect()->route('resume-builder.edit', $resume)
|
||||
->with('success', 'Resume created successfully! Start building your professional CV.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Resume creation error', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => Auth::id()
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'title' => 'Unable to create resume. Please try again.'
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resume
|
||||
*/
|
||||
public function edit(Resume $resume): View
|
||||
{
|
||||
$this->authorize('update', $resume);
|
||||
|
||||
return view('resume-builder.edit', [
|
||||
'title' => 'Edit Resume - ' . $resume->title,
|
||||
'resume' => $resume,
|
||||
'templates' => $this->resumeService->getAvailableTemplates()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resume
|
||||
*/
|
||||
public function update(UpdateResumeRequest $request, Resume $resume): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $resume);
|
||||
|
||||
try {
|
||||
$resumeData = $request->validated();
|
||||
|
||||
// Update resume using repository
|
||||
$this->resumeRepository->update($resume->id, $resumeData);
|
||||
|
||||
// Update completion percentage
|
||||
$this->resumeRepository->updateCompletionPercentage($resume->id);
|
||||
|
||||
logger()->info('Resume updated', [
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Resume updated successfully!');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Resume update error', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'title' => 'Unable to update resume. Please try again.'
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resume preview
|
||||
*/
|
||||
public function preview(Resume $resume): View
|
||||
{
|
||||
$this->authorize('view', $resume);
|
||||
|
||||
// Increment view count using repository
|
||||
$this->resumeRepository->incrementViewCount($resume->id);
|
||||
|
||||
return view('resume-builder.preview', [
|
||||
'title' => 'Preview - ' . $resume->title,
|
||||
'resume' => $resume
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resume
|
||||
*/
|
||||
public function destroy(Resume $resume): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $resume);
|
||||
|
||||
try {
|
||||
// Delete resume using repository
|
||||
$this->resumeRepository->delete($resume->id);
|
||||
|
||||
logger()->info('Resume deleted', [
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id,
|
||||
'title' => $resume->title
|
||||
]);
|
||||
|
||||
return redirect()->route('resume-builder.index')
|
||||
->with('success', 'Resume deleted successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Resume deletion error', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'error' => 'Unable to delete resume. Please try again.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download resume as PDF
|
||||
*/
|
||||
public function downloadPdf(Resume $resume)
|
||||
{
|
||||
$this->authorize('view', $resume);
|
||||
|
||||
try {
|
||||
// Increment download count using repository
|
||||
$this->resumeRepository->incrementDownloadCount($resume->id);
|
||||
|
||||
return $this->resumeService->generatePdf($resume);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('PDF generation error', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'error' => 'Unable to generate PDF. Please try again.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing resume
|
||||
*/
|
||||
public function duplicate(Resume $resume): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $resume);
|
||||
|
||||
try {
|
||||
$userId = Auth::id();
|
||||
|
||||
// Check if user can create more resumes
|
||||
if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) {
|
||||
return back()->withErrors([
|
||||
'error' => 'You have reached the maximum number of resumes allowed.'
|
||||
]);
|
||||
}
|
||||
|
||||
$newTitle = $resume->title . ' (Copy)';
|
||||
$duplicatedResume = $this->resumeRepository->duplicateResume($resume->id, $userId, $newTitle);
|
||||
|
||||
logger()->info('Resume duplicated', [
|
||||
'user_id' => $userId,
|
||||
'original_resume_id' => $resume->id,
|
||||
'new_resume_id' => $duplicatedResume->id
|
||||
]);
|
||||
|
||||
return redirect()->route('resume-builder.edit', $duplicatedResume)
|
||||
->with('success', 'Resume duplicated successfully!');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Resume duplication error', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'error' => 'Unable to duplicate resume. Please try again.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search user's resumes
|
||||
*/
|
||||
public function search(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$query = $request->input('query', '');
|
||||
$userId = Auth::id();
|
||||
|
||||
if (empty($query)) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$resumes = $this->resumeRepository
|
||||
->getUserResumes($userId)
|
||||
->filter(function ($resume) use ($query) {
|
||||
return stripos($resume->title, $query) !== false ||
|
||||
stripos($resume->description, $query) !== false;
|
||||
})
|
||||
->take(10)
|
||||
->values();
|
||||
|
||||
return response()->json($resumes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume analytics
|
||||
*/
|
||||
public function analytics(Resume $resume): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resume);
|
||||
|
||||
$analytics = [
|
||||
'view_count' => $resume->view_count,
|
||||
'download_count' => $resume->download_count,
|
||||
'completion_percentage' => $resume->completion_percentage,
|
||||
'last_viewed_at' => $resume->last_viewed_at,
|
||||
'last_downloaded_at' => $resume->last_downloaded_at,
|
||||
'created_at' => $resume->created_at,
|
||||
'updated_at' => $resume->updated_at,
|
||||
];
|
||||
|
||||
return response()->json($analytics);
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/TemplateController.php
Normal file
87
app/Http/Controllers/TemplateController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TemplateController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of available templates.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// Get available templates
|
||||
$templates = [
|
||||
// You can replace this with actual template data from database
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Professional',
|
||||
'description' => 'Clean and professional template',
|
||||
'preview_image' => '/images/templates/professional.jpg',
|
||||
'is_premium' => false,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'name' => 'Modern',
|
||||
'description' => 'Modern and stylish template',
|
||||
'preview_image' => '/images/templates/modern.jpg',
|
||||
'is_premium' => true,
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'name' => 'Classic',
|
||||
'description' => 'Traditional classic template',
|
||||
'preview_image' => '/images/templates/classic.jpg',
|
||||
'is_premium' => false,
|
||||
],
|
||||
];
|
||||
|
||||
return view('templates.index', compact('templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified template.
|
||||
*/
|
||||
public function show(string $template): View
|
||||
{
|
||||
// Find template by ID or slug
|
||||
// This is a placeholder - replace with actual template fetching logic
|
||||
$templateData = [
|
||||
'id' => 1,
|
||||
'name' => ucfirst($template),
|
||||
'description' => 'Template description',
|
||||
'preview_image' => "/images/templates/{$template}.jpg",
|
||||
'is_premium' => false,
|
||||
];
|
||||
|
||||
return view('templates.show', compact('templateData'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the specified template.
|
||||
*/
|
||||
public function preview(string $template): View
|
||||
{
|
||||
// Generate preview for the template
|
||||
// This would typically include sample data
|
||||
$templateData = [
|
||||
'id' => 1,
|
||||
'name' => ucfirst($template),
|
||||
'description' => 'Template description',
|
||||
'preview_image' => "/images/templates/{$template}.jpg",
|
||||
'is_premium' => false,
|
||||
];
|
||||
|
||||
$sampleData = [
|
||||
'name' => 'John Doe',
|
||||
'title' => 'Software Developer',
|
||||
'email' => 'john.doe@example.com',
|
||||
'phone' => '+1 (555) 123-4567',
|
||||
'summary' => 'Experienced software developer with expertise in web technologies.',
|
||||
];
|
||||
|
||||
return view('templates.preview', compact('templateData', 'sampleData'));
|
||||
}
|
||||
}
|
||||
76
app/Http/Kernel.php
Normal file
76
app/Http/Kernel.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* HTTP Kernel
|
||||
* Professional Resume Builder - Laravel Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
/**
|
||||
* The application's global HTTP middleware stack.
|
||||
*
|
||||
* These middleware are run during every request to your application.
|
||||
*
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware groups.
|
||||
*
|
||||
* @var array<string, array<int, class-string|string>>
|
||||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's middleware aliases.
|
||||
*
|
||||
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
|
||||
*
|
||||
* @var array<string, class-string|string>
|
||||
*/
|
||||
protected $middlewareAliases = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
];
|
||||
}
|
||||
17
app/Http/Middleware/Authenticate.php
Normal file
17
app/Http/Middleware/Authenticate.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class Authenticate extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the path the user should be redirected to when they are not authenticated.
|
||||
*/
|
||||
protected function redirectTo(Request $request): ?string
|
||||
{
|
||||
return $request->expectsJson() ? null : route('login');
|
||||
}
|
||||
}
|
||||
17
app/Http/Middleware/EncryptCookies.php
Normal file
17
app/Http/Middleware/EncryptCookies.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
|
||||
|
||||
class EncryptCookies extends Middleware
|
||||
{
|
||||
/**
|
||||
* The names of the cookies that should not be encrypted.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
];
|
||||
}
|
||||
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file
17
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
|
||||
|
||||
class PreventRequestsDuringMaintenance extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be reachable while maintenance mode is enabled.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
];
|
||||
}
|
||||
30
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
30
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectIfAuthenticated
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
$guards = empty($guards) ? [null] : $guards;
|
||||
|
||||
foreach ($guards as $guard) {
|
||||
if (Auth::guard($guard)->check()) {
|
||||
return redirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/TrimStrings.php
Normal file
19
app/Http/Middleware/TrimStrings.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
|
||||
|
||||
class TrimStrings extends Middleware
|
||||
{
|
||||
/**
|
||||
* The names of the attributes that should not be trimmed.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
}
|
||||
20
app/Http/Middleware/TrustHosts.php
Normal file
20
app/Http/Middleware/TrustHosts.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/TrustProxies.php
Normal file
28
app/Http/Middleware/TrustProxies.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustProxies as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TrustProxies extends Middleware
|
||||
{
|
||||
/**
|
||||
* The trusted proxies for this application.
|
||||
*
|
||||
* @var array<int, string>|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $headers =
|
||||
Request::HEADER_X_FORWARDED_FOR |
|
||||
Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PORT |
|
||||
Request::HEADER_X_FORWARDED_PROTO |
|
||||
Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||
}
|
||||
22
app/Http/Middleware/ValidateSignature.php
Normal file
22
app/Http/Middleware/ValidateSignature.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
|
||||
|
||||
class ValidateSignature extends Middleware
|
||||
{
|
||||
/**
|
||||
* The names of the query string parameters that should be ignored.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
// 'fbclid',
|
||||
// 'utm_campaign',
|
||||
// 'utm_content',
|
||||
// 'utm_medium',
|
||||
// 'utm_source',
|
||||
// 'utm_term',
|
||||
];
|
||||
}
|
||||
17
app/Http/Middleware/VerifyCsrfToken.php
Normal file
17
app/Http/Middleware/VerifyCsrfToken.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||
|
||||
class VerifyCsrfToken extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be excluded from CSRF verification.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
];
|
||||
}
|
||||
110
app/Http/Requests/Auth/LoginRequest.php
Normal file
110
app/Http/Requests/Auth/LoginRequest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Login Form Request
|
||||
*
|
||||
* Validates user login credentials with enterprise security standards.
|
||||
* Implements rate limiting and security validations.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email:rfc,dns',
|
||||
'max:255',
|
||||
'exists:users,email'
|
||||
],
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:6',
|
||||
'max:255'
|
||||
],
|
||||
'remember' => [
|
||||
'boolean'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom error messages for validation rules.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Email address is required.',
|
||||
'email.email' => 'Please enter a valid email address.',
|
||||
'email.exists' => 'No account found with this email address.',
|
||||
'password.required' => 'Password is required.',
|
||||
'password.min' => 'Password must be at least 6 characters long.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attributes for validator errors.
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'email address',
|
||||
'password' => 'password',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'email' => strtolower(trim($this->email)),
|
||||
'remember' => $this->boolean('remember'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed validation attempts for security monitoring
|
||||
*
|
||||
* Logs failed login validation attempts with security context
|
||||
* including IP address, user agent, and validation errors.
|
||||
* Essential for detecting potential security threats.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Validation\Validator $validator
|
||||
* @return void
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected function failedValidation($validator): void
|
||||
{
|
||||
logger()->warning('Login validation failed', [
|
||||
'email' => $this->input('email'),
|
||||
'ip' => $this->ip(),
|
||||
'user_agent' => $this->userAgent(),
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
}
|
||||
209
app/Http/Requests/Auth/RegisterRequest.php
Normal file
209
app/Http/Requests/Auth/RegisterRequest.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Registration Form Request
|
||||
*
|
||||
* Validates user registration data with comprehensive security checks.
|
||||
* Implements password strength validation and data sanitization.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:2',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
|
||||
],
|
||||
'last_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:2',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email:rfc,dns',
|
||||
'max:255',
|
||||
'unique:users,email',
|
||||
],
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'confirmed',
|
||||
Password::min(8)
|
||||
->letters()
|
||||
->mixedCase()
|
||||
->numbers()
|
||||
->symbols()
|
||||
->uncompromised(),
|
||||
],
|
||||
'password_confirmation' => [
|
||||
'required',
|
||||
'string',
|
||||
'same:password',
|
||||
],
|
||||
'phone' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:10',
|
||||
'max:20',
|
||||
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
|
||||
],
|
||||
'terms' => [
|
||||
'required',
|
||||
'accepted',
|
||||
],
|
||||
'newsletter' => [
|
||||
'boolean',
|
||||
],
|
||||
'marketing' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom error messages for validation rules.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'first_name.required' => 'First name is required.',
|
||||
'first_name.min' => 'First name must be at least 2 characters.',
|
||||
'first_name.regex' => 'First name contains invalid characters.',
|
||||
'last_name.required' => 'Last name is required.',
|
||||
'last_name.min' => 'Last name must be at least 2 characters.',
|
||||
'last_name.regex' => 'Last name contains invalid characters.',
|
||||
'email.required' => 'Email address is required.',
|
||||
'email.email' => 'Please enter a valid email address.',
|
||||
'email.unique' => 'An account with this email address already exists.',
|
||||
'password.required' => 'Password is required.',
|
||||
'password.min' => 'Password must be at least 8 characters long.',
|
||||
'password.confirmed' => 'Password confirmation does not match.',
|
||||
'password_confirmation.required' => 'Password confirmation is required.',
|
||||
'password_confirmation.same' => 'Password confirmation must match the password.',
|
||||
'phone.regex' => 'Please enter a valid phone number.',
|
||||
'terms.required' => 'You must accept the terms and conditions.',
|
||||
'terms.accepted' => 'You must accept the terms and conditions.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attributes for validator errors.
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => 'first name',
|
||||
'last_name' => 'last name',
|
||||
'email' => 'email address',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password confirmation',
|
||||
'phone' => 'phone number',
|
||||
'terms' => 'terms and conditions',
|
||||
'newsletter' => 'newsletter subscription',
|
||||
'marketing' => 'marketing communications',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'first_name' => ucfirst(strtolower(trim($this->first_name))),
|
||||
'last_name' => ucfirst(strtolower(trim($this->last_name))),
|
||||
'email' => strtolower(trim($this->email)),
|
||||
'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null,
|
||||
'newsletter' => $this->boolean('newsletter'),
|
||||
'marketing' => $this->boolean('marketing'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure advanced validation rules and security checks
|
||||
*
|
||||
* Implements additional validation logic beyond standard rules:
|
||||
* - Name similarity validation
|
||||
* - Suspicious pattern detection
|
||||
* - Data integrity verification
|
||||
*
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
if ($this->first_name === $this->last_name) {
|
||||
$validator->errors()->add(
|
||||
'last_name',
|
||||
'Last name should be different from first name.'
|
||||
);
|
||||
}
|
||||
|
||||
$suspiciousPatterns = ['test', 'admin', 'root', 'null', 'undefined'];
|
||||
$fullName = strtolower($this->first_name . ' ' . $this->last_name);
|
||||
|
||||
foreach ($suspiciousPatterns as $pattern) {
|
||||
if (strpos($fullName, $pattern) !== false) {
|
||||
$validator->errors()->add(
|
||||
'first_name',
|
||||
'Please enter your real name.'
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed registration validation for security monitoring
|
||||
*
|
||||
* Logs failed registration attempts with comprehensive security context
|
||||
* including user input, IP address, and validation errors.
|
||||
* Critical for detecting registration abuse and attacks.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Validation\Validator $validator
|
||||
* @return void
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected function failedValidation($validator): void
|
||||
{
|
||||
logger()->warning('Registration validation failed', [
|
||||
'email' => $this->input('email'),
|
||||
'first_name' => $this->input('first_name'),
|
||||
'last_name' => $this->input('last_name'),
|
||||
'ip' => $this->ip(),
|
||||
'user_agent' => $this->userAgent(),
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
}
|
||||
119
app/Http/Requests/Auth/UpdateProfileRequest.php
Normal file
119
app/Http/Requests/Auth/UpdateProfileRequest.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Update Profile Form Request
|
||||
*
|
||||
* Validates user profile update data with comprehensive security checks.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class UpdateProfileRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return Auth::check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:2',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
|
||||
],
|
||||
'last_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:2',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-ZÀ-ÿ\s\-\'\.]+$/',
|
||||
],
|
||||
'phone' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:10',
|
||||
'max:20',
|
||||
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
|
||||
],
|
||||
'bio' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:1000',
|
||||
],
|
||||
'website' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https?:\/\/.+$/',
|
||||
],
|
||||
'linkedin' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/',
|
||||
],
|
||||
'github' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/',
|
||||
],
|
||||
'twitter' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https:\/\/(www\.)?twitter\.com\/[a-zA-Z0-9_]+\/?$/',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom error messages for validation rules.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'first_name.required' => 'First name is required.',
|
||||
'first_name.regex' => 'First name contains invalid characters.',
|
||||
'last_name.required' => 'Last name is required.',
|
||||
'last_name.regex' => 'Last name contains invalid characters.',
|
||||
'phone.regex' => 'Please enter a valid phone number.',
|
||||
'website.regex' => 'Website URL must start with http:// or https://',
|
||||
'linkedin.regex' => 'Please enter a valid LinkedIn profile URL.',
|
||||
'github.regex' => 'Please enter a valid GitHub profile URL.',
|
||||
'twitter.regex' => 'Please enter a valid Twitter profile URL.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'first_name' => ucfirst(strtolower(trim($this->first_name ?? ''))),
|
||||
'last_name' => ucfirst(strtolower(trim($this->last_name ?? ''))),
|
||||
'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null,
|
||||
'bio' => $this->bio ? trim($this->bio) : null,
|
||||
'website' => $this->website ? strtolower(trim($this->website)) : null,
|
||||
'linkedin' => $this->linkedin ? strtolower(trim($this->linkedin)) : null,
|
||||
'github' => $this->github ? strtolower(trim($this->github)) : null,
|
||||
'twitter' => $this->twitter ? strtolower(trim($this->twitter)) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
336
app/Http/Requests/Resume/StoreResumeRequest.php
Normal file
336
app/Http/Requests/Resume/StoreResumeRequest.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Resume;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Store Resume Form Request
|
||||
*
|
||||
* Validates data for creating a new resume with comprehensive validation rules.
|
||||
* Implements business logic validations and data sanitization.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class StoreResumeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return Auth::check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:255',
|
||||
'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:1000',
|
||||
],
|
||||
'template' => [
|
||||
'required',
|
||||
'string',
|
||||
'in:professional,modern,executive,minimal,technical',
|
||||
],
|
||||
'content' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'content.personal_info' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'content.personal_info.full_name' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.title' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.email' => [
|
||||
'nullable',
|
||||
'email',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.phone' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:20',
|
||||
],
|
||||
'content.personal_info.location' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.website' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.linkedin' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.github' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.summary' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2000',
|
||||
],
|
||||
'content.experience' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:20',
|
||||
],
|
||||
'content.experience.*.company' => [
|
||||
'required_with:content.experience',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.experience.*.position' => [
|
||||
'required_with:content.experience',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.experience.*.start_date' => [
|
||||
'required_with:content.experience',
|
||||
'date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.experience.*.end_date' => [
|
||||
'nullable',
|
||||
'date',
|
||||
'after:content.experience.*.start_date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.experience.*.current' => [
|
||||
'boolean',
|
||||
],
|
||||
'content.experience.*.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2000',
|
||||
],
|
||||
'content.education' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:10',
|
||||
],
|
||||
'content.education.*.institution' => [
|
||||
'required_with:content.education',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.degree' => [
|
||||
'required_with:content.education',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.field' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.start_date' => [
|
||||
'required_with:content.education',
|
||||
'date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.education.*.end_date' => [
|
||||
'nullable',
|
||||
'date',
|
||||
'after:content.education.*.start_date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.skills' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'settings' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'settings.color_scheme' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'in:blue,green,red,purple,orange,gray,black',
|
||||
],
|
||||
'settings.font_family' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'in:Arial,Times New Roman,Calibri,Helvetica,Georgia',
|
||||
],
|
||||
'settings.font_size' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:8',
|
||||
'max:16',
|
||||
],
|
||||
'is_public' => [
|
||||
'boolean',
|
||||
],
|
||||
'allow_comments' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom error messages for validation rules.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => 'Resume title is required.',
|
||||
'title.min' => 'Resume title must be at least 3 characters.',
|
||||
'title.max' => 'Resume title cannot exceed 255 characters.',
|
||||
'title.regex' => 'Resume title contains invalid characters.',
|
||||
'description.max' => 'Description cannot exceed 1000 characters.',
|
||||
'template.required' => 'Please select a resume template.',
|
||||
'template.in' => 'Selected template is not available.',
|
||||
'content.experience.max' => 'You cannot add more than 20 work experiences.',
|
||||
'content.education.max' => 'You cannot add more than 10 education entries.',
|
||||
'content.personal_info.email.email' => 'Please enter a valid email address.',
|
||||
'content.personal_info.website.url' => 'Please enter a valid website URL.',
|
||||
'content.personal_info.linkedin.url' => 'Please enter a valid LinkedIn URL.',
|
||||
'content.personal_info.github.url' => 'Please enter a valid GitHub URL.',
|
||||
'content.experience.*.company.required_with' => 'Company name is required for work experience.',
|
||||
'content.experience.*.position.required_with' => 'Position title is required for work experience.',
|
||||
'content.experience.*.start_date.required_with' => 'Start date is required for work experience.',
|
||||
'content.experience.*.start_date.before_or_equal' => 'Start date cannot be in the future.',
|
||||
'content.experience.*.end_date.after' => 'End date must be after start date.',
|
||||
'content.experience.*.end_date.before_or_equal' => 'End date cannot be in the future.',
|
||||
'content.education.*.institution.required_with' => 'Institution name is required for education.',
|
||||
'content.education.*.degree.required_with' => 'Degree is required for education.',
|
||||
'content.education.*.start_date.required_with' => 'Start date is required for education.',
|
||||
'settings.font_size.min' => 'Font size must be at least 8.',
|
||||
'settings.font_size.max' => 'Font size cannot exceed 16.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attributes for validator errors.
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'resume title',
|
||||
'description' => 'resume description',
|
||||
'template' => 'resume template',
|
||||
'content.personal_info.full_name' => 'full name',
|
||||
'content.personal_info.title' => 'professional title',
|
||||
'content.personal_info.email' => 'email address',
|
||||
'content.personal_info.phone' => 'phone number',
|
||||
'content.personal_info.location' => 'location',
|
||||
'content.personal_info.website' => 'website',
|
||||
'content.personal_info.linkedin' => 'LinkedIn profile',
|
||||
'content.personal_info.github' => 'GitHub profile',
|
||||
'content.summary' => 'professional summary',
|
||||
'is_public' => 'public visibility',
|
||||
'allow_comments' => 'allow comments',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'title' => trim($this->title),
|
||||
'description' => $this->description ? trim($this->description) : null,
|
||||
'is_public' => $this->boolean('is_public'),
|
||||
'allow_comments' => $this->boolean('allow_comments'),
|
||||
]);
|
||||
|
||||
if ($this->has('content.personal_info')) {
|
||||
$personalInfo = $this->input('content.personal_info', []);
|
||||
|
||||
if (isset($personalInfo['email'])) {
|
||||
$personalInfo['email'] = strtolower(trim($personalInfo['email']));
|
||||
}
|
||||
|
||||
if (isset($personalInfo['full_name'])) {
|
||||
$personalInfo['full_name'] = trim($personalInfo['full_name']);
|
||||
}
|
||||
|
||||
$this->merge(['content' => array_merge($this->input('content', []), [
|
||||
'personal_info' => $personalInfo
|
||||
])]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$userId = Auth::id();
|
||||
$existingResume = \App\Models\Resume::where('user_id', $userId)
|
||||
->where('title', $this->title)
|
||||
->first();
|
||||
|
||||
if ($existingResume) {
|
||||
$validator->errors()->add(
|
||||
'title',
|
||||
'You already have a resume with this title. Please choose a different title.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->has('content.experience') && is_array($this->content['experience'])) {
|
||||
foreach ($this->content['experience'] as $index => $experience) {
|
||||
if (!empty($experience['start_date']) && !empty($experience['end_date'])) {
|
||||
$startDate = Carbon::parse($experience['start_date']);
|
||||
$endDate = Carbon::parse($experience['end_date']);
|
||||
|
||||
if ($startDate->greaterThan($endDate)) {
|
||||
$validator->errors()->add(
|
||||
"content.experience.{$index}.end_date",
|
||||
'End date must be after start date.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed validation attempt.
|
||||
*/
|
||||
protected function failedValidation($validator): void
|
||||
{
|
||||
logger()->warning('Resume creation validation failed', [
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $this->input('title'),
|
||||
'template' => $this->input('template'),
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
}
|
||||
465
app/Http/Requests/Resume/UpdateResumeRequest.php
Normal file
465
app/Http/Requests/Resume/UpdateResumeRequest.php
Normal file
@@ -0,0 +1,465 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Resume;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Update Resume Form Request
|
||||
*
|
||||
* Validates data for updating an existing resume with comprehensive validation rules.
|
||||
* Implements business logic validations and data sanitization for resume updates.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class UpdateResumeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$resume = $this->route('resume');
|
||||
return Auth::check() && $resume && $resume->user_id === Auth::id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$resumeId = $this->route('resume')->id ?? null;
|
||||
|
||||
return [
|
||||
'title' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:255',
|
||||
'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:1000',
|
||||
],
|
||||
'template' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'in:professional,modern,executive,minimal,technical',
|
||||
],
|
||||
'status' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'in:draft,published,archived',
|
||||
],
|
||||
'content' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'content.personal_info' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'content.personal_info.full_name' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.title' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.email' => [
|
||||
'nullable',
|
||||
'email',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.phone' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:20',
|
||||
'regex:/^[\+]?[0-9\s\-\(\)]+$/',
|
||||
],
|
||||
'content.personal_info.location' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.website' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.personal_info.linkedin' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/',
|
||||
],
|
||||
'content.personal_info.github' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/',
|
||||
],
|
||||
'content.summary' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2000',
|
||||
],
|
||||
'content.experience' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:20',
|
||||
],
|
||||
'content.experience.*.company' => [
|
||||
'required_with:content.experience',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.experience.*.position' => [
|
||||
'required_with:content.experience',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.experience.*.location' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.experience.*.start_date' => [
|
||||
'required_with:content.experience',
|
||||
'date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.experience.*.end_date' => [
|
||||
'nullable',
|
||||
'date',
|
||||
'after:content.experience.*.start_date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.experience.*.current' => [
|
||||
'boolean',
|
||||
],
|
||||
'content.experience.*.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2000',
|
||||
],
|
||||
'content.experience.*.achievements' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:10',
|
||||
],
|
||||
'content.experience.*.achievements.*' => [
|
||||
'string',
|
||||
'max:500',
|
||||
],
|
||||
'content.education' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:10',
|
||||
],
|
||||
'content.education.*.institution' => [
|
||||
'required_with:content.education',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.degree' => [
|
||||
'required_with:content.education',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.field' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.education.*.start_date' => [
|
||||
'required_with:content.education',
|
||||
'date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.education.*.end_date' => [
|
||||
'nullable',
|
||||
'date',
|
||||
'after:content.education.*.start_date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.education.*.gpa' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'min:0',
|
||||
'max:4',
|
||||
],
|
||||
'content.education.*.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:1000',
|
||||
],
|
||||
'content.skills' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'content.projects' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:15',
|
||||
],
|
||||
'content.projects.*.name' => [
|
||||
'required_with:content.projects',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.projects.*.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:1000',
|
||||
],
|
||||
'content.projects.*.url' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.projects.*.technologies' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:20',
|
||||
],
|
||||
'content.certifications' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:20',
|
||||
],
|
||||
'content.certifications.*.name' => [
|
||||
'required_with:content.certifications',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.certifications.*.issuer' => [
|
||||
'required_with:content.certifications',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'content.certifications.*.date' => [
|
||||
'required_with:content.certifications',
|
||||
'date',
|
||||
'before_or_equal:today',
|
||||
],
|
||||
'content.certifications.*.url' => [
|
||||
'nullable',
|
||||
'url',
|
||||
'max:255',
|
||||
],
|
||||
'content.languages' => [
|
||||
'nullable',
|
||||
'array',
|
||||
'max:10',
|
||||
],
|
||||
'content.languages.*.language' => [
|
||||
'required_with:content.languages',
|
||||
'string',
|
||||
'max:100',
|
||||
],
|
||||
'content.languages.*.level' => [
|
||||
'required_with:content.languages',
|
||||
'string',
|
||||
'in:Native,Fluent,Advanced,Intermediate,Basic',
|
||||
],
|
||||
'settings' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'settings.color_scheme' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'in:blue,green,red,purple,orange,gray,black',
|
||||
],
|
||||
'settings.font_family' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'in:Arial,Times New Roman,Calibri,Helvetica,Georgia',
|
||||
],
|
||||
'settings.font_size' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:8',
|
||||
'max:16',
|
||||
],
|
||||
'settings.line_spacing' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
'min:1.0',
|
||||
'max:2.0',
|
||||
],
|
||||
'settings.margin' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:10',
|
||||
'max:30',
|
||||
],
|
||||
'settings.show_photo' => [
|
||||
'boolean',
|
||||
],
|
||||
'is_public' => [
|
||||
'boolean',
|
||||
],
|
||||
'allow_comments' => [
|
||||
'boolean',
|
||||
],
|
||||
'password' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:6',
|
||||
'max:50',
|
||||
],
|
||||
'expires_at' => [
|
||||
'nullable',
|
||||
'date',
|
||||
'after:today',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom error messages for validation rules.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => 'Resume title is required.',
|
||||
'title.min' => 'Resume title must be at least 3 characters.',
|
||||
'title.regex' => 'Resume title contains invalid characters.',
|
||||
'template.in' => 'Selected template is not available.',
|
||||
'status.in' => 'Invalid resume status.',
|
||||
'content.personal_info.email.email' => 'Please enter a valid email address.',
|
||||
'content.personal_info.phone.regex' => 'Please enter a valid phone number.',
|
||||
'content.personal_info.linkedin.regex' => 'Please enter a valid LinkedIn profile URL.',
|
||||
'content.personal_info.github.regex' => 'Please enter a valid GitHub profile URL.',
|
||||
'content.experience.max' => 'You cannot have more than 20 work experiences.',
|
||||
'content.education.max' => 'You cannot have more than 10 education entries.',
|
||||
'content.projects.max' => 'You cannot have more than 15 projects.',
|
||||
'content.certifications.max' => 'You cannot have more than 20 certifications.',
|
||||
'content.languages.max' => 'You cannot have more than 10 languages.',
|
||||
'content.experience.*.end_date.after' => 'End date must be after start date.',
|
||||
'content.education.*.gpa.max' => 'GPA cannot exceed 4.0.',
|
||||
'content.languages.*.level.in' => 'Invalid language proficiency level.',
|
||||
'settings.font_size.min' => 'Font size must be at least 8.',
|
||||
'settings.font_size.max' => 'Font size cannot exceed 16.',
|
||||
'settings.line_spacing.min' => 'Line spacing must be at least 1.0.',
|
||||
'settings.line_spacing.max' => 'Line spacing cannot exceed 2.0.',
|
||||
'expires_at.after' => 'Expiration date must be in the future.',
|
||||
'password.min' => 'Password must be at least 6 characters.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('title')) {
|
||||
$this->merge(['title' => trim($this->title)]);
|
||||
}
|
||||
|
||||
if ($this->has('description')) {
|
||||
$this->merge(['description' => $this->description ? trim($this->description) : null]);
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'is_public' => $this->boolean('is_public'),
|
||||
'allow_comments' => $this->boolean('allow_comments'),
|
||||
]);
|
||||
|
||||
|
||||
if ($this->has('content.personal_info')) {
|
||||
$personalInfo = $this->input('content.personal_info', []);
|
||||
|
||||
if (isset($personalInfo['email'])) {
|
||||
$personalInfo['email'] = strtolower(trim($personalInfo['email']));
|
||||
}
|
||||
|
||||
$this->merge(['content' => array_merge($this->input('content', []), [
|
||||
'personal_info' => $personalInfo
|
||||
])]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$resume = $this->route('resume');
|
||||
|
||||
if ($this->has('title')) {
|
||||
$userId = Auth::id();
|
||||
$existingResume = \App\Models\Resume::where('user_id', $userId)
|
||||
->where('title', $this->title)
|
||||
->where('id', '!=', $resume->id)
|
||||
->first();
|
||||
|
||||
if ($existingResume) {
|
||||
$validator->errors()->add(
|
||||
'title',
|
||||
'You already have a resume with this title. Please choose a different title.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->has('content.experience') && is_array($this->content['experience'])) {
|
||||
foreach ($this->content['experience'] as $index => $experience) {
|
||||
if (!empty($experience['start_date']) && !empty($experience['end_date'])) {
|
||||
$startDate = Carbon::parse($experience['start_date']);
|
||||
$endDate = Carbon::parse($experience['end_date']);
|
||||
|
||||
if ($startDate->greaterThan($endDate)) {
|
||||
$validator->errors()->add(
|
||||
"content.experience.{$index}.end_date",
|
||||
'End date must be after start date.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->has('status') && $this->status === 'published') {
|
||||
$content = $this->input('content', []);
|
||||
$requiredSections = ['personal_info', 'summary'];
|
||||
|
||||
foreach ($requiredSections as $section) {
|
||||
if (empty($content[$section])) {
|
||||
$validator->errors()->add(
|
||||
'status',
|
||||
"Cannot publish resume: {$section} section is required."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed validation attempt.
|
||||
*/
|
||||
protected function failedValidation($validator): void
|
||||
{
|
||||
$resume = $this->route('resume');
|
||||
|
||||
logger()->warning('Resume update validation failed', [
|
||||
'user_id' => Auth::id(),
|
||||
'resume_id' => $resume->id ?? null,
|
||||
'title' => $this->input('title'),
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
]);
|
||||
|
||||
parent::failedValidation($validator);
|
||||
}
|
||||
}
|
||||
156
app/Interfaces/BaseRepositoryInterface.php
Normal file
156
app/Interfaces/BaseRepositoryInterface.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interfaces;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Base Repository Interface
|
||||
*
|
||||
* Defines the contract for all repository implementations.
|
||||
* Provides a consistent API for data access operations.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
interface BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get all records
|
||||
*
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function all(array $columns = ['*']): Collection;
|
||||
|
||||
/**
|
||||
* Get records with pagination
|
||||
*
|
||||
* @param int $perPage
|
||||
* @param array $columns
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Find a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function find(int $id, array $columns = ['*']): ?Model;
|
||||
|
||||
/**
|
||||
* Find a record by ID or throw an exception
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public function findOrFail(int $id, array $columns = ['*']): Model;
|
||||
|
||||
/**
|
||||
* Find records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function findBy(array $criteria, array $columns = ['*']): Collection;
|
||||
|
||||
/**
|
||||
* Find a single record by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function findOneBy(array $criteria, array $columns = ['*']): ?Model;
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*
|
||||
* @param array $data
|
||||
* @return Model
|
||||
*/
|
||||
public function create(array $data): Model;
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
/**
|
||||
* Update or create a record
|
||||
*
|
||||
* @param array $attributes
|
||||
* @param array $values
|
||||
* @return Model
|
||||
*/
|
||||
public function updateOrCreate(array $attributes, array $values = []): Model;
|
||||
|
||||
/**
|
||||
* Delete a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(int $id): bool;
|
||||
|
||||
/**
|
||||
* Delete records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteBy(array $criteria): int;
|
||||
|
||||
/**
|
||||
* Count records
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int
|
||||
*/
|
||||
public function count(array $criteria = []): int;
|
||||
|
||||
/**
|
||||
* Check if record exists
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return bool
|
||||
*/
|
||||
public function exists(array $criteria): bool;
|
||||
|
||||
/**
|
||||
* Get records with relationships
|
||||
*
|
||||
* @param array $relations
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function with(array $relations): BaseRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Order records
|
||||
*
|
||||
* @param string $column
|
||||
* @param string $direction
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Limit records
|
||||
*
|
||||
* @param int $limit
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function limit(int $limit): BaseRepositoryInterface;
|
||||
}
|
||||
206
app/Interfaces/ResumeRepositoryInterface.php
Normal file
206
app/Interfaces/ResumeRepositoryInterface.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interfaces;
|
||||
|
||||
use App\Models\Resume;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Resume Repository Interface
|
||||
*
|
||||
* Defines the contract for resume-specific data operations.
|
||||
* Extends the base repository with resume-specific methods.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
interface ResumeRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get user's resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUserResumes(int $userId, array $columns = ['*']): Collection;
|
||||
|
||||
/**
|
||||
* Get user's resumes with pagination
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Find resume by public URL
|
||||
*
|
||||
* @param string $publicUrl
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function findByPublicUrl(string $publicUrl): ?Resume;
|
||||
|
||||
/**
|
||||
* Get public resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPublicResumes(int $limit = 20): Collection;
|
||||
|
||||
/**
|
||||
* Get resumes by template
|
||||
*
|
||||
* @param string $template
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByTemplate(string $template): Collection;
|
||||
|
||||
/**
|
||||
* Get resumes by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByStatus(string $status): Collection;
|
||||
|
||||
/**
|
||||
* Get popular resumes (most viewed)
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPopularResumes(int $limit = 10, int $days = 30): Collection;
|
||||
|
||||
/**
|
||||
* Get recent resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getRecentResumes(int $limit = 10, int $days = 7): Collection;
|
||||
|
||||
/**
|
||||
* Search resumes
|
||||
*
|
||||
* @param string $query
|
||||
* @param array $filters
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection;
|
||||
|
||||
/**
|
||||
* Get incomplete resumes
|
||||
*
|
||||
* @param int $threshold
|
||||
* @return Collection
|
||||
*/
|
||||
public function getIncompleteResumes(int $threshold = 50): Collection;
|
||||
|
||||
/**
|
||||
* Duplicate resume for user
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @param int $userId
|
||||
* @param string $newTitle
|
||||
* @return Resume
|
||||
*/
|
||||
public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume;
|
||||
|
||||
/**
|
||||
* Update resume view count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementViewCount(int $resumeId): bool;
|
||||
|
||||
/**
|
||||
* Update resume download count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementDownloadCount(int $resumeId): bool;
|
||||
|
||||
/**
|
||||
* Get resume statistics
|
||||
*
|
||||
* @param int|null $userId
|
||||
* @return array
|
||||
*/
|
||||
public function getResumeStatistics(?int $userId = null): array;
|
||||
|
||||
/**
|
||||
* Get resumes requiring PDF regeneration
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesNeedingPdfRegeneration(): Collection;
|
||||
|
||||
/**
|
||||
* Archive old resumes
|
||||
*
|
||||
* @param int $days
|
||||
* @return int Number of archived resumes
|
||||
*/
|
||||
public function archiveOldResumes(int $days = 365): int;
|
||||
|
||||
/**
|
||||
* Get user's resume by title
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $title
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function getUserResumeByTitle(int $userId, string $title): ?Resume;
|
||||
|
||||
/**
|
||||
* Check if user can create more resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $limit
|
||||
* @return bool
|
||||
*/
|
||||
public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool;
|
||||
|
||||
/**
|
||||
* Get featured resumes for showcase
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getFeaturedResumes(int $limit = 6): Collection;
|
||||
|
||||
/**
|
||||
* Update resume completion percentage
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function updateCompletionPercentage(int $resumeId): bool;
|
||||
|
||||
/**
|
||||
* Get resumes expiring soon
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesExpiringSoon(int $days = 7): Collection;
|
||||
|
||||
/**
|
||||
* Bulk update resume status
|
||||
*
|
||||
* @param array $resumeIds
|
||||
* @param string $status
|
||||
* @return int Number of updated resumes
|
||||
*/
|
||||
public function bulkUpdateStatus(array $resumeIds, string $status): int;
|
||||
}
|
||||
157
app/Interfaces/UserRepositoryInterface.php
Normal file
157
app/Interfaces/UserRepositoryInterface.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Interfaces;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* User Repository Interface
|
||||
*
|
||||
* Defines the contract for user-specific data operations.
|
||||
* Extends the base repository with user-specific methods.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
interface UserRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Find a user by email address
|
||||
*
|
||||
* @param string $email
|
||||
* @return User|null
|
||||
*/
|
||||
public function findByEmail(string $email): ?User;
|
||||
|
||||
/**
|
||||
* Find users by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function findByStatus(string $status): Collection;
|
||||
|
||||
/**
|
||||
* Get users who haven't logged in recently
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getInactiveUsers(int $days = 30): Collection;
|
||||
|
||||
/**
|
||||
* Get users with completed profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithCompletedProfiles(): Collection;
|
||||
|
||||
/**
|
||||
* Get users with incomplete profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithIncompleteProfiles(): Collection;
|
||||
|
||||
/**
|
||||
* Get users subscribed to newsletter
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getNewsletterSubscribers(): Collection;
|
||||
|
||||
/**
|
||||
* Search users by name or email
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchUsers(string $query, int $limit = 10): Collection;
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getUserStatistics(): array;
|
||||
|
||||
/**
|
||||
* Update user's last login information
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $ipAddress
|
||||
* @return bool
|
||||
*/
|
||||
public function updateLastLogin(int $userId, string $ipAddress): bool;
|
||||
|
||||
/**
|
||||
* Increment user's login attempts
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementLoginAttempts(string $email): bool;
|
||||
|
||||
/**
|
||||
* Reset user's login attempts
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function resetLoginAttempts(string $email): bool;
|
||||
|
||||
/**
|
||||
* Lock user account
|
||||
*
|
||||
* @param string $email
|
||||
* @param int $minutes
|
||||
* @return bool
|
||||
*/
|
||||
public function lockAccount(string $email, int $minutes = 15): bool;
|
||||
|
||||
/**
|
||||
* Check if account is locked
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function isAccountLocked(string $email): bool;
|
||||
|
||||
/**
|
||||
* Suspend user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $reason
|
||||
* @return bool
|
||||
*/
|
||||
public function suspendAccount(int $userId, string $reason): bool;
|
||||
|
||||
/**
|
||||
* Reactivate user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function reactivateAccount(int $userId): bool;
|
||||
|
||||
/**
|
||||
* Get users registered in date range
|
||||
*
|
||||
* @param string $startDate
|
||||
* @param string $endDate
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection;
|
||||
|
||||
/**
|
||||
* Get paginated users with filters
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator;
|
||||
}
|
||||
260
app/Models/Resume.php
Normal file
260
app/Models/Resume.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Resume Model
|
||||
* Professional Resume Builder - Resume Entity
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Resume Model
|
||||
* Represents user resumes with all personal and professional information
|
||||
*/
|
||||
class Resume extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'template_id',
|
||||
'slug',
|
||||
'personal_info',
|
||||
'professional_summary',
|
||||
'work_experiences',
|
||||
'education',
|
||||
'skills',
|
||||
'languages',
|
||||
'certifications',
|
||||
'projects',
|
||||
'references',
|
||||
'custom_sections',
|
||||
'settings',
|
||||
'is_active',
|
||||
'is_completed',
|
||||
'is_public',
|
||||
'public_url',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'personal_info' => 'array',
|
||||
'work_experiences' => 'array',
|
||||
'education' => 'array',
|
||||
'skills' => 'array',
|
||||
'languages' => 'array',
|
||||
'certifications' => 'array',
|
||||
'projects' => 'array',
|
||||
'references' => 'array',
|
||||
'custom_sections' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_completed' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the resume.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resume's public URL.
|
||||
*/
|
||||
public function getPublicUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->is_public && $this->public_url) {
|
||||
return route('public.resume', $this->public_url);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resume's preview URL.
|
||||
*/
|
||||
public function getPreviewUrlAttribute(): string
|
||||
{
|
||||
return route('resume-builder.preview', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resume's edit URL.
|
||||
*/
|
||||
public function getEditUrlAttribute(): string
|
||||
{
|
||||
return route('resume-builder.edit', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resume's PDF download URL.
|
||||
*/
|
||||
public function getPdfUrlAttribute(): string
|
||||
{
|
||||
return route('resume-builder.download-pdf', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate completion percentage of the resume.
|
||||
*/
|
||||
public function getCompletionPercentageAttribute(): int
|
||||
{
|
||||
$sections = [
|
||||
'personal_info' => 25,
|
||||
'professional_summary' => 10,
|
||||
'work_experiences' => 30,
|
||||
'education' => 15,
|
||||
'skills' => 10,
|
||||
'languages' => 5,
|
||||
'certifications' => 5,
|
||||
];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($sections as $section => $weight) {
|
||||
if ($this->isSectionCompleted($section)) {
|
||||
$completed += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return min(100, $completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific section is completed.
|
||||
*/
|
||||
public function isSectionCompleted(string $section): bool
|
||||
{
|
||||
$data = $this->$section;
|
||||
|
||||
if (empty($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($section) {
|
||||
case 'personal_info':
|
||||
$required = ['first_name', 'last_name', 'email', 'phone'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($data[$field] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'professional_summary':
|
||||
return !empty($data) && strlen($data) >= 50;
|
||||
|
||||
case 'work_experiences':
|
||||
return is_array($data) && count($data) > 0 && !empty($data[0]['company'] ?? null);
|
||||
|
||||
case 'education':
|
||||
return is_array($data) && count($data) > 0 && !empty($data[0]['institution'] ?? null);
|
||||
|
||||
case 'skills':
|
||||
return is_array($data) && count($data) > 0;
|
||||
|
||||
default:
|
||||
return !empty($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique slug for the resume.
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
{
|
||||
$baseSlug = str()->slug($this->title);
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while (static::where('slug', $slug)->where('id', '!=', $this->id)->exists()) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique public URL for the resume.
|
||||
*/
|
||||
public function generatePublicUrl(): string
|
||||
{
|
||||
return str()->random(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the resume as completed.
|
||||
*/
|
||||
public function markAsCompleted(): void
|
||||
{
|
||||
$this->update(['is_completed' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active resumes.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include completed resumes.
|
||||
*/
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('is_completed', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include public resumes.
|
||||
*/
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model.
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($resume) {
|
||||
if (empty($resume->slug)) {
|
||||
$resume->slug = $resume->generateSlug();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($resume) {
|
||||
if ($resume->isDirty('title')) {
|
||||
$resume->slug = $resume->generateSlug();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
191
app/Models/User.php
Normal file
191
app/Models/User.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* User Model
|
||||
* Professional Resume Builder - User Entity
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* User Model
|
||||
* Represents application users with authentication and profile features
|
||||
*/
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'date_of_birth',
|
||||
'gender',
|
||||
'avatar',
|
||||
'bio',
|
||||
'website',
|
||||
'linkedin',
|
||||
'github',
|
||||
'twitter',
|
||||
'preferences',
|
||||
'newsletter_subscribed',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'status',
|
||||
'suspended_at',
|
||||
'suspension_reason',
|
||||
'login_attempts',
|
||||
'locked_until',
|
||||
'locale',
|
||||
'timezone',
|
||||
'two_factor_enabled',
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'profile_completed_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'date_of_birth' => 'date',
|
||||
'suspended_at' => 'datetime',
|
||||
'locked_until' => 'datetime',
|
||||
'profile_completed_at' => 'datetime',
|
||||
'newsletter_subscribed' => 'boolean',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'preferences' => 'array',
|
||||
'two_factor_recovery_codes' => 'array',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's full name.
|
||||
*/
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return "{$this->first_name} {$this->last_name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials.
|
||||
*/
|
||||
public function getInitialsAttribute(): string
|
||||
{
|
||||
return strtoupper(substr($this->first_name, 0, 1) . substr($this->last_name, 0, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's avatar URL.
|
||||
*/
|
||||
public function getAvatarUrlAttribute(): string
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return asset('storage/avatars/' . $this->avatar);
|
||||
}
|
||||
|
||||
return "https://ui-avatars.com/api/?name={$this->initials}&background=667eea&color=fff&size=200";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has completed their profile.
|
||||
*/
|
||||
public function hasCompletedProfile(): bool
|
||||
{
|
||||
$requiredFields = ['first_name', 'last_name', 'email', 'phone', 'city', 'country'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (empty($this->$field)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's resumes.
|
||||
*/
|
||||
public function resumes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Resume::class)->orderBy('updated_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's active resumes.
|
||||
*/
|
||||
public function activeResumes(): HasMany
|
||||
{
|
||||
return $this->resumes()->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's completed resumes.
|
||||
*/
|
||||
public function completedResumes(): HasMany
|
||||
{
|
||||
return $this->resumes()->where('is_completed', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login information.
|
||||
*/
|
||||
public function updateLastLogin(string $ipAddress): void
|
||||
{
|
||||
$this->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => $ipAddress,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active users.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include verified users.
|
||||
*/
|
||||
public function scopeVerified($query)
|
||||
{
|
||||
return $query->whereNotNull('email_verified_at');
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
26
app/Providers/AuthServiceProvider.php
Normal file
26
app/Providers/AuthServiceProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
// use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The model to policy mappings for the application.
|
||||
*
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
//
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
19
app/Providers/BroadcastServiceProvider.php
Normal file
19
app/Providers/BroadcastServiceProvider.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BroadcastServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Broadcast::routes();
|
||||
|
||||
require base_path('routes/channels.php');
|
||||
}
|
||||
}
|
||||
38
app/Providers/EventServiceProvider.php
Normal file
38
app/Providers/EventServiceProvider.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event to listener mappings for the application.
|
||||
*
|
||||
* @var array<class-string, array<int, class-string>>
|
||||
*/
|
||||
protected $listen = [
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any events for your application.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if events and listeners should be automatically discovered.
|
||||
*/
|
||||
public function shouldDiscoverEvents(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
63
app/Providers/RepositoryServiceProvider.php
Normal file
63
app/Providers/RepositoryServiceProvider.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
// Interfaces
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Interfaces\ResumeRepositoryInterface;
|
||||
|
||||
// Repositories
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Repositories\ResumeRepository;
|
||||
|
||||
// Models
|
||||
use App\Models\User;
|
||||
use App\Models\Resume;
|
||||
|
||||
/**
|
||||
* Repository Service Provider
|
||||
*
|
||||
* Binds repository interfaces to their concrete implementations.
|
||||
* Enables dependency injection throughout the application.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class RepositoryServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* All of the container bindings that should be registered.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public array $bindings = [
|
||||
UserRepositoryInterface::class => UserRepository::class,
|
||||
ResumeRepositoryInterface::class => ResumeRepository::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Bind User Repository
|
||||
$this->app->bind(UserRepositoryInterface::class, function ($app) {
|
||||
return new UserRepository(new User());
|
||||
});
|
||||
|
||||
// Bind Resume Repository
|
||||
$this->app->bind(ResumeRepositoryInterface::class, function ($app) {
|
||||
return new ResumeRepository(new Resume());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
40
app/Providers/RouteServiceProvider.php
Normal file
40
app/Providers/RouteServiceProvider.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The path to your application's "home" route.
|
||||
*
|
||||
* Typically, users are redirected here after authentication.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const HOME = '/home';
|
||||
|
||||
/**
|
||||
* Define your route model bindings, pattern filters, and other route configuration.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web.php'));
|
||||
});
|
||||
}
|
||||
}
|
||||
345
app/Repositories/BaseRepository.php
Normal file
345
app/Repositories/BaseRepository.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\BaseRepositoryInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
/**
|
||||
* Base Repository Implementation
|
||||
*
|
||||
* Provides common repository functionality for all models.
|
||||
* Implements the BaseRepositoryInterface with reusable methods.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
abstract class BaseRepository implements BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* The model instance
|
||||
*
|
||||
* @var Model
|
||||
*/
|
||||
protected Model $model;
|
||||
|
||||
/**
|
||||
* The query builder instance
|
||||
*
|
||||
* @var Builder
|
||||
*/
|
||||
protected Builder $query;
|
||||
|
||||
/**
|
||||
* Create a new repository instance
|
||||
*
|
||||
* @param Model $model
|
||||
*/
|
||||
public function __construct(Model $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->resetQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records
|
||||
*
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function all(array $columns = ['*']): Collection
|
||||
{
|
||||
$result = $this->query->get($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records with pagination
|
||||
*
|
||||
* @param int $perPage
|
||||
* @param array $columns
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator
|
||||
{
|
||||
$result = $this->query->paginate($perPage, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function find(int $id, array $columns = ['*']): ?Model
|
||||
{
|
||||
$result = $this->query->find($id, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record by ID or throw an exception
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function findOrFail(int $id, array $columns = ['*']): Model
|
||||
{
|
||||
$result = $this->query->findOrFail($id, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function findBy(array $criteria, array $columns = ['*']): Collection
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->get($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function findOneBy(array $criteria, array $columns = ['*']): ?Model
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->first($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*
|
||||
* @param array $data
|
||||
* @return Model
|
||||
*/
|
||||
public function create(array $data): Model
|
||||
{
|
||||
return $this->model->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$model = $this->findOrFail($id);
|
||||
return $model->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a record
|
||||
*
|
||||
* @param array $attributes
|
||||
* @param array $values
|
||||
* @return Model
|
||||
*/
|
||||
public function updateOrCreate(array $attributes, array $values = []): Model
|
||||
{
|
||||
return $this->model->updateOrCreate($attributes, $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$model = $this->findOrFail($id);
|
||||
return $model->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteBy(array $criteria): int
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->delete();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int
|
||||
*/
|
||||
public function count(array $criteria = []): int
|
||||
{
|
||||
if (!empty($criteria)) {
|
||||
$this->applyCriteria($criteria);
|
||||
}
|
||||
|
||||
$result = $this->query->count();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if record exists
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return bool
|
||||
*/
|
||||
public function exists(array $criteria): bool
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->exists();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records with relationships
|
||||
*
|
||||
* @param array $relations
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function with(array $relations): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->with($relations);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order records
|
||||
*
|
||||
* @param string $column
|
||||
* @param string $direction
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->orderBy($column, $direction);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit records
|
||||
*
|
||||
* @param int $limit
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function limit(int $limit): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->limit($limit);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply criteria to the query
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return void
|
||||
*/
|
||||
protected function applyCriteria(array $criteria): void
|
||||
{
|
||||
foreach ($criteria as $field => $value) {
|
||||
if (is_array($value)) {
|
||||
$this->query = $this->query->whereIn($field, $value);
|
||||
} elseif (is_null($value)) {
|
||||
$this->query = $this->query->whereNull($field);
|
||||
} else {
|
||||
$this->query = $this->query->where($field, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the query builder
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function resetQuery(): void
|
||||
{
|
||||
$this->query = $this->model->newQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model instance
|
||||
*
|
||||
* @return Model
|
||||
*/
|
||||
public function getModel(): Model
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the model instance
|
||||
*
|
||||
* @param Model $model
|
||||
* @return void
|
||||
*/
|
||||
public function setModel(Model $model): void
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->resetQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback within a database transaction
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function transaction(callable $callback)
|
||||
{
|
||||
return $this->model->getConnection()->transaction($callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh timestamp for the model
|
||||
*
|
||||
* @return \Carbon\Carbon
|
||||
*/
|
||||
protected function freshTimestamp(): \Carbon\Carbon
|
||||
{
|
||||
return $this->model->freshTimestamp();
|
||||
}
|
||||
}
|
||||
522
app/Repositories/ResumeRepository.php
Normal file
522
app/Repositories/ResumeRepository.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\ResumeRepositoryInterface;
|
||||
use App\Models\Resume;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Resume Repository Implementation
|
||||
*
|
||||
* Handles all resume-related data operations.
|
||||
* Implements ResumeRepositoryInterface with resume-specific business logic.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class ResumeRepository extends BaseRepository implements ResumeRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Create a new ResumeRepository instance
|
||||
*
|
||||
* @param Resume $model
|
||||
*/
|
||||
public function __construct(Resume $model)
|
||||
{
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUserResumes(int $userId, array $columns = ['*']): Collection
|
||||
{
|
||||
return $this->orderBy('updated_at', 'desc')
|
||||
->findBy(['user_id' => $userId], $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resumes with pagination
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
$result = $this->query
|
||||
->where('user_id', $userId)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate($perPage);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find resume by public URL
|
||||
*
|
||||
* @param string $publicUrl
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function findByPublicUrl(string $publicUrl): ?Resume
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'public_url' => $publicUrl,
|
||||
'is_public' => true,
|
||||
'status' => 'published'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPublicResumes(int $limit = 20): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', Carbon::now())
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes by template
|
||||
*
|
||||
* @param string $template
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByTemplate(string $template): Collection
|
||||
{
|
||||
return $this->orderBy('created_at', 'desc')
|
||||
->findBy(['template' => $template]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByStatus(string $status): Collection
|
||||
{
|
||||
return $this->orderBy('updated_at', 'desc')
|
||||
->findBy(['status' => $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular resumes (most viewed)
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPopularResumes(int $limit = 10, int $days = 30): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('last_viewed_at', '>=', $cutoffDate)
|
||||
->orderBy('view_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getRecentResumes(int $limit = 10, int $days = 7): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('published_at', '>=', $cutoffDate)
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search resumes
|
||||
*
|
||||
* @param string $query
|
||||
* @param array $filters
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection
|
||||
{
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
$queryBuilder = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'like', $searchTerm)
|
||||
->orWhere('description', 'like', $searchTerm)
|
||||
->orWhereJsonContains('content->summary', $searchTerm)
|
||||
->orWhereJsonLike('content->skills', $searchTerm);
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['template']) && !empty($filters['template'])) {
|
||||
$queryBuilder = $queryBuilder->where('template', $filters['template']);
|
||||
}
|
||||
|
||||
if (isset($filters['min_completion']) && is_numeric($filters['min_completion'])) {
|
||||
$queryBuilder = $queryBuilder->where('completion_percentage', '>=', $filters['min_completion']);
|
||||
}
|
||||
|
||||
$result = $queryBuilder
|
||||
->orderBy('view_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incomplete resumes
|
||||
*
|
||||
* @param int $threshold
|
||||
* @return Collection
|
||||
*/
|
||||
public function getIncompleteResumes(int $threshold = 50): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('completion_percentage', '<', $threshold)
|
||||
->where('status', 'draft')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with(['user:id,first_name,last_name,email'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate resume for user
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @param int $userId
|
||||
* @param string $newTitle
|
||||
* @return Resume
|
||||
*/
|
||||
public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume
|
||||
{
|
||||
return $this->transaction(function () use ($resumeId, $userId, $newTitle) {
|
||||
$originalResume = $this->findOrFail($resumeId);
|
||||
|
||||
$duplicateData = $originalResume->toArray();
|
||||
|
||||
// Remove unique fields and reset for new resume
|
||||
unset($duplicateData['id'], $duplicateData['created_at'], $duplicateData['updated_at'], $duplicateData['deleted_at']);
|
||||
|
||||
$duplicateData['user_id'] = $userId;
|
||||
$duplicateData['title'] = $newTitle;
|
||||
$duplicateData['status'] = 'draft';
|
||||
$duplicateData['is_public'] = false;
|
||||
$duplicateData['public_url'] = null;
|
||||
$duplicateData['published_at'] = null;
|
||||
$duplicateData['view_count'] = 0;
|
||||
$duplicateData['download_count'] = 0;
|
||||
$duplicateData['last_viewed_at'] = null;
|
||||
$duplicateData['last_downloaded_at'] = null;
|
||||
$duplicateData['pdf_path'] = null;
|
||||
$duplicateData['pdf_generated_at'] = null;
|
||||
$duplicateData['pdf_version'] = 1;
|
||||
|
||||
return $this->create($duplicateData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume view count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementViewCount(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resume->update([
|
||||
'view_count' => $resume->view_count + 1,
|
||||
'last_viewed_at' => $this->freshTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume download count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementDownloadCount(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resume->update([
|
||||
'download_count' => $resume->download_count + 1,
|
||||
'last_downloaded_at' => $this->freshTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume statistics
|
||||
*
|
||||
* @param int|null $userId
|
||||
* @return array
|
||||
*/
|
||||
public function getResumeStatistics(?int $userId = null): array
|
||||
{
|
||||
$query = $this->query;
|
||||
|
||||
if ($userId) {
|
||||
$query = $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'total_resumes' => $query->count(),
|
||||
'draft_resumes' => $query->where('status', 'draft')->count(),
|
||||
'published_resumes' => $query->where('status', 'published')->count(),
|
||||
'archived_resumes' => $query->where('status', 'archived')->count(),
|
||||
'public_resumes' => $query->where('is_public', true)->count(),
|
||||
'private_resumes' => $query->where('is_public', false)->count(),
|
||||
];
|
||||
|
||||
// Template statistics
|
||||
$templateStats = $query->select('template', DB::raw('count(*) as count'))
|
||||
->groupBy('template')
|
||||
->pluck('count', 'template')
|
||||
->toArray();
|
||||
$stats['by_template'] = $templateStats;
|
||||
|
||||
// Completion statistics
|
||||
$stats['avg_completion'] = $query->avg('completion_percentage') ?? 0;
|
||||
$stats['completed_resumes'] = $query->where('completion_percentage', '>=', 90)->count();
|
||||
|
||||
// View statistics
|
||||
$stats['total_views'] = $query->sum('view_count');
|
||||
$stats['total_downloads'] = $query->sum('download_count');
|
||||
|
||||
// Recent activity (last 30 days)
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
$stats['created_last_30_days'] = $query->where('created_at', '>=', $thirtyDaysAgo)->count();
|
||||
$stats['updated_last_30_days'] = $query->where('updated_at', '>=', $thirtyDaysAgo)->count();
|
||||
|
||||
$this->resetQuery();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes requiring PDF regeneration
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesNeedingPdfRegeneration(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('status', 'published')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('pdf_generated_at')
|
||||
->orWhere('updated_at', '>', DB::raw('pdf_generated_at'));
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive old resumes
|
||||
*
|
||||
* @param int $days
|
||||
* @return int Number of archived resumes
|
||||
*/
|
||||
public function archiveOldResumes(int $days = 365): int
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('status', 'draft')
|
||||
->where('updated_at', '<', $cutoffDate)
|
||||
->update(['status' => 'archived']);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resume by title
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $title
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function getUserResumeByTitle(int $userId, string $title): ?Resume
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'user_id' => $userId,
|
||||
'title' => $title
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create more resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $limit
|
||||
* @return bool
|
||||
*/
|
||||
public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool
|
||||
{
|
||||
$userResumeCount = $this->count(['user_id' => $userId]);
|
||||
return $userResumeCount < $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured resumes for showcase
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getFeaturedResumes(int $limit = 6): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('completion_percentage', '>=', 90)
|
||||
->where('view_count', '>', 50)
|
||||
->orderBy('view_count', 'desc')
|
||||
->orderBy('download_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume completion percentage
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function updateCompletionPercentage(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = $resume->content ?? [];
|
||||
$sections = ['personal_info', 'summary', 'experience', 'education', 'skills'];
|
||||
$completedSections = 0;
|
||||
$totalSections = count($sections);
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if (isset($content[$section]) && !empty($content[$section])) {
|
||||
$completedSections++;
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = $totalSections > 0 ? intval(($completedSections / $totalSections) * 100) : 0;
|
||||
|
||||
return $resume->update([
|
||||
'completion_percentage' => $percentage,
|
||||
'completion_sections' => array_fill_keys($sections, false)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes expiring soon
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesExpiringSoon(int $days = 7): Collection
|
||||
{
|
||||
$futureDate = Carbon::now()->addDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->whereNotNull('expires_at')
|
||||
->whereBetween('expires_at', [Carbon::now(), $futureDate])
|
||||
->orderBy('expires_at', 'asc')
|
||||
->with(['user:id,first_name,last_name,email'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update resume status
|
||||
*
|
||||
* @param array $resumeIds
|
||||
* @param string $status
|
||||
* @return int Number of updated resumes
|
||||
*/
|
||||
public function bulkUpdateStatus(array $resumeIds, string $status): int
|
||||
{
|
||||
$result = $this->query
|
||||
->whereIn('id', $resumeIds)
|
||||
->update([
|
||||
'status' => $status,
|
||||
'updated_at' => $this->freshTimestamp()
|
||||
]);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
410
app/Repositories/UserRepository.php
Normal file
410
app/Repositories/UserRepository.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* User Repository Implementation
|
||||
*
|
||||
* Handles all user-related data operations.
|
||||
* Implements UserRepositoryInterface with user-specific business logic.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class UserRepository extends BaseRepository implements UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Create a new UserRepository instance
|
||||
*
|
||||
* @param User $model
|
||||
*/
|
||||
public function __construct(User $model)
|
||||
{
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by email address
|
||||
*
|
||||
* @param string $email
|
||||
* @return User|null
|
||||
*/
|
||||
public function findByEmail(string $email): ?User
|
||||
{
|
||||
return $this->findOneBy(['email' => $email]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function findByStatus(string $status): Collection
|
||||
{
|
||||
return $this->findBy(['status' => $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who haven't logged in recently
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getInactiveUsers(int $days = 30): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where(function ($query) use ($cutoffDate) {
|
||||
$query->where('last_login_at', '<', $cutoffDate)
|
||||
->orWhereNull('last_login_at');
|
||||
})
|
||||
->where('status', 'active')
|
||||
->orderBy('last_login_at', 'asc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with completed profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithCompletedProfiles(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereNotNull('profile_completed_at')
|
||||
->where('status', 'active')
|
||||
->orderBy('profile_completed_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with incomplete profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithIncompleteProfiles(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereNull('profile_completed_at')
|
||||
->where('status', 'active')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users subscribed to newsletter
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getNewsletterSubscribers(): Collection
|
||||
{
|
||||
return $this->findBy([
|
||||
'newsletter_subscribed' => true,
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by name or email
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchUsers(string $query, int $limit = 10): Collection
|
||||
{
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
$result = $this->query
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('first_name', 'like', $searchTerm)
|
||||
->orWhere('last_name', 'like', $searchTerm)
|
||||
->orWhere('email', 'like', $searchTerm)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$searchTerm]);
|
||||
})
|
||||
->where('status', 'active')
|
||||
->orderBy('last_login_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve comprehensive user statistics and analytics
|
||||
*
|
||||
* Aggregates key user metrics including:
|
||||
* - Total users by status (active, inactive, suspended)
|
||||
* - Email verification status counts
|
||||
* - Registration trends (30-day window)
|
||||
* - Profile completion metrics
|
||||
* - Newsletter subscription data
|
||||
* - Daily active user statistics
|
||||
*
|
||||
* Used for admin dashboards, reporting, and user behavior analysis.
|
||||
*
|
||||
* @return array Comprehensive statistics array with user metrics
|
||||
* @throws Exception When database queries fail
|
||||
*/
|
||||
public function getUserStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => $this->count(),
|
||||
'active_users' => $this->count(['status' => 'active']),
|
||||
'inactive_users' => $this->count(['status' => 'inactive']),
|
||||
'suspended_users' => $this->count(['status' => 'suspended']),
|
||||
'verified_users' => $this->query->whereNotNull('email_verified_at')->count(),
|
||||
'unverified_users' => $this->query->whereNull('email_verified_at')->count(),
|
||||
];
|
||||
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
$stats['new_users_30_days'] = $this->query
|
||||
->where('created_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$stats['completed_profiles'] = $this->query
|
||||
->whereNotNull('profile_completed_at')
|
||||
->count();
|
||||
|
||||
$stats['newsletter_subscribers'] = $this->count(['newsletter_subscribed' => true]);
|
||||
|
||||
$stats['active_today'] = $this->query
|
||||
->whereDate('last_login_at', Carbon::today())
|
||||
->count();
|
||||
|
||||
$this->resetQuery();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login metadata with security tracking
|
||||
*
|
||||
* Records successful login timestamp, IP address, and resets
|
||||
* security-related fields including login attempts and account locks.
|
||||
* Essential for authentication audit trails and security monitoring.
|
||||
*
|
||||
* @param int $userId The user ID to update
|
||||
* @param string $ipAddress Client IP address for security tracking
|
||||
* @return bool True if update succeeded, false otherwise
|
||||
* @throws Exception When user record update fails
|
||||
*/
|
||||
public function updateLastLogin(int $userId, string $ipAddress): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'last_login_at' => $this->freshTimestamp(),
|
||||
'last_login_ip' => $ipAddress,
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed login attempt counter for user account
|
||||
*
|
||||
* Increases the login_attempts field by 1 for security tracking.
|
||||
* Used in conjunction with account locking mechanisms to prevent
|
||||
* brute force attacks. Should be called after each failed login.
|
||||
*
|
||||
* @param string $email User email address to track attempts for
|
||||
* @return bool True if increment succeeded, false if user not found
|
||||
*/
|
||||
public function incrementLoginAttempts(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->increment('login_attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user's login attempts
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function resetLoginAttempts(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->update([
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily lock user account for security purposes
|
||||
*
|
||||
* Implements account lockout mechanism to prevent brute force attacks
|
||||
* by setting locked_until timestamp. Account remains inaccessible
|
||||
* until the lockout period expires or is manually cleared.
|
||||
*
|
||||
* @param string $email User email address to lock
|
||||
* @param int $minutes Lockout duration in minutes (default: 15)
|
||||
* @return bool True if lock applied successfully, false if user not found
|
||||
*/
|
||||
public function lockAccount(string $email, int $minutes = 15): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->update([
|
||||
'locked_until' => Carbon::now()->addMinutes($minutes),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if user account is currently locked
|
||||
*
|
||||
* Validates account lockout status by checking if locked_until
|
||||
* timestamp exists and is still in the future. Used before
|
||||
* authentication attempts to enforce security policies.
|
||||
*
|
||||
* @param string $email User email address to check
|
||||
* @return bool True if account is locked, false if accessible or user not found
|
||||
*/
|
||||
public function isAccountLocked(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user || !$user->locked_until) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Carbon::now()->lessThan($user->locked_until);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $reason
|
||||
* @return bool
|
||||
*/
|
||||
public function suspendAccount(int $userId, string $reason): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'status' => 'suspended',
|
||||
'suspended_at' => $this->freshTimestamp(),
|
||||
'suspension_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function reactivateAccount(int $userId): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'status' => 'active',
|
||||
'suspended_at' => null,
|
||||
'suspension_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users registered in date range
|
||||
*
|
||||
* @param string $startDate
|
||||
* @param string $endDate
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated users with filters
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->query;
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['status']) && !empty($filters['status'])) {
|
||||
$query = $query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['verified']) && $filters['verified'] !== '') {
|
||||
if ($filters['verified']) {
|
||||
$query = $query->whereNotNull('email_verified_at');
|
||||
} else {
|
||||
$query = $query->whereNull('email_verified_at');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['newsletter']) && $filters['newsletter'] !== '') {
|
||||
$query = $query->where('newsletter_subscribed', (bool) $filters['newsletter']);
|
||||
}
|
||||
|
||||
if (isset($filters['search']) && !empty($filters['search'])) {
|
||||
$searchTerm = '%' . $filters['search'] . '%';
|
||||
$query = $query->where(function ($q) use ($searchTerm) {
|
||||
$q->where('first_name', 'like', $searchTerm)
|
||||
->orWhere('last_name', 'like', $searchTerm)
|
||||
->orWhere('email', 'like', $searchTerm);
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['date_from']) && !empty($filters['date_from'])) {
|
||||
$query = $query->whereDate('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to']) && !empty($filters['date_to'])) {
|
||||
$query = $query->whereDate('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Default ordering
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$query = $query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
$result = $query->paginate($perPage);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
447
app/Services/AuthService.php
Normal file
447
app/Services/AuthService.php
Normal file
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Professional Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles user authentication, registration, and security features with repository pattern
|
||||
*/
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* Maximum login attempts per minute
|
||||
*/
|
||||
protected const MAX_LOGIN_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Login throttle key prefix
|
||||
*/
|
||||
protected const THROTTLE_KEY_PREFIX = 'login_attempts:';
|
||||
|
||||
/**
|
||||
* Account lockout duration in minutes
|
||||
*/
|
||||
protected const LOCKOUT_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Create a new service instance with dependency injection
|
||||
*/
|
||||
public function __construct(UserRepositoryInterface $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt user authentication with comprehensive security measures
|
||||
*
|
||||
* Implements enterprise-grade authentication with:
|
||||
* - Rate limiting to prevent brute force attacks
|
||||
* - Account lockout mechanisms for security
|
||||
* - Detailed security event logging
|
||||
* - Structured response format for consistent handling
|
||||
*
|
||||
* @param array $credentials User login credentials (email, password, remember)
|
||||
* @return array Structured authentication result with success status and details
|
||||
* @throws ValidationException When rate limiting is exceeded or validation fails
|
||||
* @throws Exception When system errors occur during authentication
|
||||
*/
|
||||
public function attemptLogin(array $credentials): array
|
||||
{
|
||||
$email = $credentials['email'];
|
||||
$password = $credentials['password'];
|
||||
$remember = $credentials['remember'] ?? false;
|
||||
|
||||
try {
|
||||
$this->checkRateLimit($email);
|
||||
|
||||
if ($this->userRepository->isAccountLocked($email)) {
|
||||
Log::warning('Login attempt on locked account', [
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'account_locked',
|
||||
'message' => 'Account is temporarily locked'
|
||||
];
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
$this->incrementRateLimit($email);
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'invalid_credentials',
|
||||
'message' => 'User not found'
|
||||
];
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
$this->incrementRateLimit($email);
|
||||
|
||||
Log::warning('Login attempt on inactive account', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'account_inactive',
|
||||
'message' => 'Account is deactivated'
|
||||
];
|
||||
}
|
||||
|
||||
if (!Hash::check($password, $user->password)) {
|
||||
$this->incrementRateLimit($email);
|
||||
$this->userRepository->incrementLoginAttempts($email);
|
||||
|
||||
$updatedUser = $this->userRepository->findByEmail($email);
|
||||
if ($updatedUser && $updatedUser->login_attempts >= self::MAX_LOGIN_ATTEMPTS) {
|
||||
$this->userRepository->lockAccount($email, self::LOCKOUT_DURATION);
|
||||
|
||||
Log::warning('Account locked due to excessive failed attempts', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'attempts' => $updatedUser->login_attempts
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'too_many_attempts',
|
||||
'message' => 'Account locked due to failed attempts'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'invalid_credentials',
|
||||
'message' => 'Invalid password'
|
||||
];
|
||||
}
|
||||
|
||||
Auth::login($user, $remember);
|
||||
|
||||
$this->userRepository->updateLastLogin($user->id, request()->ip());
|
||||
$this->userRepository->resetLoginAttempts($email);
|
||||
$this->clearRateLimit($email);
|
||||
|
||||
Log::info('Successful user authentication', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user' => $user,
|
||||
'message' => 'Login successful'
|
||||
];
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (Exception $e) {
|
||||
Log::error('Authentication service error', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'system_error',
|
||||
'message' => 'System error occurred'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user account with comprehensive security validation
|
||||
*
|
||||
* Implements secure user registration with:
|
||||
* - Duplicate email detection using repository layer
|
||||
* - Secure password hashing with bcrypt
|
||||
* - Security-focused default settings
|
||||
* - Comprehensive audit logging
|
||||
* - Prepared data structure for repository storage
|
||||
*
|
||||
* @param array $userData User registration data (first_name, last_name, email, password)
|
||||
* @return User The created user instance
|
||||
* @throws ValidationException When email already exists or validation fails
|
||||
* @throws Exception When user creation fails due to system errors
|
||||
*/
|
||||
public function createUser(array $userData): User
|
||||
{
|
||||
try {
|
||||
if ($this->userRepository->findByEmail($userData['email'])) {
|
||||
Log::warning('Registration attempt with existing email', [
|
||||
'email' => $userData['email'],
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'An account with this email address already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$preparedData = [
|
||||
'first_name' => $userData['first_name'],
|
||||
'last_name' => $userData['last_name'],
|
||||
'email' => $userData['email'],
|
||||
'password' => Hash::make($userData['password']),
|
||||
'status' => 'active',
|
||||
'locale' => $userData['locale'] ?? 'en',
|
||||
'timezone' => $userData['timezone'] ?? 'UTC',
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
'email_verified_at' => null,
|
||||
'last_login_at' => null,
|
||||
'last_login_ip' => null
|
||||
];
|
||||
|
||||
$user = $this->userRepository->create($preparedData);
|
||||
|
||||
if ($user) {
|
||||
Log::info('New user account created successfully', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent()
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (Exception $e) {
|
||||
Log::error('User creation failed', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'email' => $userData['email'] ?? 'unknown',
|
||||
'ip_address' => request()->ip(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
throw new Exception('Failed to create user account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile information
|
||||
*/
|
||||
public function updateProfile(User $user, array $profileData): User
|
||||
{
|
||||
unset($profileData['password'], $profileData['email'], $profileData['is_active']);
|
||||
|
||||
$user->update($profileData);
|
||||
|
||||
logger()->info('User profile updated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
public function changePassword(User $user, string $currentPassword, string $newPassword): bool
|
||||
{
|
||||
if (!Hash::check($currentPassword, $user->password)) {
|
||||
throw new \InvalidArgumentException('Current password is incorrect.');
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($newPassword)]);
|
||||
|
||||
logger()->info('User password changed', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user account
|
||||
*/
|
||||
public function deactivateAccount(User $user): bool
|
||||
{
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
logger()->info('User account deactivated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate user account
|
||||
*/
|
||||
public function activateAccount(User $user): bool
|
||||
{
|
||||
$user->update(['is_active' => true]);
|
||||
|
||||
logger()->info('User account activated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded login rate limit
|
||||
*/
|
||||
protected function checkRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_LOGIN_ATTEMPTS)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => "Too many login attempts. Please try again in {$seconds} seconds.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment rate limit counter
|
||||
*/
|
||||
protected function incrementRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rate limit counter
|
||||
*/
|
||||
protected function clearRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit key for email
|
||||
*/
|
||||
protected function getRateLimitKey(string $email): string
|
||||
{
|
||||
return self::THROTTLE_KEY_PREFIX . $email . '|' . request()->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset notification
|
||||
*/
|
||||
public function sendPasswordResetNotification(string $email): bool
|
||||
{
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generate and send password reset token
|
||||
// Implementation depends on your notification preferences
|
||||
// $user->sendPasswordResetNotification($token);
|
||||
|
||||
logger()->info('Password reset requested', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email address
|
||||
*/
|
||||
public function verifyEmail(User $user): bool
|
||||
{
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user->markEmailAsVerified();
|
||||
|
||||
logger()->info('Email verified', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*/
|
||||
public function getUserStats(User $user): array
|
||||
{
|
||||
return [
|
||||
'account_created' => $user->created_at,
|
||||
'last_login' => $user->last_login_at,
|
||||
'profile_completion' => $this->calculateProfileCompletion($user),
|
||||
'total_resumes' => $user->resumes()->count(),
|
||||
'completed_resumes' => $user->completedResumes()->count(),
|
||||
'is_verified' => $user->hasVerifiedEmail(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate profile completion percentage
|
||||
*/
|
||||
protected function calculateProfileCompletion(User $user): int
|
||||
{
|
||||
$fields = [
|
||||
'first_name', 'last_name', 'email', 'phone',
|
||||
'address', 'city', 'country', 'profession'
|
||||
];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($user->$field)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($completed / count($fields)) * 100);
|
||||
}
|
||||
}
|
||||
127
app/Services/ProfileCompletionService.php
Normal file
127
app/Services/ProfileCompletionService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Profile Completion Service
|
||||
*
|
||||
* Handles user profile completion calculations and analytics.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class ProfileCompletionService
|
||||
{
|
||||
/**
|
||||
* Required fields for profile completion
|
||||
*/
|
||||
private const REQUIRED_FIELDS = [
|
||||
'first_name' => 10,
|
||||
'last_name' => 10,
|
||||
'email' => 15,
|
||||
'phone' => 10,
|
||||
'bio' => 20,
|
||||
'website' => 10,
|
||||
'linkedin' => 15,
|
||||
'github' => 10,
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculate profile completion percentage
|
||||
*/
|
||||
public function calculateCompletion(User $user): int
|
||||
{
|
||||
$totalWeight = array_sum(self::REQUIRED_FIELDS);
|
||||
$completedWeight = 0;
|
||||
|
||||
foreach (self::REQUIRED_FIELDS as $field => $weight) {
|
||||
if (!empty($user->$field)) {
|
||||
$completedWeight += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($completedWeight / $totalWeight) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get missing profile fields
|
||||
*/
|
||||
public function getMissingFields(User $user): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
foreach (self::REQUIRED_FIELDS as $field => $weight) {
|
||||
if (empty($user->$field)) {
|
||||
$missing[] = [
|
||||
'field' => $field,
|
||||
'label' => $this->getFieldLabel($field),
|
||||
'weight' => $weight,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly field labels
|
||||
*/
|
||||
private function getFieldLabel(string $field): string
|
||||
{
|
||||
$labels = [
|
||||
'first_name' => 'First Name',
|
||||
'last_name' => 'Last Name',
|
||||
'email' => 'Email Address',
|
||||
'phone' => 'Phone Number',
|
||||
'bio' => 'Professional Bio',
|
||||
'website' => 'Website',
|
||||
'linkedin' => 'LinkedIn Profile',
|
||||
'github' => 'GitHub Profile',
|
||||
];
|
||||
|
||||
return $labels[$field] ?? ucfirst(str_replace('_', ' ', $field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if profile is considered complete
|
||||
*/
|
||||
public function isProfileComplete(User $user): bool
|
||||
{
|
||||
return $this->calculateCompletion($user) >= 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile completion statistics
|
||||
*/
|
||||
public function getCompletionStats(User $user): array
|
||||
{
|
||||
$completion = $this->calculateCompletion($user);
|
||||
$missingFields = $this->getMissingFields($user);
|
||||
|
||||
return [
|
||||
'completion_percentage' => $completion,
|
||||
'is_complete' => $this->isProfileComplete($user),
|
||||
'missing_fields' => $missingFields,
|
||||
'missing_count' => count($missingFields),
|
||||
'status' => $this->getCompletionStatus($completion),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completion status message
|
||||
*/
|
||||
private function getCompletionStatus(int $completion): string
|
||||
{
|
||||
if ($completion >= 90) {
|
||||
return 'excellent';
|
||||
} elseif ($completion >= 70) {
|
||||
return 'good';
|
||||
} elseif ($completion >= 50) {
|
||||
return 'fair';
|
||||
} else {
|
||||
return 'incomplete';
|
||||
}
|
||||
}
|
||||
}
|
||||
380
app/Services/ResumeService.php
Normal file
380
app/Services/ResumeService.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Resume Service
|
||||
* Professional Resume Builder - Resume Management Service
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Resume;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Response;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
/**
|
||||
* Resume Service
|
||||
* Handles resume creation, management, and operations
|
||||
*/
|
||||
class ResumeService
|
||||
{
|
||||
/**
|
||||
* Available resume templates
|
||||
*/
|
||||
protected array $templates = [
|
||||
'professional' => [
|
||||
'id' => 'professional',
|
||||
'name' => 'Professional Classic',
|
||||
'description' => 'Clean and professional design perfect for corporate environments',
|
||||
'preview' => 'templates/professional-preview.jpg',
|
||||
'category' => 'Professional'
|
||||
],
|
||||
'modern' => [
|
||||
'id' => 'modern',
|
||||
'name' => 'Modern Creative',
|
||||
'description' => 'Contemporary design with creative elements for modern companies',
|
||||
'preview' => 'templates/modern-preview.jpg',
|
||||
'category' => 'Creative'
|
||||
],
|
||||
'executive' => [
|
||||
'id' => 'executive',
|
||||
'name' => 'Executive',
|
||||
'description' => 'Sophisticated layout for senior-level positions',
|
||||
'preview' => 'templates/executive-preview.jpg',
|
||||
'category' => 'Executive'
|
||||
],
|
||||
'minimal' => [
|
||||
'id' => 'minimal',
|
||||
'name' => 'Minimal Clean',
|
||||
'description' => 'Simple and clean design focusing on content',
|
||||
'preview' => 'templates/minimal-preview.jpg',
|
||||
'category' => 'Minimal'
|
||||
],
|
||||
'technical' => [
|
||||
'id' => 'technical',
|
||||
'name' => 'Technical',
|
||||
'description' => 'Optimized for technical and engineering roles',
|
||||
'preview' => 'templates/technical-preview.jpg',
|
||||
'category' => 'Technical'
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all resumes for a specific user
|
||||
*/
|
||||
public function getUserResumes(int $userId): Collection
|
||||
{
|
||||
return Resume::where('user_id', $userId)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new resume
|
||||
*/
|
||||
public function createResume(array $resumeData): Resume
|
||||
{
|
||||
// Set default values
|
||||
$resumeData['is_active'] = true;
|
||||
$resumeData['is_completed'] = false;
|
||||
$resumeData['is_public'] = false;
|
||||
|
||||
// Initialize empty sections
|
||||
$resumeData['personal_info'] = $resumeData['personal_info'] ?? [];
|
||||
$resumeData['work_experiences'] = $resumeData['work_experiences'] ?? [];
|
||||
$resumeData['education'] = $resumeData['education'] ?? [];
|
||||
$resumeData['skills'] = $resumeData['skills'] ?? [];
|
||||
$resumeData['languages'] = $resumeData['languages'] ?? [];
|
||||
$resumeData['certifications'] = $resumeData['certifications'] ?? [];
|
||||
$resumeData['projects'] = $resumeData['projects'] ?? [];
|
||||
$resumeData['references'] = $resumeData['references'] ?? [];
|
||||
$resumeData['custom_sections'] = $resumeData['custom_sections'] ?? [];
|
||||
$resumeData['settings'] = $resumeData['settings'] ?? $this->getDefaultSettings();
|
||||
|
||||
$resume = Resume::create($resumeData);
|
||||
|
||||
logger()->info('Resume created', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'title' => $resume->title,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing resume
|
||||
*/
|
||||
public function updateResume(Resume $resume, array $resumeData): Resume
|
||||
{
|
||||
// Update completion status based on content
|
||||
if ($this->shouldMarkAsCompleted($resumeData)) {
|
||||
$resumeData['is_completed'] = true;
|
||||
}
|
||||
|
||||
$resume->update($resumeData);
|
||||
|
||||
logger()->info('Resume updated', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'completion' => $resume->completion_percentage,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resume
|
||||
*/
|
||||
public function deleteResume(Resume $resume): bool
|
||||
{
|
||||
$resumeId = $resume->id;
|
||||
$userId = $resume->user_id;
|
||||
|
||||
$deleted = $resume->delete();
|
||||
|
||||
if ($deleted) {
|
||||
logger()->info('Resume deleted', [
|
||||
'resume_id' => $resumeId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a resume
|
||||
*/
|
||||
public function duplicateResume(Resume $resume, string $newTitle): Resume
|
||||
{
|
||||
$resumeData = $resume->toArray();
|
||||
|
||||
// Remove unique identifiers
|
||||
unset($resumeData['id'], $resumeData['slug'], $resumeData['public_url']);
|
||||
|
||||
// Set new title and reset status
|
||||
$resumeData['title'] = $newTitle;
|
||||
$resumeData['is_public'] = false;
|
||||
$resumeData['created_at'] = now();
|
||||
$resumeData['updated_at'] = now();
|
||||
|
||||
$newResume = Resume::create($resumeData);
|
||||
|
||||
logger()->info('Resume duplicated', [
|
||||
'original_resume_id' => $resume->id,
|
||||
'new_resume_id' => $newResume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
]);
|
||||
|
||||
return $newResume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF for a resume
|
||||
*/
|
||||
public function generatePdf(Resume $resume): Response
|
||||
{
|
||||
$template = $this->getTemplate($resume->template_id ?? 'professional');
|
||||
|
||||
$pdf = Pdf::loadView('pdf.resume.' . $template['id'], [
|
||||
'resume' => $resume,
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
$fileName = str()->slug($resume->title) . '-resume.pdf';
|
||||
|
||||
logger()->info('PDF generated', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'template' => $template['id'],
|
||||
]);
|
||||
|
||||
return $pdf->download($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make resume public
|
||||
*/
|
||||
public function makePublic(Resume $resume): Resume
|
||||
{
|
||||
if (!$resume->public_url) {
|
||||
$resume->public_url = $resume->generatePublicUrl();
|
||||
}
|
||||
|
||||
$resume->update(['is_public' => true]);
|
||||
|
||||
logger()->info('Resume made public', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'public_url' => $resume->public_url,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make resume private
|
||||
*/
|
||||
public function makePrivate(Resume $resume): Resume
|
||||
{
|
||||
$resume->update(['is_public' => false]);
|
||||
|
||||
logger()->info('Resume made private', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates
|
||||
*/
|
||||
public function getAvailableTemplates(): array
|
||||
{
|
||||
return $this->templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific template
|
||||
*/
|
||||
public function getTemplate(string $templateId): array
|
||||
{
|
||||
return $this->templates[$templateId] ?? $this->templates['professional'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume analytics
|
||||
*/
|
||||
public function getResumeAnalytics(Resume $resume): array
|
||||
{
|
||||
return [
|
||||
'completion_percentage' => $resume->completion_percentage,
|
||||
'sections_completed' => $this->getCompletedSections($resume),
|
||||
'total_sections' => 7,
|
||||
'word_count' => $this->calculateWordCount($resume),
|
||||
'last_updated' => $resume->updated_at,
|
||||
'views' => 0, // Implement view tracking if needed
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completed sections count
|
||||
*/
|
||||
protected function getCompletedSections(Resume $resume): int
|
||||
{
|
||||
$sections = ['personal_info', 'professional_summary', 'work_experiences',
|
||||
'education', 'skills', 'languages', 'certifications'];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($sections as $section) {
|
||||
if ($resume->isSectionCompleted($section)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return $completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total word count in resume
|
||||
*/
|
||||
protected function calculateWordCount(Resume $resume): int
|
||||
{
|
||||
$wordCount = 0;
|
||||
|
||||
// Professional summary
|
||||
if ($resume->professional_summary) {
|
||||
$wordCount += str_word_count(strip_tags($resume->professional_summary));
|
||||
}
|
||||
|
||||
// Work experiences
|
||||
if ($resume->work_experiences) {
|
||||
foreach ($resume->work_experiences as $experience) {
|
||||
$wordCount += str_word_count(strip_tags($experience['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Education
|
||||
if ($resume->education) {
|
||||
foreach ($resume->education as $education) {
|
||||
$wordCount += str_word_count(strip_tags($education['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Projects
|
||||
if ($resume->projects) {
|
||||
foreach ($resume->projects as $project) {
|
||||
$wordCount += str_word_count(strip_tags($project['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return $wordCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resume should be marked as completed
|
||||
*/
|
||||
protected function shouldMarkAsCompleted(array $resumeData): bool
|
||||
{
|
||||
$requiredSections = ['personal_info', 'work_experiences', 'education'];
|
||||
|
||||
foreach ($requiredSections as $section) {
|
||||
if (empty($resumeData[$section])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default resume settings
|
||||
*/
|
||||
protected function getDefaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'theme' => 'light',
|
||||
'font_family' => 'Roboto',
|
||||
'font_size' => 14,
|
||||
'color_scheme' => 'blue',
|
||||
'show_photo' => false,
|
||||
'show_references' => true,
|
||||
'show_certifications' => true,
|
||||
'show_projects' => true,
|
||||
'section_order' => [
|
||||
'personal_info',
|
||||
'professional_summary',
|
||||
'work_experiences',
|
||||
'education',
|
||||
'skills',
|
||||
'languages',
|
||||
'certifications',
|
||||
'projects',
|
||||
'references'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume statistics for user
|
||||
*/
|
||||
public function getUserResumeStats(int $userId): array
|
||||
{
|
||||
$resumes = $this->getUserResumes($userId);
|
||||
|
||||
return [
|
||||
'total_resumes' => $resumes->count(),
|
||||
'completed_resumes' => $resumes->where('is_completed', true)->count(),
|
||||
'public_resumes' => $resumes->where('is_public', true)->count(),
|
||||
'avg_completion' => $resumes->avg('completion_percentage') ?? 0,
|
||||
'last_updated' => $resumes->max('updated_at'),
|
||||
];
|
||||
}
|
||||
}
|
||||
400
app/Services/SecurityService.php
Normal file
400
app/Services/SecurityService.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security Service
|
||||
* Professional Resume Builder - Enterprise Security Service
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Security Service
|
||||
* Handles security-related operations and monitoring
|
||||
*/
|
||||
class SecurityService
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* Maximum failed attempts before account lockout
|
||||
*/
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Account lockout duration in minutes
|
||||
*/
|
||||
private const LOCKOUT_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Password reset token expiry in hours
|
||||
*/
|
||||
private const PASSWORD_RESET_EXPIRY = 24;
|
||||
|
||||
/**
|
||||
* Create a new service instance
|
||||
*/
|
||||
public function __construct(UserRepositoryInterface $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure password reset token
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*/
|
||||
public function generatePasswordResetToken(User $user): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = now()->addHours(self::PASSWORD_RESET_EXPIRY);
|
||||
|
||||
// Store token in cache with expiry
|
||||
Cache::put(
|
||||
"password_reset:{$user->id}:{$token}",
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'expires_at' => $expiry,
|
||||
'created_at' => now()
|
||||
],
|
||||
$expiry
|
||||
);
|
||||
|
||||
Log::info('Password reset token generated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'expires_at' => $expiry,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password reset token
|
||||
*
|
||||
* @param string $token
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function validatePasswordResetToken(string $token, int $userId): bool
|
||||
{
|
||||
$cacheKey = "password_reset:{$userId}:{$token}";
|
||||
$tokenData = Cache::get($cacheKey);
|
||||
|
||||
if (!$tokenData) {
|
||||
Log::warning('Invalid password reset token used', [
|
||||
'user_id' => $userId,
|
||||
'token' => substr($token, 0, 8) . '...',
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenData['user_id'] !== $userId) {
|
||||
Log::warning('Password reset token user mismatch', [
|
||||
'expected_user_id' => $userId,
|
||||
'token_user_id' => $tokenData['user_id'],
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user password using token
|
||||
*
|
||||
* @param string $token
|
||||
* @param int $userId
|
||||
* @param string $newPassword
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resetPassword(string $token, int $userId, string $newPassword): bool
|
||||
{
|
||||
try {
|
||||
if (!$this->validatePasswordResetToken($token, $userId)) {
|
||||
throw new Exception('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findById($userId);
|
||||
if (!$user) {
|
||||
throw new Exception('User not found');
|
||||
}
|
||||
|
||||
// Update password
|
||||
$hashedPassword = Hash::make($newPassword);
|
||||
$updateData = [
|
||||
'password' => $hashedPassword,
|
||||
'password_updated_at' => now(),
|
||||
'login_attempts' => 0,
|
||||
'account_locked_until' => null
|
||||
];
|
||||
|
||||
$success = $this->userRepository->update($userId, $updateData);
|
||||
|
||||
if ($success) {
|
||||
// Remove used token
|
||||
$cacheKey = "password_reset:{$userId}:{$token}";
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
Log::info('Password reset completed successfully', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
}
|
||||
|
||||
return $success;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Password reset failed', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user account is secure
|
||||
*
|
||||
* @param User $user
|
||||
* @return array
|
||||
*/
|
||||
public function checkAccountSecurity(User $user): array
|
||||
{
|
||||
$securityChecks = [
|
||||
'password_strength' => $this->checkPasswordStrength($user),
|
||||
'two_factor_enabled' => $user->two_factor_secret !== null,
|
||||
'email_verified' => $user->email_verified_at !== null,
|
||||
'recent_password_change' => $this->checkRecentPasswordChange($user),
|
||||
'suspicious_activity' => $this->checkSuspiciousActivity($user),
|
||||
'account_locked' => $this->userRepository->isAccountLocked($user->email)
|
||||
];
|
||||
|
||||
$securityScore = $this->calculateSecurityScore($securityChecks);
|
||||
|
||||
return [
|
||||
'security_score' => $securityScore,
|
||||
'checks' => $securityChecks,
|
||||
'recommendations' => $this->getSecurityRecommendations($securityChecks)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event
|
||||
*
|
||||
* @param string $event
|
||||
* @param User $user
|
||||
* @param array $details
|
||||
*/
|
||||
public function logSecurityEvent(string $event, User $user, array $details = []): void
|
||||
{
|
||||
$logData = array_merge([
|
||||
'event' => $event,
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'timestamp' => now()->toISOString()
|
||||
], $details);
|
||||
|
||||
Log::warning("Security Event: {$event}", $logData);
|
||||
|
||||
// Store in security events table (implement if needed)
|
||||
// SecurityEvent::create($logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate two-factor authentication secret
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*/
|
||||
public function generateTwoFactorSecret(User $user): string
|
||||
{
|
||||
$secret = bin2hex(random_bytes(16));
|
||||
|
||||
$this->userRepository->update($user->id, [
|
||||
'two_factor_secret' => $secret,
|
||||
'two_factor_recovery_codes' => $this->generateRecoveryCodes()
|
||||
]);
|
||||
|
||||
Log::info('Two-factor authentication enabled', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify two-factor authentication code
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $code
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyTwoFactorCode(User $user, string $code): bool
|
||||
{
|
||||
if (!$user->two_factor_secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Implement TOTP verification logic here
|
||||
// This is a simplified version
|
||||
$isValid = $this->validateTOTP($user->two_factor_secret, $code);
|
||||
|
||||
if ($isValid) {
|
||||
Log::info('Two-factor authentication verified', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Invalid two-factor authentication code', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
}
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check password strength
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkPasswordStrength(User $user): bool
|
||||
{
|
||||
// This is a simplified check - implement proper password strength validation
|
||||
return $user->password_updated_at &&
|
||||
$user->password_updated_at->gt(now()->subMonths(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password was changed recently
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkRecentPasswordChange(User $user): bool
|
||||
{
|
||||
return $user->password_updated_at &&
|
||||
$user->password_updated_at->gt(now()->subMonths(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious activity
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkSuspiciousActivity(User $user): bool
|
||||
{
|
||||
// Check failed login attempts
|
||||
return $user->login_attempts > 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate security score
|
||||
*
|
||||
* @param array $checks
|
||||
* @return int
|
||||
*/
|
||||
private function calculateSecurityScore(array $checks): int
|
||||
{
|
||||
$score = 0;
|
||||
$totalChecks = count($checks);
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if ($check === true) {
|
||||
$score++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($score / $totalChecks) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security recommendations
|
||||
*
|
||||
* @param array $checks
|
||||
* @return array
|
||||
*/
|
||||
private function getSecurityRecommendations(array $checks): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$checks['two_factor_enabled']) {
|
||||
$recommendations[] = 'Enable two-factor authentication for enhanced security';
|
||||
}
|
||||
|
||||
if (!$checks['email_verified']) {
|
||||
$recommendations[] = 'Verify your email address';
|
||||
}
|
||||
|
||||
if (!$checks['recent_password_change']) {
|
||||
$recommendations[] = 'Consider changing your password regularly';
|
||||
}
|
||||
|
||||
if ($checks['suspicious_activity']) {
|
||||
$recommendations[] = 'Review recent login activity for suspicious behavior';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recovery codes
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function generateRecoveryCodes(): array
|
||||
{
|
||||
$codes = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$codes[] = strtoupper(bin2hex(random_bytes(4)));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TOTP code (simplified implementation)
|
||||
*
|
||||
* @param string $secret
|
||||
* @param string $code
|
||||
* @return bool
|
||||
*/
|
||||
private function validateTOTP(string $secret, string $code): bool
|
||||
{
|
||||
// This is a placeholder - implement proper TOTP validation
|
||||
// You would typically use a library like spomky-labs/otphp
|
||||
return strlen($code) === 6 && is_numeric($code);
|
||||
}
|
||||
}
|
||||
64
bootstrap/app.php
Normal file
64
bootstrap/app.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Application Bootstrap
|
||||
* Professional Resume Builder - Laravel Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Create The Application
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The first thing we will do is create a new Laravel application instance
|
||||
| which serves as the "glue" for all the components of Laravel, and is
|
||||
| the IoC container for the system binding all of the various parts.
|
||||
|
|
||||
*/
|
||||
|
||||
$app = new Illuminate\Foundation\Application(
|
||||
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bind Important Interfaces
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, we need to bind some important interfaces into the container so
|
||||
| we will be able to resolve them when needed. The kernels serve the
|
||||
| incoming requests to this application from both the web and CLI.
|
||||
|
|
||||
*/
|
||||
|
||||
$app->singleton(
|
||||
Illuminate\Contracts\Http\Kernel::class,
|
||||
App\Http\Kernel::class
|
||||
);
|
||||
|
||||
$app->singleton(
|
||||
Illuminate\Contracts\Console\Kernel::class,
|
||||
App\Console\Kernel::class
|
||||
);
|
||||
|
||||
$app->singleton(
|
||||
Illuminate\Contracts\Debug\ExceptionHandler::class,
|
||||
App\Exceptions\Handler::class
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Return The Application
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This script returns the application instance. The instance is given to
|
||||
| the calling script so we can separate the building of the instances
|
||||
| from the actual running of the application and sending responses.
|
||||
|
|
||||
*/
|
||||
|
||||
return $app;
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
189
config/app.php
Normal file
189
config/app.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application. This value is used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| any other location as required by the application or its packages.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| your application so that it is used when running Artisan tasks.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. We have gone
|
||||
| ahead and set this to a sensible default for you out of the box.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by the translation service provider. You are free to set this value
|
||||
| to any of the locales which will be supported by the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => 'en',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Fallback Locale
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The fallback locale determines the locale to use when the current one
|
||||
| is not available. You may change the value to correspond to any of
|
||||
| the language folders that are provided through your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'fallback_locale' => 'en',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Faker Locale
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This locale will be used by the Faker PHP library when generating fake
|
||||
| data for your database seeds. For example, this will be used to get
|
||||
| localized telephone numbers, street address information and more.
|
||||
|
|
||||
*/
|
||||
|
||||
'faker_locale' => 'en_US',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is used by the Illuminate encrypter service and should be set
|
||||
| to a random, 32 character string, otherwise these encrypted strings
|
||||
| will not be safe. Please do this before deploying an application!
|
||||
|
|
||||
*/
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => 'file',
|
||||
// 'store' => 'redis',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Autoloaded Service Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The service providers listed here will be automatically loaded on the
|
||||
| request to your application. Feel free to add your own services to
|
||||
| this array to grant expanded functionality to your applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => ServiceProvider::defaultProviders()->merge([
|
||||
/*
|
||||
* Package Service Providers...
|
||||
*/
|
||||
|
||||
/*
|
||||
* Application Service Providers...
|
||||
*/
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
// App\Providers\BroadcastServiceProvider::class,
|
||||
App\Providers\EventServiceProvider::class,
|
||||
App\Providers\RouteServiceProvider::class,
|
||||
App\Providers\RepositoryServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Class Aliases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array of class aliases will be registered when this application
|
||||
| is started. However, feel free to register as many as you wish as
|
||||
| the aliases are "lazy" loaded so they don't hinder performance.
|
||||
|
|
||||
*/
|
||||
|
||||
'aliases' => Facade::defaultAliases()->merge([
|
||||
// 'Example' => App\Facades\Example::class,
|
||||
])->toArray(),
|
||||
|
||||
];
|
||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default authentication "guard" and password
|
||||
| reset options for your application. You may change these defaults
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => 'web',
|
||||
'passwords' => 'users',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| here which uses session storage and the Eloquent user provider.
|
||||
|
|
||||
| All authentication drivers have a user provider. This defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| mechanisms used by this application to persist your user's data.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication drivers have a user provider. This defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| mechanisms used by this application to persist your user's data.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| sources which represent each model / table. These sources may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => App\Models\User::class,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may specify multiple password reset configurations if you have more
|
||||
| than one user table or model in the application and you want to have
|
||||
| separate password reset settings based on the specific user types.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => 'password_reset_tokens',
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the amount of seconds before a password confirmation
|
||||
| times out and the user is prompted to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => 10800,
|
||||
|
||||
];
|
||||
71
config/broadcasting.php
Normal file
71
config/broadcasting.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_DRIVER', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over websockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
111
config/cache.php
Normal file
111
config/cache.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache connection that gets used while
|
||||
| using this caching library. This connection is used when another is
|
||||
| not explicitly specified when executing a given caching function.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_DRIVER', 'file'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "apc", "array", "database", "file",
|
||||
| "memcached", "redis", "dynamodb", "octane", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'apc' => [
|
||||
'driver' => 'apc',
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'lock_connection' => null,
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'cache',
|
||||
'lock_connection' => 'default',
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, or DynamoDB cache
|
||||
| stores there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
|
||||
|
||||
];
|
||||
34
config/cors.php
Normal file
34
config/cors.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => ['*'],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => false,
|
||||
|
||||
];
|
||||
151
config/database.php
Normal file
151
config/database.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for all database work. Of course
|
||||
| you may use many connections at once using the Database library.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'mysql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here are each of the database connections setup for your application.
|
||||
| Of course, examples of configuring each database platform that is
|
||||
| supported by Laravel is shown below to make development simple.
|
||||
|
|
||||
|
|
||||
| All database work in Laravel is done through the PHP PDO facilities
|
||||
| so make sure you have the driver for your particular database of
|
||||
| choice installed on your machine before you begin development.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run in the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => 'migrations',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
76
config/filesystems.php
Normal file
76
config/filesystems.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application. Just store away!
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure as many filesystem "disks" as you wish, and you
|
||||
| may even configure multiple disks of the same driver. Defaults have
|
||||
| been set up for each driver as an example of the required values.
|
||||
|
|
||||
| Supported Drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app'),
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
54
config/hashing.php
Normal file
54
config/hashing.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Hash Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default hash driver that will be used to hash
|
||||
| passwords for your application. By default, the bcrypt algorithm is
|
||||
| used; however, you remain free to modify this option if you wish.
|
||||
|
|
||||
| Supported: "bcrypt", "argon", "argon2id"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 'bcrypt',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bcrypt Options
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the configuration options that should be used when
|
||||
| passwords are hashed using the Bcrypt algorithm. This will allow you
|
||||
| to control the amount of time it takes to hash the given password.
|
||||
|
|
||||
*/
|
||||
|
||||
'bcrypt' => [
|
||||
'rounds' => env('BCRYPT_ROUNDS', 12),
|
||||
'verify' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Argon Options
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the configuration options that should be used when
|
||||
| passwords are hashed using the Argon algorithm. These will allow you
|
||||
| to control the amount of time it takes to hash the given password.
|
||||
|
|
||||
*/
|
||||
|
||||
'argon' => [
|
||||
'memory' => 65536,
|
||||
'threads' => 1,
|
||||
'time' => 4,
|
||||
'verify' => true,
|
||||
],
|
||||
|
||||
];
|
||||
131
config/logging.php
Normal file
131
config/logging.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that gets used when writing
|
||||
| messages to the logs. The name specified in this option should match
|
||||
| one of the channels defined in the "channels" configuration array.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => false,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Out of
|
||||
| the box, Laravel uses the Monolog PHP logging library. This gives
|
||||
| you a variety of powerful log handlers / formatters to utilize.
|
||||
|
|
||||
| Available Drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog",
|
||||
| "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['single'],
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 14,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => 'Laravel Log',
|
||||
'emoji' => ':boom:',
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => LOG_USER,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
134
config/mail.php
Normal file
134
config/mail.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send any email
|
||||
| messages sent by your application. Alternative mailers may be setup
|
||||
| and used as needed; however, this mailer will be used by default.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'smtp'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers to be used while
|
||||
| sending an e-mail. You will specify which one you are using for your
|
||||
| mailers below. You are free to add additional mailers as required.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "log", "array", "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => null,
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'mailgun' => [
|
||||
'transport' => 'mailgun',
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all e-mails sent by your application to be sent from
|
||||
| the same address. Here, you may specify a name and address that is
|
||||
| used globally for all e-mails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Markdown Mail Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| If you are using Markdown based email rendering, you may configure your
|
||||
| theme and component paths here, allowing you to customize the design
|
||||
| of the emails. Or, you may simply stick with the Laravel defaults!
|
||||
|
|
||||
*/
|
||||
|
||||
'markdown' => [
|
||||
'theme' => 'default',
|
||||
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
109
config/queue.php
Normal file
109
config/queue.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue API supports an assortment of back-ends via a single
|
||||
| API, giving you convenient access to each back-end using the same
|
||||
| syntax for every one. Here you may define a default connection.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection information for each server that
|
||||
| is used by your application. A default configuration has been added
|
||||
| for each back-end shipped with Laravel. You are free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'table' => 'jobs',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => 'localhost',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => 90,
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'mysql'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control which database and table are used to store the jobs that
|
||||
| have failed. You may change them to any database / table you wish.
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'mysql'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
83
config/sanctum.php
Normal file
83
config/sanctum.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort()
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
|
||||
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
34
config/services.php
Normal file
34
config/services.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'mailgun' => [
|
||||
'domain' => env('MAILGUN_DOMAIN'),
|
||||
'secret' => env('MAILGUN_SECRET'),
|
||||
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
|
||||
'scheme' => 'https',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'token' => env('POSTMARK_TOKEN'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
];
|
||||
214
config/session.php
Normal file
214
config/session.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default session "driver" that will be used on
|
||||
| requests. By default, we will use the lightweight native driver but
|
||||
| you may specify any of the other wonderful drivers provided here.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "apc",
|
||||
| "memcached", "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'file'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to immediately expire on the browser closing, set that option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it is stored. All encryption will be run
|
||||
| automatically by Laravel and you can use the Session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the native session driver, we need a location where session
|
||||
| files may be stored. A default has been set for you but a different
|
||||
| location may be specified. This is only needed for file sessions.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table we
|
||||
| should use to manage the sessions. Of course, a sensible default is
|
||||
| provided for you; however, you are free to change this as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => 'sessions',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While using one of the framework's cache driven session backends you may
|
||||
| list a cache store that should be used for these sessions. This value
|
||||
| must match with one of the application's configured cache "stores".
|
||||
|
|
||||
| Affects: "apc", "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the cookie used to identify a session
|
||||
| instance by ID. The name specified here will get used every time a
|
||||
| new session cookie is created by the framework for every driver.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application but you are free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => '/',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the domain of the cookie used to identify a session
|
||||
| in your application. This will determine which domains the cookie is
|
||||
| available to in your application. A sensible default has been set.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. You are free to modify this option if needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" since this is a secure default value.
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => 'lax',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => false,
|
||||
|
||||
];
|
||||
36
config/view.php
Normal file
36
config/view.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| View Storage Paths
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Most templating systems load templates from disk. Here you may specify
|
||||
| an array of paths that should be checked for your views. Of course
|
||||
| the usual Laravel view path has already been registered for you.
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => [
|
||||
resource_path('views'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Compiled View Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines where all the compiled Blade templates will be
|
||||
| stored for your application. Typically, this is within the storage
|
||||
| directory. However, as usual, you are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'compiled' => env(
|
||||
'VIEW_COMPILED_PATH',
|
||||
realpath(storage_path('framework/views'))
|
||||
),
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
295
database/factories/ResumeFactory.php
Normal file
295
database/factories/ResumeFactory.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Resume>
|
||||
*/
|
||||
class ResumeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$templates = ['professional', 'modern', 'executive', 'minimal', 'technical'];
|
||||
$statuses = ['draft', 'published', 'archived'];
|
||||
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'title' => fake()->sentence(3),
|
||||
'description' => fake()->paragraph(),
|
||||
'template' => fake()->randomElement($templates),
|
||||
'content' => $this->generateSampleContent(),
|
||||
'settings' => $this->generateSettings(),
|
||||
'status' => fake()->randomElement($statuses),
|
||||
'is_public' => fake()->boolean(30), // 30% chance of being public
|
||||
'public_url' => fake()->optional(0.3)->slug(),
|
||||
'published_at' => fake()->optional(0.5)->dateTimeBetween('-6 months', 'now'),
|
||||
'view_count' => fake()->numberBetween(0, 1000),
|
||||
'download_count' => fake()->numberBetween(0, 100),
|
||||
'last_viewed_at' => fake()->optional(0.7)->dateTimeBetween('-1 month', 'now'),
|
||||
'last_downloaded_at' => fake()->optional(0.4)->dateTimeBetween('-1 month', 'now'),
|
||||
'completion_percentage' => fake()->numberBetween(25, 100),
|
||||
'completion_sections' => $this->generateCompletionSections(),
|
||||
'analytics' => [
|
||||
'total_views' => fake()->numberBetween(0, 1000),
|
||||
'unique_visitors' => fake()->numberBetween(0, 500),
|
||||
'avg_time_on_page' => fake()->numberBetween(30, 300),
|
||||
'bounce_rate' => fake()->randomFloat(2, 0, 1),
|
||||
],
|
||||
'allow_comments' => fake()->boolean(20),
|
||||
'expires_at' => fake()->optional(0.2)->dateTimeBetween('now', '+1 year'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sample resume content.
|
||||
*/
|
||||
private function generateSampleContent(): array
|
||||
{
|
||||
return [
|
||||
'personal_info' => [
|
||||
'full_name' => fake()->name(),
|
||||
'title' => fake()->jobTitle(),
|
||||
'email' => fake()->email(),
|
||||
'phone' => fake()->phoneNumber(),
|
||||
'location' => fake()->city() . ', ' . fake()->country(),
|
||||
'website' => fake()->optional()->url(),
|
||||
'linkedin' => fake()->optional()->url(),
|
||||
'github' => fake()->optional()->url(),
|
||||
],
|
||||
'summary' => fake()->paragraph(4),
|
||||
'experience' => $this->generateExperience(),
|
||||
'education' => $this->generateEducation(),
|
||||
'skills' => $this->generateSkills(),
|
||||
'projects' => fake()->optional(0.7)->passthrough($this->generateProjects()),
|
||||
'certifications' => fake()->optional(0.5)->passthrough($this->generateCertifications()),
|
||||
'languages' => fake()->optional(0.6)->passthrough($this->generateLanguages()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate work experience.
|
||||
*/
|
||||
private function generateExperience(): array
|
||||
{
|
||||
$experiences = [];
|
||||
$count = fake()->numberBetween(1, 4);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$startDate = fake()->dateTimeBetween('-10 years', '-1 year');
|
||||
$isCurrent = $i === 0 && fake()->boolean(40);
|
||||
|
||||
$experiences[] = [
|
||||
'company' => fake()->company(),
|
||||
'position' => fake()->jobTitle(),
|
||||
'location' => fake()->city() . ', ' . fake()->country(),
|
||||
'start_date' => $startDate->format('Y-m-d'),
|
||||
'end_date' => $isCurrent ? null : fake()->dateTimeBetween($startDate, 'now')->format('Y-m-d'),
|
||||
'current' => $isCurrent,
|
||||
'description' => fake()->paragraph(2),
|
||||
'achievements' => fake()->sentences(fake()->numberBetween(2, 5)),
|
||||
];
|
||||
}
|
||||
|
||||
return $experiences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate education.
|
||||
*/
|
||||
private function generateEducation(): array
|
||||
{
|
||||
$education = [];
|
||||
$count = fake()->numberBetween(1, 3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$startDate = fake()->dateTimeBetween('-15 years', '-2 years');
|
||||
$endDate = fake()->dateTimeBetween($startDate, '-1 year');
|
||||
|
||||
$education[] = [
|
||||
'institution' => fake()->company() . ' University',
|
||||
'degree' => fake()->randomElement(['Bachelor of Science', 'Master of Science', 'Bachelor of Arts', 'Master of Arts']),
|
||||
'field' => fake()->randomElement(['Computer Science', 'Information Technology', 'Software Engineering', 'Business Administration']),
|
||||
'start_date' => $startDate->format('Y-m-d'),
|
||||
'end_date' => $endDate->format('Y-m-d'),
|
||||
'gpa' => fake()->optional(0.7)->randomFloat(1, 2.5, 4.0),
|
||||
'description' => fake()->optional(0.5)->sentence(),
|
||||
];
|
||||
}
|
||||
|
||||
return $education;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate skills.
|
||||
*/
|
||||
private function generateSkills(): array
|
||||
{
|
||||
$skillCategories = [
|
||||
'Programming Languages' => ['PHP', 'JavaScript', 'Python', 'Java', 'TypeScript'],
|
||||
'Frontend' => ['React', 'Angular', 'Vue.js', 'HTML5', 'CSS3', 'Bootstrap'],
|
||||
'Backend' => ['Laravel', 'Node.js', 'Express', 'Django', 'Spring Boot'],
|
||||
'Databases' => ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis'],
|
||||
'Tools & Technologies' => ['Git', 'Docker', 'AWS', 'Linux', 'Jenkins'],
|
||||
];
|
||||
|
||||
$skills = [];
|
||||
$numCategories = fake()->numberBetween(2, 5);
|
||||
$categories = fake()->randomElements(array_keys($skillCategories), $numCategories);
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$skills[$category] = fake()->randomElements($skillCategories[$category], fake()->numberBetween(2, 4));
|
||||
}
|
||||
|
||||
return $skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate projects.
|
||||
*/
|
||||
private function generateProjects(): array
|
||||
{
|
||||
$projects = [];
|
||||
$count = fake()->numberBetween(1, 4);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$projects[] = [
|
||||
'name' => fake()->catchPhrase(),
|
||||
'description' => fake()->paragraph(),
|
||||
'technologies' => fake()->randomElements(['Laravel', 'React', 'Vue.js', 'MySQL', 'Docker', 'AWS'], fake()->numberBetween(2, 4)),
|
||||
'url' => fake()->optional(0.6)->url(),
|
||||
'achievements' => fake()->sentences(fake()->numberBetween(2, 4)),
|
||||
];
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate certifications.
|
||||
*/
|
||||
private function generateCertifications(): array
|
||||
{
|
||||
$certifications = [];
|
||||
$count = fake()->numberBetween(1, 3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$certifications[] = [
|
||||
'name' => fake()->sentence(4),
|
||||
'issuer' => fake()->company(),
|
||||
'date' => fake()->dateTimeBetween('-5 years', 'now')->format('Y-m-d'),
|
||||
'credential_id' => fake()->optional(0.7)->bothify('??###-####'),
|
||||
'url' => fake()->optional(0.5)->url(),
|
||||
];
|
||||
}
|
||||
|
||||
return $certifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate languages.
|
||||
*/
|
||||
private function generateLanguages(): array
|
||||
{
|
||||
$languages = ['English', 'Spanish', 'French', 'German', 'Italian', 'Portuguese', 'Chinese', 'Japanese'];
|
||||
$levels = ['Native', 'Fluent', 'Advanced', 'Intermediate', 'Basic'];
|
||||
|
||||
$result = [];
|
||||
$count = fake()->numberBetween(1, 4);
|
||||
$selectedLanguages = fake()->randomElements($languages, $count);
|
||||
|
||||
foreach ($selectedLanguages as $language) {
|
||||
$result[] = [
|
||||
'language' => $language,
|
||||
'level' => fake()->randomElement($levels),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate settings.
|
||||
*/
|
||||
private function generateSettings(): array
|
||||
{
|
||||
return [
|
||||
'color_scheme' => fake()->randomElement(['blue', 'green', 'red', 'purple', 'orange']),
|
||||
'font_family' => fake()->randomElement(['Arial', 'Times New Roman', 'Calibri', 'Helvetica']),
|
||||
'font_size' => fake()->numberBetween(9, 12),
|
||||
'line_spacing' => fake()->randomFloat(1, 1.0, 1.5),
|
||||
'margin' => fake()->numberBetween(15, 25),
|
||||
'show_photo' => fake()->boolean(30),
|
||||
'section_order' => ['summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion sections.
|
||||
*/
|
||||
private function generateCompletionSections(): array
|
||||
{
|
||||
$sections = ['personal_info', 'summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages'];
|
||||
$completed = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$completed[$section] = fake()->boolean(80); // 80% chance of being completed
|
||||
}
|
||||
|
||||
return $completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the resume is published.
|
||||
*/
|
||||
public function published()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'status' => 'published',
|
||||
'published_at' => fake()->dateTimeBetween('-6 months', 'now'),
|
||||
'is_public' => true,
|
||||
'public_url' => fake()->slug(),
|
||||
'completion_percentage' => fake()->numberBetween(80, 100),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the resume is a draft.
|
||||
*/
|
||||
public function draft()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'status' => 'draft',
|
||||
'published_at' => null,
|
||||
'is_public' => false,
|
||||
'public_url' => null,
|
||||
'completion_percentage' => fake()->numberBetween(25, 75),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the resume is popular.
|
||||
*/
|
||||
public function popular()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'view_count' => fake()->numberBetween(500, 5000),
|
||||
'download_count' => fake()->numberBetween(50, 500),
|
||||
'is_public' => true,
|
||||
'status' => 'published',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
97
database/factories/UserFactory.php
Normal file
97
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => fake()->firstName(),
|
||||
'last_name' => fake()->lastName(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'phone' => fake()->phoneNumber(),
|
||||
'date_of_birth' => fake()->date('Y-m-d', '2000-01-01'),
|
||||
'gender' => fake()->randomElement(['male', 'female', 'other', 'prefer_not_to_say']),
|
||||
'bio' => fake()->paragraph(3),
|
||||
'website' => fake()->url(),
|
||||
'linkedin' => 'https://linkedin.com/in/' . fake()->userName(),
|
||||
'github' => 'https://github.com/' . fake()->userName(),
|
||||
'twitter' => 'https://twitter.com/' . fake()->userName(),
|
||||
'preferences' => [
|
||||
'theme' => fake()->randomElement(['professional', 'modern', 'minimal']),
|
||||
'language' => fake()->randomElement(['en', 'de', 'es', 'fr']),
|
||||
'notifications' => fake()->boolean(),
|
||||
'marketing_emails' => fake()->boolean(),
|
||||
],
|
||||
'newsletter_subscribed' => fake()->boolean(),
|
||||
'last_login_at' => fake()->optional()->dateTimeBetween('-1 month', 'now'),
|
||||
'last_login_ip' => fake()->ipv4(),
|
||||
'status' => fake()->randomElement(['active', 'inactive']),
|
||||
'locale' => fake()->randomElement(['en', 'de', 'es', 'fr']),
|
||||
'timezone' => fake()->timezone(),
|
||||
'profile_completed_at' => fake()->optional(0.8)->dateTimeBetween('-1 month', 'now'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'email_verified_at' => null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user is suspended.
|
||||
*/
|
||||
public function suspended()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'status' => 'suspended',
|
||||
'suspended_at' => now(),
|
||||
'suspension_reason' => fake()->sentence(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user has incomplete profile.
|
||||
*/
|
||||
public function incompleteProfile()
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
return [
|
||||
'profile_completed_at' => null,
|
||||
'bio' => null,
|
||||
'website' => null,
|
||||
'phone' => null,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
63
database/migrations/2024_01_01_000000_create_users_table.php
Normal file
63
database/migrations/2024_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('first_name');
|
||||
$table->string('last_name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('phone')->nullable();
|
||||
$table->date('date_of_birth')->nullable();
|
||||
$table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
$table->string('linkedin')->nullable();
|
||||
$table->string('github')->nullable();
|
||||
$table->string('twitter')->nullable();
|
||||
$table->json('preferences')->nullable(); // User preferences as JSON
|
||||
$table->boolean('newsletter_subscribed')->default(false);
|
||||
$table->timestamp('last_login_at')->nullable();
|
||||
$table->string('last_login_ip')->nullable();
|
||||
$table->enum('status', ['active', 'inactive', 'suspended'])->default('active');
|
||||
$table->timestamp('suspended_at')->nullable();
|
||||
$table->string('suspension_reason')->nullable();
|
||||
$table->integer('login_attempts')->default(0);
|
||||
$table->timestamp('locked_until')->nullable();
|
||||
$table->string('locale', 10)->default('en');
|
||||
$table->string('timezone', 50)->default('UTC');
|
||||
$table->boolean('two_factor_enabled')->default(false);
|
||||
$table->string('two_factor_secret')->nullable();
|
||||
$table->json('two_factor_recovery_codes')->nullable();
|
||||
$table->timestamp('profile_completed_at')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['email', 'status']);
|
||||
$table->index(['last_login_at']);
|
||||
$table->index(['created_at']);
|
||||
$table->index(['status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['email', 'token']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->string('queue', 191); // Changed from text to string with length
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['failed_at']);
|
||||
$table->index(['queue']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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('resumes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->enum('template', ['professional', 'modern', 'executive', 'minimal', 'technical'])->default('professional');
|
||||
$table->json('content')->nullable(); // Resume content as JSON
|
||||
$table->json('settings')->nullable(); // Template settings as JSON
|
||||
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
|
||||
$table->boolean('is_public')->default(false);
|
||||
$table->string('public_url')->nullable()->unique();
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->integer('view_count')->default(0);
|
||||
$table->integer('download_count')->default(0);
|
||||
$table->timestamp('last_viewed_at')->nullable();
|
||||
$table->timestamp('last_downloaded_at')->nullable();
|
||||
$table->integer('completion_percentage')->default(0);
|
||||
$table->json('completion_sections')->nullable(); // Which sections are complete
|
||||
$table->string('pdf_path')->nullable(); // Path to generated PDF
|
||||
$table->timestamp('pdf_generated_at')->nullable();
|
||||
$table->integer('pdf_version')->default(1);
|
||||
$table->json('analytics')->nullable(); // Analytics data
|
||||
$table->json('feedback')->nullable(); // User feedback/notes
|
||||
$table->boolean('allow_comments')->default(false);
|
||||
$table->string('password')->nullable(); // Password protection
|
||||
$table->timestamp('expires_at')->nullable(); // Public link expiration
|
||||
$table->json('share_settings')->nullable(); // Sharing permissions
|
||||
$table->string('slug')->nullable(); // SEO-friendly URL
|
||||
$table->json('seo_meta')->nullable(); // SEO metadata
|
||||
$table->timestamps();
|
||||
$table->softDeletes(); // Soft delete for data recovery
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['user_id', 'status']);
|
||||
$table->index(['is_public', 'published_at']);
|
||||
$table->index(['template']);
|
||||
$table->index(['created_at']);
|
||||
$table->index(['view_count']);
|
||||
$table->index(['slug']);
|
||||
$table->index(['public_url']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('resumes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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('resume_views', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('resume_id')->constrained()->onDelete('cascade');
|
||||
$table->string('ip_address');
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->string('referrer')->nullable();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->json('browser_info')->nullable(); // Browser details
|
||||
$table->integer('duration')->nullable(); // Time spent viewing (seconds)
|
||||
$table->boolean('is_download')->default(false);
|
||||
$table->enum('format', ['web', 'pdf', 'docx'])->default('web');
|
||||
$table->timestamp('viewed_at');
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for analytics
|
||||
$table->index(['resume_id', 'viewed_at']);
|
||||
$table->index(['ip_address']);
|
||||
$table->index(['country']);
|
||||
$table->index(['is_download']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('resume_views');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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('activity_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->string('event'); // login, logout, resume_created, etc.
|
||||
$table->string('description');
|
||||
$table->json('properties')->nullable(); // Additional event data
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->morphs('subject'); // Polymorphic relation to any model
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for querying
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['event']);
|
||||
// Note: morphs() already creates an index for subject_type and subject_id
|
||||
$table->index(['created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_logs');
|
||||
}
|
||||
};
|
||||
20
database/seeders/DatabaseSeeder.php
Normal file
20
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
ResumeSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
242
database/seeders/ResumeSeeder.php
Normal file
242
database/seeders/ResumeSeeder.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Resume;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ResumeSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$admin = User::where('email', 'david@valera-melendez.de')->first();
|
||||
$demo = User::where('email', 'demo@example.com')->first();
|
||||
|
||||
if ($admin) {
|
||||
// Create admin's professional resume
|
||||
Resume::create([
|
||||
'user_id' => $admin->id,
|
||||
'title' => 'Senior Full-Stack Developer Resume',
|
||||
'description' => 'Professional resume showcasing expertise in Laravel, Angular, and enterprise software development.',
|
||||
'template' => 'executive',
|
||||
'content' => [
|
||||
'personal_info' => [
|
||||
'full_name' => 'David Valera Melendez',
|
||||
'title' => 'Senior Full-Stack Developer',
|
||||
'email' => 'david@valera-melendez.de',
|
||||
'phone' => '+49 123 456 7890',
|
||||
'location' => 'Germany',
|
||||
'website' => 'https://valera-melendez.de',
|
||||
'linkedin' => 'https://linkedin.com/in/david-valera-melendez',
|
||||
'github' => 'https://github.com/davidvalera',
|
||||
],
|
||||
'summary' => 'Experienced Senior Full-Stack Developer with 8+ years of expertise in enterprise web applications. Specialized in Laravel, Angular, and modern development practices. Proven track record of delivering scalable solutions for international clients.',
|
||||
'experience' => [
|
||||
[
|
||||
'company' => 'Tech Solutions GmbH',
|
||||
'position' => 'Senior Full-Stack Developer',
|
||||
'location' => 'Berlin, Germany',
|
||||
'start_date' => '2020-01-01',
|
||||
'end_date' => null,
|
||||
'current' => true,
|
||||
'description' => 'Lead development of enterprise web applications using Laravel, Angular, and microservices architecture. Mentor junior developers and establish best practices.',
|
||||
'achievements' => [
|
||||
'Architected and developed 5+ enterprise applications serving 100K+ users',
|
||||
'Reduced application load time by 60% through optimization techniques',
|
||||
'Led team of 6 developers on critical client projects',
|
||||
'Implemented CI/CD pipelines reducing deployment time by 80%'
|
||||
]
|
||||
],
|
||||
[
|
||||
'company' => 'Digital Innovation Ltd',
|
||||
'position' => 'Full-Stack Developer',
|
||||
'location' => 'Hamburg, Germany',
|
||||
'start_date' => '2018-06-01',
|
||||
'end_date' => '2019-12-31',
|
||||
'current' => false,
|
||||
'description' => 'Developed responsive web applications and REST APIs for various clients in e-commerce and fintech sectors.',
|
||||
'achievements' => [
|
||||
'Built e-commerce platform handling €2M+ monthly transactions',
|
||||
'Developed fintech dashboard with real-time analytics',
|
||||
'Improved code quality by implementing automated testing'
|
||||
]
|
||||
]
|
||||
],
|
||||
'education' => [
|
||||
[
|
||||
'institution' => 'Technical University of Berlin',
|
||||
'degree' => 'Master of Science in Computer Science',
|
||||
'field' => 'Software Engineering',
|
||||
'start_date' => '2014-09-01',
|
||||
'end_date' => '2016-07-31',
|
||||
'gpa' => '1.2',
|
||||
'description' => 'Specialized in software architecture, distributed systems, and web technologies.'
|
||||
],
|
||||
[
|
||||
'institution' => 'University of Applied Sciences',
|
||||
'degree' => 'Bachelor of Science in Information Technology',
|
||||
'field' => 'Web Development',
|
||||
'start_date' => '2011-09-01',
|
||||
'end_date' => '2014-07-31',
|
||||
'gpa' => '1.4',
|
||||
'description' => 'Foundation in programming, databases, and web development technologies.'
|
||||
]
|
||||
],
|
||||
'skills' => [
|
||||
'Backend Development' => ['Laravel', 'PHP', 'Node.js', 'Python', 'REST APIs', 'GraphQL'],
|
||||
'Frontend Development' => ['Angular', 'TypeScript', 'JavaScript', 'HTML5', 'CSS3', 'Bootstrap', 'Sass'],
|
||||
'Databases' => ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis'],
|
||||
'DevOps & Tools' => ['Docker', 'AWS', 'Git', 'CI/CD', 'Linux', 'Nginx'],
|
||||
'Methodologies' => ['Agile', 'Scrum', 'TDD', 'Clean Code', 'Design Patterns']
|
||||
],
|
||||
'projects' => [
|
||||
[
|
||||
'name' => 'Enterprise Resume Builder',
|
||||
'description' => 'Professional resume builder application with Laravel backend and Angular frontend.',
|
||||
'technologies' => ['Laravel', 'Angular', 'Bootstrap', 'MySQL'],
|
||||
'url' => 'https://resume.valera-melendez.de',
|
||||
'achievements' => [
|
||||
'Built scalable architecture supporting 10K+ users',
|
||||
'Implemented real-time collaboration features',
|
||||
'Created PDF generation with custom templates'
|
||||
]
|
||||
],
|
||||
[
|
||||
'name' => 'E-commerce Platform',
|
||||
'description' => 'Full-featured e-commerce solution with inventory management and payment processing.',
|
||||
'technologies' => ['Laravel', 'Vue.js', 'PostgreSQL', 'Stripe'],
|
||||
'achievements' => [
|
||||
'Processed €5M+ in transactions',
|
||||
'Integrated multiple payment gateways',
|
||||
'Built admin dashboard with analytics'
|
||||
]
|
||||
]
|
||||
],
|
||||
'certifications' => [
|
||||
[
|
||||
'name' => 'AWS Certified Solutions Architect',
|
||||
'issuer' => 'Amazon Web Services',
|
||||
'date' => '2023-03-15',
|
||||
'credential_id' => 'AWS-SA-12345',
|
||||
'url' => 'https://aws.amazon.com/certification/'
|
||||
],
|
||||
[
|
||||
'name' => 'Laravel Certified Developer',
|
||||
'issuer' => 'Laravel',
|
||||
'date' => '2022-08-20',
|
||||
'credential_id' => 'LRV-DEV-67890'
|
||||
]
|
||||
],
|
||||
'languages' => [
|
||||
['language' => 'German', 'level' => 'Native'],
|
||||
['language' => 'English', 'level' => 'Fluent'],
|
||||
['language' => 'Spanish', 'level' => 'Intermediate'],
|
||||
]
|
||||
],
|
||||
'settings' => [
|
||||
'color_scheme' => 'blue',
|
||||
'font_family' => 'Arial',
|
||||
'font_size' => 11,
|
||||
'line_spacing' => 1.2,
|
||||
'margin' => 20,
|
||||
'show_photo' => false,
|
||||
'section_order' => ['summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages']
|
||||
],
|
||||
'status' => 'published',
|
||||
'is_public' => true,
|
||||
'public_url' => 'david-valera-melendez-senior-developer',
|
||||
'published_at' => now(),
|
||||
'completion_percentage' => 100,
|
||||
'completion_sections' => [
|
||||
'personal_info' => true,
|
||||
'summary' => true,
|
||||
'experience' => true,
|
||||
'education' => true,
|
||||
'skills' => true,
|
||||
'projects' => true,
|
||||
'certifications' => true,
|
||||
'languages' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
if ($demo) {
|
||||
// Create demo user's resume
|
||||
Resume::create([
|
||||
'user_id' => $demo->id,
|
||||
'title' => 'My Professional Resume',
|
||||
'description' => 'Demo resume for testing the application features.',
|
||||
'template' => 'modern',
|
||||
'content' => [
|
||||
'personal_info' => [
|
||||
'full_name' => 'Demo User',
|
||||
'title' => 'Software Developer',
|
||||
'email' => 'demo@example.com',
|
||||
'phone' => '+1 555 123 4567',
|
||||
'location' => 'New York, USA',
|
||||
],
|
||||
'summary' => 'Passionate software developer with experience in web development and modern frameworks.',
|
||||
'experience' => [
|
||||
[
|
||||
'company' => 'Example Corp',
|
||||
'position' => 'Junior Developer',
|
||||
'location' => 'New York, USA',
|
||||
'start_date' => '2022-01-01',
|
||||
'end_date' => null,
|
||||
'current' => true,
|
||||
'description' => 'Developing web applications and learning new technologies.',
|
||||
'achievements' => [
|
||||
'Built responsive web interfaces',
|
||||
'Collaborated with senior developers',
|
||||
'Participated in code reviews'
|
||||
]
|
||||
]
|
||||
],
|
||||
'education' => [
|
||||
[
|
||||
'institution' => 'State University',
|
||||
'degree' => 'Bachelor of Science',
|
||||
'field' => 'Computer Science',
|
||||
'start_date' => '2018-09-01',
|
||||
'end_date' => '2022-05-31',
|
||||
'gpa' => '3.8'
|
||||
]
|
||||
],
|
||||
'skills' => [
|
||||
'Programming' => ['JavaScript', 'Python', 'Java'],
|
||||
'Web Development' => ['HTML', 'CSS', 'React', 'Node.js'],
|
||||
'Tools' => ['Git', 'VS Code', 'Docker']
|
||||
]
|
||||
],
|
||||
'settings' => [
|
||||
'color_scheme' => 'green',
|
||||
'font_family' => 'Arial',
|
||||
'font_size' => 10,
|
||||
'section_order' => ['summary', 'experience', 'education', 'skills']
|
||||
],
|
||||
'status' => 'draft',
|
||||
'completion_percentage' => 75,
|
||||
'completion_sections' => [
|
||||
'personal_info' => true,
|
||||
'summary' => true,
|
||||
'experience' => true,
|
||||
'education' => true,
|
||||
'skills' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// Create additional test resumes
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
if (!in_array($user->email, ['david@valera-melendez.de', 'demo@example.com'])) {
|
||||
Resume::factory()->create(['user_id' => $user->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
database/seeders/UserSeeder.php
Normal file
66
database/seeders/UserSeeder.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create admin user
|
||||
User::create([
|
||||
'first_name' => 'David',
|
||||
'last_name' => 'Valera Melendez',
|
||||
'email' => 'david@valera-melendez.de',
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make('password123'),
|
||||
'phone' => '+49 123 456 7890',
|
||||
'bio' => 'Senior Full-Stack Developer and Enterprise Software Architect specializing in Laravel, Angular, and modern web technologies.',
|
||||
'website' => 'https://valera-melendez.de',
|
||||
'linkedin' => 'https://linkedin.com/in/david-valera-melendez',
|
||||
'github' => 'https://github.com/davidvalera',
|
||||
'preferences' => [
|
||||
'theme' => 'professional',
|
||||
'language' => 'en',
|
||||
'notifications' => true,
|
||||
'marketing_emails' => false,
|
||||
],
|
||||
'newsletter_subscribed' => false,
|
||||
'status' => 'active',
|
||||
'locale' => 'en',
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'profile_completed_at' => now(),
|
||||
]);
|
||||
|
||||
// Create demo user
|
||||
User::create([
|
||||
'first_name' => 'Demo',
|
||||
'last_name' => 'User',
|
||||
'email' => 'demo@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make('demo123'),
|
||||
'bio' => 'Demo user for testing the resume builder application.',
|
||||
'preferences' => [
|
||||
'theme' => 'modern',
|
||||
'language' => 'en',
|
||||
'notifications' => true,
|
||||
'marketing_emails' => true,
|
||||
],
|
||||
'newsletter_subscribed' => true,
|
||||
'status' => 'active',
|
||||
'locale' => 'en',
|
||||
'timezone' => 'UTC',
|
||||
'profile_completed_at' => now(),
|
||||
]);
|
||||
|
||||
// Create additional test users
|
||||
User::factory(10)->create();
|
||||
}
|
||||
}
|
||||
21
public/.htaccess
Normal file
21
public/.htaccess
Normal file
@@ -0,0 +1,21 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
55
public/index.php
Normal file
55
public/index.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Check If The Application Is Under Maintenance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| If the application is in maintenance / demo mode via the "down" command
|
||||
| we will load this file so that any pre-rendered content can be shown
|
||||
| instead of starting the framework, which could cause an exception.
|
||||
|
|
||||
*/
|
||||
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register The Auto Loader
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Composer provides a convenient, automatically generated class loader for
|
||||
| this application. We just need to utilize it! We'll simply require it
|
||||
| into the script here so we don't need to manually load our classes.
|
||||
|
|
||||
*/
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Run The Application
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Once we have the application, we can handle the incoming request using
|
||||
| the application's HTTP kernel. Then, we will send the response back
|
||||
| to this client's browser, allowing them to enjoy our application.
|
||||
|
|
||||
*/
|
||||
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$kernel = $app->make(Kernel::class);
|
||||
|
||||
$response = $kernel->handle(
|
||||
$request = Request::capture()
|
||||
)->send();
|
||||
|
||||
$kernel->terminate($request, $response);
|
||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
0
resources/css/app.css
Normal file
0
resources/css/app.css
Normal file
534
resources/js/app.js
Normal file
534
resources/js/app.js
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Main JavaScript Application
|
||||
* Professional Resume Builder - Laravel Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// Import Bootstrap JavaScript
|
||||
import 'bootstrap';
|
||||
|
||||
// Import Axios for HTTP requests
|
||||
import axios from 'axios';
|
||||
|
||||
// Configure Axios defaults
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
// CSRF Token setup
|
||||
const token = document.head.querySelector('meta[name="csrf-token"]');
|
||||
if (token) {
|
||||
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
|
||||
} else {
|
||||
console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Class - Main Application Logic
|
||||
*/
|
||||
class ResumeBuilderApp {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.setupFormValidation();
|
||||
this.setupTooltips();
|
||||
this.setupAnimations();
|
||||
this.setupAccessibility();
|
||||
this.setupProgressBars();
|
||||
// Professional Resume Builder initialized - Made in Germany 🇩🇪
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.handlePageLoad();
|
||||
});
|
||||
|
||||
// Handle form submissions with loading states
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (e.target.tagName === 'FORM') {
|
||||
this.handleFormSubmit(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle dynamic content loading
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-action]')) {
|
||||
this.handleDynamicAction(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle page load events
|
||||
*/
|
||||
handlePageLoad() {
|
||||
// Animate elements on page load
|
||||
this.animateOnLoad();
|
||||
|
||||
// Focus management
|
||||
this.setupFocusManagement();
|
||||
|
||||
// Auto-dismiss alerts
|
||||
this.setupAlertDismissal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup form validation
|
||||
*/
|
||||
setupFormValidation() {
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
|
||||
forms.forEach(form => {
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
// Real-time validation
|
||||
input.addEventListener('input', () => {
|
||||
this.validateField(input);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
this.validateField(input);
|
||||
});
|
||||
});
|
||||
|
||||
// Form submission validation
|
||||
form.addEventListener('submit', (e) => {
|
||||
if (!this.validateForm(form)) {
|
||||
e.preventDefault();
|
||||
this.showValidationErrors(form);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate individual field
|
||||
*/
|
||||
validateField(field) {
|
||||
const isValid = field.checkValidity();
|
||||
const hasValue = field.value.trim().length > 0;
|
||||
|
||||
field.classList.remove('is-valid', 'is-invalid');
|
||||
|
||||
if (hasValue) {
|
||||
field.classList.add(isValid ? 'is-valid' : 'is-invalid');
|
||||
}
|
||||
|
||||
// Custom validation rules
|
||||
if (field.type === 'password' && field.name === 'password') {
|
||||
this.validatePassword(field);
|
||||
}
|
||||
|
||||
if (field.type === 'password' && field.name === 'password_confirmation') {
|
||||
this.validatePasswordConfirmation(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
validatePassword(passwordField) {
|
||||
const password = passwordField.value;
|
||||
const minLength = 8;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*]/.test(password);
|
||||
|
||||
const isStrong = password.length >= minLength && hasUpperCase && hasLowerCase && hasNumbers;
|
||||
|
||||
passwordField.classList.remove('is-valid', 'is-invalid');
|
||||
if (password.length > 0) {
|
||||
passwordField.classList.add(isStrong ? 'is-valid' : 'is-invalid');
|
||||
}
|
||||
|
||||
return isStrong;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password confirmation
|
||||
*/
|
||||
validatePasswordConfirmation(confirmField) {
|
||||
const password = document.querySelector('input[name="password"]')?.value || '';
|
||||
const confirmation = confirmField.value;
|
||||
|
||||
const matches = password === confirmation && confirmation.length > 0;
|
||||
|
||||
confirmField.classList.remove('is-valid', 'is-invalid');
|
||||
if (confirmation.length > 0) {
|
||||
confirmField.classList.add(matches ? 'is-valid' : 'is-invalid');
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entire form
|
||||
*/
|
||||
validateForm(form) {
|
||||
const inputs = form.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
let isValid = true;
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!this.validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation errors
|
||||
*/
|
||||
showValidationErrors(form) {
|
||||
const firstInvalidField = form.querySelector('.is-invalid');
|
||||
if (firstInvalidField) {
|
||||
firstInvalidField.focus();
|
||||
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission with loading states
|
||||
*/
|
||||
handleFormSubmit(form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
if (submitButton) {
|
||||
const originalContent = submitButton.innerHTML;
|
||||
const loadingText = submitButton.dataset.loading || 'Processing...';
|
||||
|
||||
submitButton.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${loadingText}`;
|
||||
submitButton.disabled = true;
|
||||
|
||||
// Re-enable if form doesn't redirect (error case)
|
||||
setTimeout(() => {
|
||||
if (document.contains(submitButton)) {
|
||||
submitButton.innerHTML = originalContent;
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup tooltips
|
||||
*/
|
||||
setupTooltips() {
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup animations
|
||||
*/
|
||||
setupAnimations() {
|
||||
// Intersection Observer for scroll animations
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate-fade-in-up');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-animate]').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate elements on page load
|
||||
*/
|
||||
animateOnLoad() {
|
||||
// Stagger animation for cards
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach((card, index) => {
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
card.style.transition = 'all 0.5s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, 100 * index);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup progress bars with animation
|
||||
*/
|
||||
setupProgressBars() {
|
||||
const progressBars = document.querySelectorAll('.progress-bar');
|
||||
|
||||
const animateProgress = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const progressBar = entry.target;
|
||||
const targetWidth = progressBar.style.width;
|
||||
|
||||
progressBar.style.width = '0%';
|
||||
|
||||
setTimeout(() => {
|
||||
progressBar.style.transition = 'width 1s ease-in-out';
|
||||
progressBar.style.width = targetWidth;
|
||||
}, 200);
|
||||
|
||||
observer.unobserve(progressBar);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver(animateProgress);
|
||||
progressBars.forEach(bar => observer.observe(bar));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup accessibility features
|
||||
*/
|
||||
setupAccessibility() {
|
||||
// Keyboard navigation for custom elements
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.target.matches('[role="button"]:not(button):not(input)')) {
|
||||
e.preventDefault();
|
||||
e.target.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Focus management for modals
|
||||
document.addEventListener('shown.bs.modal', (e) => {
|
||||
const modal = e.target;
|
||||
const focusableElement = modal.querySelector('input, button, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusableElement) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup focus management
|
||||
*/
|
||||
setupFocusManagement() {
|
||||
// Auto-focus first input in forms
|
||||
const firstInput = document.querySelector('input:not([type="hidden"]):not([readonly])');
|
||||
if (firstInput && !firstInput.value) {
|
||||
setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
|
||||
// Skip links for accessibility
|
||||
const skipLinks = document.querySelectorAll('.skip-link');
|
||||
skipLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(link.getAttribute('href'));
|
||||
if (target) {
|
||||
target.focus();
|
||||
target.scrollIntoView();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup alert auto-dismissal
|
||||
*/
|
||||
setupAlertDismissal() {
|
||||
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
|
||||
|
||||
alerts.forEach(alert => {
|
||||
// Auto-dismiss success alerts
|
||||
if (alert.classList.contains('alert-success')) {
|
||||
setTimeout(() => {
|
||||
this.dismissAlert(alert);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Auto-dismiss info alerts
|
||||
if (alert.classList.contains('alert-info')) {
|
||||
setTimeout(() => {
|
||||
this.dismissAlert(alert);
|
||||
}, 8000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss alert with animation
|
||||
*/
|
||||
dismissAlert(alert) {
|
||||
alert.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transform = 'translateY(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dynamic actions
|
||||
*/
|
||||
handleDynamicAction(e) {
|
||||
e.preventDefault();
|
||||
const action = e.target.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'confirm-delete':
|
||||
this.handleConfirmDelete(e.target);
|
||||
break;
|
||||
case 'copy-link':
|
||||
this.handleCopyLink(e.target);
|
||||
break;
|
||||
case 'toggle-visibility':
|
||||
this.handleToggleVisibility(e.target);
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle confirm delete action
|
||||
*/
|
||||
handleConfirmDelete(element) {
|
||||
const message = element.dataset.message || 'Are you sure you want to delete this item?';
|
||||
const form = element.closest('form') || document.querySelector(element.dataset.form);
|
||||
|
||||
if (confirm(message) && form) {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle copy link action
|
||||
*/
|
||||
async handleCopyLink(element) {
|
||||
const url = element.dataset.url || window.location.href;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.showToast('Link copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err);
|
||||
this.showToast('Failed to copy link', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggle visibility action
|
||||
*/
|
||||
handleToggleVisibility(element) {
|
||||
const target = document.querySelector(element.dataset.target);
|
||||
const type = element.type;
|
||||
|
||||
if (target && type === 'password') {
|
||||
const isPassword = target.type === 'password';
|
||||
target.type = isPassword ? 'text' : 'password';
|
||||
|
||||
const icon = element.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.toggle('bi-eye');
|
||||
icon.classList.toggle('bi-eye-slash');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
const toastContainer = this.getOrCreateToastContainer();
|
||||
const toast = this.createToast(message, type);
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create toast container
|
||||
*/
|
||||
getOrCreateToastContainer() {
|
||||
let container = document.querySelector('.toast-container');
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
||||
container.style.zIndex = '1055';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create toast element
|
||||
*/
|
||||
createToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
|
||||
const iconMap = {
|
||||
success: 'bi-check-circle-fill text-success',
|
||||
error: 'bi-exclamation-circle-fill text-danger',
|
||||
warning: 'bi-exclamation-triangle-fill text-warning',
|
||||
info: 'bi-info-circle-fill text-info'
|
||||
};
|
||||
|
||||
const icon = iconMap[type] || iconMap.info;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-header">
|
||||
<i class="bi ${icon} me-2"></i>
|
||||
<strong class="me-auto">Resume Builder</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return toast;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
const app = new ResumeBuilderApp();
|
||||
|
||||
// Export for global access
|
||||
window.ResumeBuilderApp = app;
|
||||
32
resources/js/bootstrap.js
vendored
Normal file
32
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allows your team to easily build robust real-time web applications.
|
||||
*/
|
||||
|
||||
// import Echo from 'laravel-echo';
|
||||
|
||||
// import Pusher from 'pusher-js';
|
||||
// window.Pusher = Pusher;
|
||||
|
||||
// window.Echo = new Echo({
|
||||
// broadcaster: 'pusher',
|
||||
// key: import.meta.env.VITE_PUSHER_APP_KEY,
|
||||
// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
|
||||
// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
|
||||
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
|
||||
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
|
||||
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
|
||||
// enabledTransports: ['ws', 'wss'],
|
||||
// });
|
||||
234
resources/sass/abstracts/_mixins.scss
Normal file
234
resources/sass/abstracts/_mixins.scss
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Mixins - Enterprise SCSS Architecture
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// ==========================================================================
|
||||
// Media Query Mixins
|
||||
// ==========================================================================
|
||||
@mixin media-up($breakpoint) {
|
||||
@media (min-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-down($breakpoint) {
|
||||
@media (max-width: ($breakpoint - 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-between($min, $max) {
|
||||
@media (min-width: $min) and (max-width: ($max - 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Flexbox Mixins
|
||||
// ==========================================================================
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@mixin flex-column-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Button Mixins
|
||||
// ==========================================================================
|
||||
@mixin button-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-sm $spacing-base;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: $font-size-base;
|
||||
line-height: $line-height-tight;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all $transition-base;
|
||||
user-select: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-variant($bg-color, $text-color: $white, $hover-bg-color: null) {
|
||||
@include button-base;
|
||||
|
||||
background-color: $bg-color;
|
||||
color: $text-color;
|
||||
|
||||
@if $hover-bg-color {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $hover-bg-color;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
} @else {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: darken($bg-color, 8%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Card Mixins
|
||||
// ==========================================================================
|
||||
@mixin card-base {
|
||||
background-color: $white;
|
||||
border-radius: $radius-xl;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
transition: all $transition-base;
|
||||
}
|
||||
|
||||
@mixin card-hover {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Form Mixins
|
||||
// ==========================================================================
|
||||
@mixin form-control-base {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-base;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-normal;
|
||||
line-height: $line-height-normal;
|
||||
color: $text-primary;
|
||||
background-color: $white;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 0.25rem rgba($primary-color, 0.25);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: $gray-100;
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Animation Mixins
|
||||
// ==========================================================================
|
||||
@mixin fade-in($duration: $transition-base) {
|
||||
animation: fadeIn $duration ease-out;
|
||||
}
|
||||
|
||||
@mixin slide-up($duration: $transition-base) {
|
||||
animation: slideUp $duration ease-out;
|
||||
}
|
||||
|
||||
@mixin scale-in($duration: $transition-base) {
|
||||
animation: scaleIn $duration ease-out;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Utility Mixins
|
||||
// ==========================================================================
|
||||
@mixin sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@mixin clearfix {
|
||||
&::after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin aspect-ratio($width, $height) {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: percentage($height / $width);
|
||||
}
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Background Pattern Mixins
|
||||
// ==========================================================================
|
||||
@mixin grid-pattern($size: 50px, $color: rgba(255, 255, 255, 0.1)) {
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, $color 2px, transparent 2px),
|
||||
radial-gradient(circle at 75% 75%, $color 1px, transparent 1px);
|
||||
background-size: $size $size;
|
||||
}
|
||||
|
||||
@mixin dot-pattern($size: 20px, $color: rgba(0, 0, 0, 0.1)) {
|
||||
background-image: radial-gradient(circle, $color 1px, transparent 1px);
|
||||
background-size: $size $size;
|
||||
}
|
||||
288
resources/sass/abstracts/_variables.scss
Normal file
288
resources/sass/abstracts/_variables.scss
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Variables - Professional Bootstrap Design System for Laravel
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
* @description Professional color system and design tokens for Laravel with Bootstrap
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
CSS CUSTOM PROPERTIES (Professional Bootstrap Match)
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* Primary Brand Colors - Professional Blue */
|
||||
--color-primary: #1976d2;
|
||||
--color-primary-light: #42a5f5;
|
||||
--color-primary-dark: #1565c0;
|
||||
--color-primary-50: #e3f2fd;
|
||||
--color-primary-100: #bbdefb;
|
||||
--color-primary-200: #90caf9;
|
||||
--color-primary-300: #64b5f6;
|
||||
--color-primary-400: #42a5f5;
|
||||
--color-primary-500: #1976d2;
|
||||
--color-primary-600: #1976d2;
|
||||
--color-primary-700: #1565c0;
|
||||
--color-primary-800: #0d47a1;
|
||||
--color-primary-900: #0d47a1;
|
||||
|
||||
/* Accent Colors */
|
||||
--color-accent: #ff9800;
|
||||
--color-accent-light: #ffb74d;
|
||||
--color-accent-dark: #f57f17;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #4caf50;
|
||||
--color-success-light: #81c784;
|
||||
--color-success-dark: #388e3c;
|
||||
|
||||
--color-warning: #ff9800;
|
||||
--color-warning-light: #ffb74d;
|
||||
--color-warning-dark: #f57f17;
|
||||
|
||||
--color-error: #f44336;
|
||||
--color-error-light: #ef5350;
|
||||
--color-error-dark: #c62828;
|
||||
--color-error-bg: rgba(244, 67, 54, 0.05);
|
||||
|
||||
--color-info: #2196f3;
|
||||
--color-info-light: #64b5f6;
|
||||
--color-info-dark: #1976d2;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #212121;
|
||||
--color-text-secondary: #757575;
|
||||
--color-text-disabled: #bdbdbd;
|
||||
--color-text-hint: #9e9e9e;
|
||||
--color-text-muted: #555555;
|
||||
--color-text-dark: #444444;
|
||||
--color-text-light: #999999;
|
||||
--color-text-white: #ffffff;
|
||||
--color-text-white-muted: rgba(255, 255, 255, 0.9);
|
||||
|
||||
/* Background Colors */
|
||||
--color-background: #fafafa;
|
||||
--color-background-paper: #ffffff;
|
||||
--color-background-default: #f5f5f5;
|
||||
--color-background-dialog: #ffffff;
|
||||
|
||||
/* Surface Colors */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-variant: #f5f5f5;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #e0e0e0;
|
||||
--color-border-light: #f5f5f5;
|
||||
--color-border-dark: #bdbdbd;
|
||||
|
||||
/* Shadow Colors */
|
||||
--color-shadow-light: rgba(0, 0, 0, 0.1);
|
||||
--color-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||
--color-shadow-dark: rgba(0, 0, 0, 0.25);
|
||||
|
||||
/* Gradient Definitions */
|
||||
--gradient-primary: linear-gradient(135deg, #1976d2 0%, #42a5f5 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #ff9800 0%, #ffb74d 100%);
|
||||
--gradient-success: linear-gradient(135deg, #4caf50 0%, #81c784 100%);
|
||||
|
||||
/* Spacing System */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-xxl: 3rem;
|
||||
|
||||
/* Border Radius System */
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
--border-radius-xl: 16px;
|
||||
--border-radius-round: 50%;
|
||||
|
||||
/* Professional Shadow System */
|
||||
--shadow-xs: 0 1px 2px 0 var(--color-shadow-light);
|
||||
--shadow-sm: 0 1px 3px 0 var(--color-shadow-light), 0 1px 2px 0 var(--color-shadow-light);
|
||||
--shadow-md: 0 4px 6px -1px var(--color-shadow-light), 0 2px 4px -1px var(--color-shadow-light);
|
||||
--shadow-lg: 0 10px 15px -3px var(--color-shadow-light), 0 4px 6px -2px var(--color-shadow-light);
|
||||
--shadow-xl: 0 20px 25px -5px var(--color-shadow-light), 0 10px 10px -5px var(--color-shadow-light);
|
||||
--shadow-2xl: 0 25px 50px -12px var(--color-shadow-dark);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SCSS VARIABLES (Build-time)
|
||||
======================================== */
|
||||
|
||||
// Primary Brand Colors
|
||||
$primary-color: #1976d2;
|
||||
$primary-light: #42a5f5;
|
||||
$primary-dark: #1565c0;
|
||||
$secondary-color: #757575;
|
||||
$accent-color: #ff9800;
|
||||
|
||||
// Semantic Colors
|
||||
$success-color: #4caf50;
|
||||
$warning-color: #ff9800;
|
||||
$danger-color: #f44336;
|
||||
$info-color: #2196f3;
|
||||
|
||||
// Text Colors
|
||||
$text-primary: #212121;
|
||||
$text-secondary: #757575;
|
||||
$text-muted: #9e9e9e;
|
||||
$text-disabled: #bdbdbd;
|
||||
$black: #000000;
|
||||
$white: #ffffff;
|
||||
|
||||
// Background Colors
|
||||
$bg-primary: #ffffff;
|
||||
$bg-secondary: #fafafa;
|
||||
$bg-tertiary: #f5f5f5;
|
||||
|
||||
// Border Colors
|
||||
$border-color: #e0e0e0;
|
||||
$border-light: #f5f5f5;
|
||||
$border-dark: #bdbdbd;
|
||||
|
||||
// Spacing System
|
||||
$spacing-xs: 0.25rem;
|
||||
$spacing-sm: 0.5rem;
|
||||
$spacing-md: 1rem;
|
||||
$spacing-base: 1rem; // Same as md for compatibility
|
||||
$spacing-lg: 1.5rem;
|
||||
$spacing-xl: 2rem;
|
||||
$spacing-xxl: 3rem;
|
||||
|
||||
// Typography Scale
|
||||
$font-size-xs: 0.75rem; // 12px
|
||||
$font-size-sm: 0.875rem; // 14px
|
||||
$font-size-base: 1rem; // 16px
|
||||
$font-size-lg: 1.125rem; // 18px
|
||||
$font-size-xl: 1.25rem; // 20px
|
||||
$font-size-xxl: 1.5rem; // 24px
|
||||
$font-size-2xl: 1.5rem; // 24px
|
||||
$font-size-xxxl: 2rem; // 32px
|
||||
$font-size-3xl: 1.875rem; // 30px
|
||||
$font-size-4xl: 2.25rem; // 36px
|
||||
$font-size-5xl: 3rem; // 48px
|
||||
|
||||
$font-weight-light: 300;
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// Line Heights
|
||||
$line-height-tight: 1.25;
|
||||
$line-height-normal: 1.5;
|
||||
$line-height-relaxed: 1.75;
|
||||
|
||||
// Font Families (Material Design Compatible)
|
||||
$font-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
$font-family-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
|
||||
// Line Heights
|
||||
$line-height-tight: 1.2;
|
||||
$line-height-normal: 1.5;
|
||||
$line-height-relaxed: 1.6;
|
||||
|
||||
// Border Radius System
|
||||
$border-radius-sm: 4px;
|
||||
$border-radius-md: 8px;
|
||||
$border-radius-lg: 12px;
|
||||
$border-radius-xl: 16px;
|
||||
$border-radius-round: 50%;
|
||||
|
||||
// Breakpoints (Bootstrap Compatible)
|
||||
$breakpoint-xs: 576px;
|
||||
$breakpoint-sm: 768px;
|
||||
$breakpoint-md: 992px;
|
||||
$breakpoint-lg: 1200px;
|
||||
$breakpoint-xl: 1400px;
|
||||
|
||||
/* ========================================
|
||||
PROFESSIONAL MIXINS
|
||||
======================================== */
|
||||
|
||||
// Material Design Card Mixin
|
||||
@mixin material-card {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
padding: var(--spacing-lg);
|
||||
transition: var(--transition-normal);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Material Design Button Mixin
|
||||
@mixin material-button($color: var(--color-primary)) {
|
||||
background-color: $color;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-normal);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(110%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient Background Mixin
|
||||
@mixin gradient-background($gradient: var(--gradient-primary)) {
|
||||
background: $gradient;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
// Professional Input Mixin
|
||||
@mixin material-input {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-surface);
|
||||
transition: var(--transition-normal);
|
||||
font-size: $font-size-base;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-hint);
|
||||
}
|
||||
}
|
||||
47
resources/sass/app.scss
Normal file
47
resources/sass/app.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
// ==========================================================================
|
||||
// Enterprise SCSS Architecture - 7-1 Pattern
|
||||
// Professional Resume Builder
|
||||
// ==========================================================================
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Abstracts
|
||||
// ==========================================================================
|
||||
@import 'abstracts/variables';
|
||||
@import 'abstracts/mixins';
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Vendors/Third-party
|
||||
// ==========================================================================
|
||||
@import '~bootstrap/scss/bootstrap';
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Base
|
||||
// ==========================================================================
|
||||
@import 'base/base';
|
||||
|
||||
// ==========================================================================
|
||||
// 4. Layout
|
||||
// ==========================================================================
|
||||
@import 'layouts/header';
|
||||
@import 'layouts/footer';
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Components
|
||||
// ==========================================================================
|
||||
@import 'components/buttons';
|
||||
@import 'components/cards';
|
||||
@import 'components/forms';
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Pages
|
||||
// ==========================================================================
|
||||
@import 'pages/login';
|
||||
@import 'pages/register';
|
||||
@import 'pages/dashboard';
|
||||
@import 'pages/resume-index';
|
||||
@import 'pages/resume-create';
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Utilities
|
||||
// ==========================================================================
|
||||
@import 'utilities/helpers';
|
||||
231
resources/sass/base/_base.scss
Normal file
231
resources/sass/base/_base.scss
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Base Styles - Enterprise SCSS Architecture
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// ==========================================================================
|
||||
// CSS Reset & Base Styles
|
||||
// ==========================================================================
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: $font-family-primary;
|
||||
font-size: $font-size-base;
|
||||
line-height: $line-height-normal;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba($black, 0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: $font-family-primary;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-normal;
|
||||
line-height: $line-height-normal;
|
||||
color: $text-primary;
|
||||
background-color: $bg-secondary;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba($black, 0);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Typography Base
|
||||
// ==========================================================================
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: $spacing-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
line-height: $line-height-tight;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
h1 { font-size: $font-size-4xl; }
|
||||
h2 { font-size: $font-size-3xl; }
|
||||
h3 { font-size: $font-size-2xl; }
|
||||
h4 { font-size: $font-size-xl; }
|
||||
h5 { font-size: $font-size-lg; }
|
||||
h6 { font-size: $font-size-base; }
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: $spacing-base;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
small,
|
||||
.small {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-normal;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Links
|
||||
// ==========================================================================
|
||||
a {
|
||||
color: $primary-color;
|
||||
text-decoration: underline;
|
||||
transition: color $transition-base;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: darken($primary-color, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: thin dotted;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Lists
|
||||
// ==========================================================================
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: $spacing-base;
|
||||
padding-left: $spacing-xl;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Forms Base
|
||||
// ==========================================================================
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Images & Media
|
||||
// ==========================================================================
|
||||
img {
|
||||
border-style: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
svg {
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tables
|
||||
// ==========================================================================
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: $spacing-sm;
|
||||
padding-bottom: $spacing-sm;
|
||||
color: $text-muted;
|
||||
text-align: left;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Code
|
||||
// ==========================================================================
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: $font-family-mono;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: $spacing-base;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: scrollbar;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Accessibility
|
||||
// ==========================================================================
|
||||
.sr-only {
|
||||
@include sr-only;
|
||||
}
|
||||
|
||||
.sr-only-focusable {
|
||||
&:active,
|
||||
&:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Focus States
|
||||
// ==========================================================================
|
||||
:focus {
|
||||
outline: 2px solid $primary-color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid $primary-color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
465
resources/sass/components/_buttons.scss
Normal file
465
resources/sass/components/_buttons.scss
Normal file
@@ -0,0 +1,465 @@
|
||||
// ==========================================================================
|
||||
// Button Components
|
||||
// Professional Resume Builder - Button Styles
|
||||
// ==========================================================================
|
||||
|
||||
// Base button enhancements
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease-in-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Button loading state
|
||||
&.btn-loading {
|
||||
color: transparent !important;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: btn-spinner 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Primary button variants
|
||||
.btn-primary {
|
||||
background: var(--brand-gradient);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-gradient-hover);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--brand-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary-soft {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
color: var(--brand-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
color: var(--brand-primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Success button variants
|
||||
.btn-success {
|
||||
background: var(--brand-gradient-success);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-gradient-success-hover);
|
||||
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-success-soft {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--brand-success);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: var(--brand-success-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Warning button variants
|
||||
.btn-warning {
|
||||
background: var(--brand-gradient-warning);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-gradient-warning-hover);
|
||||
box-shadow: 0 6px 20px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-warning-soft {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
color: var(--brand-warning);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
color: var(--brand-warning-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Danger button variants
|
||||
.btn-danger {
|
||||
background: var(--brand-gradient-danger);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-gradient-danger-hover);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger-soft {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--brand-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: var(--brand-danger-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Outline button enhancements
|
||||
.btn-outline-primary {
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--brand-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
border-color: var(--brand-success);
|
||||
color: var(--brand-success);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-success);
|
||||
border-color: var(--brand-success);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-warning {
|
||||
border-color: var(--brand-warning);
|
||||
color: var(--brand-warning);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-warning);
|
||||
border-color: var(--brand-warning);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
border-color: var(--brand-danger);
|
||||
color: var(--brand-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-danger);
|
||||
border-color: var(--brand-danger);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
// Button sizes
|
||||
.btn-xs {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.btn-xl {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
|
||||
// Button with icons
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-icon-only {
|
||||
padding: 0.625rem;
|
||||
|
||||
i {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating action button
|
||||
.btn-fab {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1050;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
i {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Button groups
|
||||
.btn-group {
|
||||
.btn {
|
||||
&:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: none;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle buttons
|
||||
.btn-toggle {
|
||||
background: var(--color-white);
|
||||
border: 2px solid var(--color-gray-200);
|
||||
color: var(--color-gray-600);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-gray-50);
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--brand-primary-dark);
|
||||
border-color: var(--brand-primary-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient buttons
|
||||
.btn-gradient-purple {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-gradient-orange {
|
||||
background: linear-gradient(135deg, #ff9a56 0%, #ff6b95 100%);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(255, 154, 86, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #ff8a46 0%, #ff5b85 100%);
|
||||
box-shadow: 0 6px 20px rgba(255, 154, 86, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-gradient-teal {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
|
||||
box-shadow: 0 6px 20px rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// Social buttons
|
||||
.btn-social {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
i {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.btn-google {
|
||||
background: #db4437;
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: #c23321;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-facebook {
|
||||
background: #3b5998;
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: #2d4373;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-linkedin {
|
||||
background: #0077b5;
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: #005885;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-github {
|
||||
background: #333;
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: #24292e;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Button animations
|
||||
@keyframes btn-spinner {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes btn-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-pulse {
|
||||
animation: btn-pulse 2s infinite;
|
||||
}
|
||||
|
||||
// Responsive button adjustments
|
||||
@include media-breakpoint-down(sm) {
|
||||
.btn-fab {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.125rem;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
537
resources/sass/components/_cards.scss
Normal file
537
resources/sass/components/_cards.scss
Normal file
@@ -0,0 +1,537 @@
|
||||
// ==========================================================================
|
||||
// Card Components
|
||||
// Professional Resume Builder - Card Styles
|
||||
// ==========================================================================
|
||||
|
||||
// Base card enhancements
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease-in-out;
|
||||
background: var(--color-white);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
// Card header enhancements
|
||||
.card-header {
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
&.bg-light {
|
||||
background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-white) 100%) !important;
|
||||
}
|
||||
|
||||
&.bg-primary {
|
||||
background: var(--brand-gradient) !important;
|
||||
color: var(--color-white);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.bg-success {
|
||||
background: var(--brand-gradient-success) !important;
|
||||
color: var(--color-white);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Card body enhancements
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
// Card footer enhancements
|
||||
.card-footer {
|
||||
background-color: var(--color-gray-50);
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
// Feature Cards
|
||||
.feature-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease-in-out;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||
|
||||
&.icon-success {
|
||||
background: var(--brand-gradient-success);
|
||||
box-shadow: 0 8px 16px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&.icon-warning {
|
||||
background: var(--brand-gradient-warning);
|
||||
box-shadow: 0 8px 16px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
&.icon-info {
|
||||
background: var(--brand-gradient-info);
|
||||
box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--color-gray-600);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
margin-top: var(--spacing-md);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-left: 0.25rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Cards
|
||||
.stats-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 20px -5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--brand-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1;
|
||||
|
||||
&.text-success {
|
||||
color: var(--brand-success);
|
||||
}
|
||||
|
||||
&.text-warning {
|
||||
color: var(--brand-warning);
|
||||
}
|
||||
|
||||
&.text-danger {
|
||||
color: var(--brand-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
color: var(--color-gray-600);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-change {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.positive {
|
||||
color: var(--brand-success);
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: var(--brand-danger);
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile Cards
|
||||
.profile-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 20px -5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto var(--spacing-md);
|
||||
border: 4px solid var(--color-white);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
color: var(--color-gray-600);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Testimonial Cards
|
||||
.testimonial-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: var(--spacing-lg);
|
||||
font-size: 4rem;
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.testimonial-content {
|
||||
font-style: italic;
|
||||
color: var(--color-gray-700);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.author-avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.author-info {
|
||||
.author-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.author-role {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-rating {
|
||||
display: flex;
|
||||
gap: 0.125rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
i {
|
||||
color: var(--brand-warning);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing Cards
|
||||
.pricing-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.featured {
|
||||
border: 2px solid var(--brand-primary);
|
||||
transform: scale(1.05);
|
||||
|
||||
.pricing-badge {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
padding: 0.375rem 1rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.pricing-price {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
.price-currency {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-gray-600);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: var(--brand-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-period {
|
||||
color: var(--color-gray-600);
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--spacing-xl);
|
||||
|
||||
li {
|
||||
padding: 0.5rem 0;
|
||||
color: var(--color-gray-600);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
color: var(--brand-success);
|
||||
margin-right: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card variations
|
||||
.card-elevated {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-flat {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
}
|
||||
|
||||
.card-bordered {
|
||||
border: 2px solid var(--color-gray-100);
|
||||
|
||||
&.border-primary {
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
&.border-success {
|
||||
border-color: var(--brand-success);
|
||||
}
|
||||
|
||||
&.border-warning {
|
||||
border-color: var(--brand-warning);
|
||||
}
|
||||
|
||||
&.border-danger {
|
||||
border-color: var(--brand-danger);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include media-breakpoint-down(md) {
|
||||
.feature-card {
|
||||
padding: var(--spacing-lg);
|
||||
text-align: left;
|
||||
|
||||
.feature-icon {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
.stats-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
.profile-avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
&.featured {
|
||||
transform: none;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pricing-price {
|
||||
.price-amount {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
.testimonial-content {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card animations
|
||||
@keyframes cardFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card-animate {
|
||||
animation: cardFadeIn 0.6s ease-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
}
|
||||
585
resources/sass/components/_forms.scss
Normal file
585
resources/sass/components/_forms.scss
Normal file
@@ -0,0 +1,585 @@
|
||||
// ==========================================================================
|
||||
// Form Components
|
||||
// Professional Resume Builder - Form Styles
|
||||
// ==========================================================================
|
||||
|
||||
// Base form enhancements
|
||||
.form-control, .form-select {
|
||||
border: 2px solid var(--color-gray-200);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background-color: var(--color-white);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1);
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--color-gray-50);
|
||||
border-color: var(--color-gray-100);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.is-valid {
|
||||
border-color: var(--brand-success);
|
||||
box-shadow: 0 0 0 0.2rem rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
border-color: var(--brand-danger);
|
||||
box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Form labels
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-700);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&.required {
|
||||
&::after {
|
||||
content: " *";
|
||||
color: var(--brand-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating labels
|
||||
.form-floating {
|
||||
position: relative;
|
||||
|
||||
.form-control, .form-select {
|
||||
height: 3.5rem;
|
||||
padding: 1rem 0.75rem;
|
||||
|
||||
&:focus, &:not(:placeholder-shown) {
|
||||
padding-top: 1.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem 0.75rem;
|
||||
overflow: hidden;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
border: 2px solid transparent;
|
||||
transform-origin: 0 0;
|
||||
transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.form-control:focus ~ label,
|
||||
.form-control:not(:placeholder-shown) ~ label,
|
||||
.form-select ~ label {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.form-control.is-invalid:focus ~ label,
|
||||
.form-control.is-invalid:not(:placeholder-shown) ~ label {
|
||||
color: var(--brand-danger);
|
||||
}
|
||||
|
||||
.form-control.is-valid:focus ~ label,
|
||||
.form-control.is-valid:not(:placeholder-shown) ~ label {
|
||||
color: var(--brand-success);
|
||||
}
|
||||
}
|
||||
|
||||
// Input groups
|
||||
.input-group {
|
||||
.form-control {
|
||||
&:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background-color: var(--color-gray-50);
|
||||
border: 2px solid var(--color-gray-200);
|
||||
color: var(--color-gray-600);
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
i {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form check (checkboxes and radios)
|
||||
.form-check {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.form-check-input {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.125rem;
|
||||
border: 2px solid var(--color-gray-300);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
&[type="radio"] {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-700);
|
||||
margin-left: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.form-switch {
|
||||
.form-check-input {
|
||||
width: 2.5rem;
|
||||
height: 1.25rem;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280,0,0,.25%29'/%3e%3c/svg%3e");
|
||||
background-position: left center;
|
||||
border-radius: var(--border-radius-full);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:checked {
|
||||
background-position: right center;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255,255,255,1.0%29'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Range inputs
|
||||
.form-range {
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-slider-track {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
background: var(--color-gray-200);
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: var(--brand-primary);
|
||||
border: 2px solid var(--color-white);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
background: var(--color-gray-200);
|
||||
border-radius: var(--border-radius-full);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: var(--brand-primary);
|
||||
border: 2px solid var(--color-white);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// File inputs
|
||||
.form-control[type="file"] {
|
||||
padding: 0.375rem 0.75rem;
|
||||
|
||||
&::file-selector-button {
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin: -0.375rem -0.75rem -0.375rem 0;
|
||||
margin-inline-end: 0.75rem;
|
||||
color: var(--color-gray-600);
|
||||
background-color: var(--color-gray-100);
|
||||
pointer-events: none;
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
border-inline-end-width: 2px;
|
||||
border-radius: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not([readonly])::file-selector-button {
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
}
|
||||
|
||||
// Form validation
|
||||
.was-validated .form-control:valid,
|
||||
.form-control.is-valid {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2322c55e' d='m2.3 6.73.8.8 4.1-4.1-.8-.8L3.1 5.9l-1.6-1.6-.8.8z'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right calc(0.375em + 0.1875rem) center;
|
||||
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
|
||||
}
|
||||
|
||||
.was-validated .form-control:invalid,
|
||||
.form-control.is-invalid {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ef4444'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath d='m5.8 4.6 2.4 2.4M8.2 4.6l-2.4 2.4'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right calc(0.375em + 0.1875rem) center;
|
||||
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
|
||||
}
|
||||
|
||||
// Feedback messages
|
||||
.valid-feedback {
|
||||
color: var(--brand-success);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: var(--brand-danger);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
// Custom form controls
|
||||
.custom-file-upload {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-step form
|
||||
.multi-step-form {
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--color-gray-200);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
&:not(:last-child)::after {
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
background: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.completed {
|
||||
.step-number {
|
||||
background: var(--brand-success);
|
||||
color: var(--color-white);
|
||||
|
||||
&::before {
|
||||
content: "✓";
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: var(--brand-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-gray-200);
|
||||
color: var(--color-gray-600);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
margin: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
|
||||
.btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form sections
|
||||
.form-section {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--color-gray-100);
|
||||
|
||||
i {
|
||||
color: var(--brand-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive form adjustments
|
||||
@include media-breakpoint-down(md) {
|
||||
.form-floating {
|
||||
.form-control, .form-select {
|
||||
height: 3.25rem;
|
||||
padding: 0.875rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-step-form {
|
||||
.step-indicator {
|
||||
.step-number {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
|
||||
&.btn-outline-secondary {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
.section-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form animations
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.form-control.is-invalid,
|
||||
.form-select.is-invalid {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
// Loading states
|
||||
.form-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-lg);
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Form success state
|
||||
.form-success {
|
||||
text-align: center;
|
||||
padding: var(--spacing-4xl) var(--spacing-lg);
|
||||
|
||||
.success-icon {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-gradient-success);
|
||||
color: var(--color-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
font-size: 2rem;
|
||||
animation: successPulse 2s infinite;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--color-dark);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
484
resources/sass/layouts/_footer.scss
Normal file
484
resources/sass/layouts/_footer.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
// ==========================================================================
|
||||
// Footer Layout Styles
|
||||
// Professional Resume Builder - Footer
|
||||
// ==========================================================================
|
||||
|
||||
.main-footer {
|
||||
background: linear-gradient(135deg, var(--color-gray-900) 0%, var(--color-gray-800) 100%);
|
||||
color: var(--color-gray-300);
|
||||
padding: var(--spacing-4xl) 0 var(--spacing-xl);
|
||||
margin-top: auto;
|
||||
|
||||
.footer-content {
|
||||
.footer-brand {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.brand-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: var(--spacing-md);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-description {
|
||||
color: var(--color-gray-400);
|
||||
line-height: 1.6;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
.footer-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
h6 {
|
||||
color: var(--color-white);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
a {
|
||||
color: var(--color-gray-400);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-white);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
width: 1rem;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.contact-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: var(--spacing-md);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
.contact-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
color: var(--color-gray-300);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-social {
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
a {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-gray-300);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
font-size: 1.125rem;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&.facebook:hover {
|
||||
background: #3b5998;
|
||||
}
|
||||
|
||||
&.twitter:hover {
|
||||
background: #1da1f2;
|
||||
}
|
||||
|
||||
&.linkedin:hover {
|
||||
background: #0077b5;
|
||||
}
|
||||
|
||||
&.github:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
&.instagram:hover {
|
||||
background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.newsletter {
|
||||
.newsletter-title {
|
||||
color: var(--color-white);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.newsletter-description {
|
||||
color: var(--color-gray-400);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
.form-control {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-white);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--brand-primary);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--brand-gradient);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: var(--spacing-lg);
|
||||
margin-top: var(--spacing-xl);
|
||||
|
||||
.footer-copyright {
|
||||
color: var(--color-gray-500);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-legal {
|
||||
.legal-links {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--color-gray-500);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal footer variant
|
||||
.footer-minimal {
|
||||
background: var(--color-white);
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
padding: var(--spacing-lg) 0;
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.footer-text {
|
||||
color: var(--color-gray-600);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--color-gray-600);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auth footer (for login/register pages)
|
||||
.auth-footer {
|
||||
background: transparent;
|
||||
padding: var(--spacing-lg) 0;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
|
||||
.footer-text {
|
||||
color: var(--color-gray-600);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@include media-breakpoint-down(lg) {
|
||||
.main-footer {
|
||||
.footer-content {
|
||||
.footer-links {
|
||||
.footer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-social {
|
||||
margin-top: var(--spacing-xl);
|
||||
|
||||
.social-links {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
flex-direction: column;
|
||||
|
||||
.form-control {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
|
||||
.footer-legal {
|
||||
margin-top: var(--spacing-md);
|
||||
|
||||
.legal-links {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-minimal {
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.footer-links {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.main-footer {
|
||||
padding: var(--spacing-2xl) 0 var(--spacing-lg);
|
||||
|
||||
.footer-content {
|
||||
.footer-brand {
|
||||
text-align: center;
|
||||
|
||||
.brand-description {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
text-align: center;
|
||||
|
||||
.contact-item {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
.footer-links {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer animations
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-animate {
|
||||
.footer-section {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
478
resources/sass/layouts/_header.scss
Normal file
478
resources/sass/layouts/_header.scss
Normal file
@@ -0,0 +1,478 @@
|
||||
// ==========================================================================
|
||||
// Header Layout Styles
|
||||
// Professional Resume Builder - Header/Navigation
|
||||
// ==========================================================================
|
||||
|
||||
// Main navigation
|
||||
.main-navbar {
|
||||
background: var(--color-white);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
padding: 0.5rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.scrolled {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary-dark);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--brand-gradient);
|
||||
color: var(--color-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-700);
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary);
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--brand-primary);
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
border: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler-icon {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28102, 126, 234, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User dropdown
|
||||
.user-dropdown {
|
||||
.dropdown-toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.375rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-white);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
object-fit: cover;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: left;
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-600);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin-left: 0.5rem;
|
||||
color: var(--color-gray-400);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
.dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
padding: 0.5rem;
|
||||
min-width: 200px;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.dropdown-item {
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.625rem 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-gray-700);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.75rem;
|
||||
width: 1rem;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
&.text-danger {
|
||||
color: var(--brand-danger);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--brand-danger);
|
||||
}
|
||||
|
||||
i {
|
||||
color: var(--brand-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
border-color: var(--color-gray-100);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification badge
|
||||
.notification-badge {
|
||||
position: relative;
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -0.375rem;
|
||||
right: -0.375rem;
|
||||
background: var(--brand-danger);
|
||||
color: var(--color-white);
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
font-weight: 600;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
// Search bar in header
|
||||
.header-search {
|
||||
position: relative;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem 0.625rem 2.75rem;
|
||||
border: 2px solid var(--color-gray-200);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-white);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-gray-400);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1050;
|
||||
display: none;
|
||||
|
||||
&.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb
|
||||
.custom-breadcrumb {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
.breadcrumb {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.breadcrumb-item {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
|
||||
&.active {
|
||||
color: var(--color-gray-800);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile navigation
|
||||
@include media-breakpoint-down(lg) {
|
||||
.main-navbar {
|
||||
.navbar-collapse {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.navbar-nav {
|
||||
.nav-link {
|
||||
padding: 0.875rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&.active::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
.brand-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
|
||||
.dropdown-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--color-gray-50);
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
.user-info {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.dropdown-item {
|
||||
background: var(--color-white);
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-search {
|
||||
margin: var(--spacing-md) 0;
|
||||
|
||||
.search-input {
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header animations
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-collapse.collapsing,
|
||||
.navbar-collapse.show {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
// Sticky header behavior
|
||||
.header-hidden {
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.header-visible {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
570
resources/sass/pages/_dashboard-old.scss
Normal file
570
resources/sass/pages/_dashboard-old.scss
Normal file
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* Dashboard Page Styles - Enterprise SCSS Architecture
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// ==========================================================================
|
||||
// Dashboard Container
|
||||
// ==========================================================================
|
||||
.dashboard-container {
|
||||
padding: $spacing-lg 0;
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
padding: $spacing-base 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Hero Section
|
||||
// ==========================================================================
|
||||
.hero-section {
|
||||
margin-bottom: $spacing-4xl;
|
||||
|
||||
.hero-card {
|
||||
@include card-base;
|
||||
background: $gradient-primary;
|
||||
color: $white;
|
||||
border-radius: $radius-2xl;
|
||||
box-shadow: $shadow-2xl;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
@include grid-pattern(60px, rgba(255, 255, 255, 0.1));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-4xl;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
padding: $spacing-3xl $spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.large-icon {
|
||||
font-size: 4rem;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: $font-size-4xl;
|
||||
font-weight: $font-weight-bold;
|
||||
margin-bottom: $spacing-lg;
|
||||
text-shadow: 0 2px 4px rgba($black, 0.2);
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
font-size: $font-size-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: $font-size-lg;
|
||||
opacity: 0.9;
|
||||
margin-bottom: $spacing-lg;
|
||||
font-weight: $font-weight-light;
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
}
|
||||
|
||||
.author-signature {
|
||||
font-size: $font-size-base;
|
||||
opacity: 0.8;
|
||||
margin-bottom: $spacing-2xl;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
@include flex-center;
|
||||
gap: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
flex-direction: column;
|
||||
gap: $spacing-base;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@include button-base;
|
||||
padding: $spacing-md $spacing-xl;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
border-radius: $radius-md;
|
||||
transition: all $transition-base;
|
||||
|
||||
&.btn-light {
|
||||
background-color: $white;
|
||||
color: $primary-color;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($white, 0.9);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline-light {
|
||||
background-color: transparent;
|
||||
color: $white;
|
||||
border: 2px solid rgba($white, 0.8);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($white, 0.1);
|
||||
border-color: $white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Stats Section
|
||||
// ==========================================================================
|
||||
.stats-section {
|
||||
margin-bottom: $spacing-4xl;
|
||||
|
||||
.stat-card {
|
||||
@include card-base;
|
||||
@include card-hover;
|
||||
border: none;
|
||||
height: 100%;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: $shadow-xl;
|
||||
|
||||
.stat-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-2xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@include flex-center;
|
||||
margin: 0 auto $spacing-lg auto;
|
||||
border-radius: $radius-full;
|
||||
transition: all $transition-base;
|
||||
|
||||
i {
|
||||
font-size: $font-size-2xl;
|
||||
}
|
||||
|
||||
&.bg-primary {
|
||||
background: linear-gradient(135deg, $primary-color, lighten($primary-color, 10%));
|
||||
}
|
||||
|
||||
&.bg-success {
|
||||
background: linear-gradient(135deg, $success-color, lighten($success-color, 10%));
|
||||
}
|
||||
|
||||
&.bg-warning {
|
||||
background: linear-gradient(135deg, $warning-color, lighten($warning-color, 10%));
|
||||
}
|
||||
|
||||
&.bg-info {
|
||||
background: linear-gradient(135deg, $info-color, lighten($info-color, 10%));
|
||||
}
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
margin-bottom: $spacing-xs;
|
||||
line-height: 1;
|
||||
|
||||
&.text-primary { color: $primary-color; }
|
||||
&.text-success { color: $success-color; }
|
||||
&.text-warning { color: $warning-color; }
|
||||
&.text-info { color: $info-color; }
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
font-size: $font-size-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: $font-size-base;
|
||||
color: $text-muted;
|
||||
font-weight: $font-weight-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Features Section
|
||||
// ==========================================================================
|
||||
.features-section {
|
||||
margin-bottom: $spacing-4xl;
|
||||
|
||||
.section-title {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
font-size: $font-size-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-muted;
|
||||
margin-bottom: $spacing-2xl;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
@include card-base;
|
||||
@include card-hover;
|
||||
border: none;
|
||||
height: 100%;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: $shadow-xl;
|
||||
|
||||
.feature-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-2xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@include flex-center;
|
||||
margin: 0 auto $spacing-lg auto;
|
||||
border-radius: $radius-full;
|
||||
background-color: rgba($primary-color, 0.1);
|
||||
transition: all $transition-base;
|
||||
|
||||
i {
|
||||
font-size: $font-size-3xl;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: $font-size-base;
|
||||
color: $text-muted;
|
||||
line-height: $line-height-relaxed;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Recent Activity Section
|
||||
// ==========================================================================
|
||||
.recent-activity-section {
|
||||
margin-bottom: $spacing-4xl;
|
||||
|
||||
.card {
|
||||
@include card-base;
|
||||
border: none;
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: $spacing-xl $spacing-xl 0 $spacing-xl;
|
||||
|
||||
.card-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
|
||||
i {
|
||||
color: $primary-color;
|
||||
margin-right: $spacing-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-lg $spacing-xl $spacing-xl $spacing-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: $spacing-lg;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
background-color: rgba($gray-50, 0.5);
|
||||
transition: all $transition-base;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($gray-100, 0.8);
|
||||
border-color: $border-color-hover;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-muted;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
&.small {
|
||||
font-size: $font-size-xs;
|
||||
|
||||
i {
|
||||
margin-right: $spacing-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Quick Actions Section
|
||||
// ==========================================================================
|
||||
.quick-actions-section {
|
||||
.card {
|
||||
@include card-base;
|
||||
border: none;
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: $spacing-xl $spacing-xl 0 $spacing-xl;
|
||||
|
||||
.card-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
|
||||
i {
|
||||
color: $primary-color;
|
||||
margin-right: $spacing-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-lg $spacing-xl $spacing-xl $spacing-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.action-card {
|
||||
@include card-base;
|
||||
text-decoration: none;
|
||||
height: 100%;
|
||||
transition: all $transition-base;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.border-primary {
|
||||
border-color: rgba($primary-color, 0.3);
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
background-color: rgba($primary-color, 0.02);
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: $shadow-xl;
|
||||
|
||||
i {
|
||||
transform: scale(1.2) rotate(10deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.border-success {
|
||||
border-color: rgba($success-color, 0.3);
|
||||
|
||||
&:hover {
|
||||
border-color: $success-color;
|
||||
background-color: rgba($success-color, 0.02);
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: $shadow-xl;
|
||||
|
||||
i {
|
||||
transform: scale(1.2) rotate(-10deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.border-warning {
|
||||
border-color: rgba($warning-color, 0.3);
|
||||
|
||||
&:hover {
|
||||
border-color: $warning-color;
|
||||
background-color: rgba($warning-color, 0.02);
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: $shadow-xl;
|
||||
|
||||
i {
|
||||
transform: scale(1.2) rotate(5deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: $spacing-2xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: $spacing-lg;
|
||||
transition: all $transition-base;
|
||||
|
||||
&.text-primary { color: $primary-color; }
|
||||
&.text-success { color: $success-color; }
|
||||
&.text-warning { color: $warning-color; }
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
&.text-primary { color: $primary-color; }
|
||||
&.text-success { color: $success-color; }
|
||||
&.text-warning { color: $warning-color; }
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-muted;
|
||||
margin: 0;
|
||||
line-height: $line-height-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Animations
|
||||
// ==========================================================================
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-container > section {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0; }
|
||||
&:nth-child(2) { animation-delay: 0.1s; }
|
||||
&:nth-child(3) { animation-delay: 0.2s; }
|
||||
&:nth-child(4) { animation-delay: 0.3s; }
|
||||
&:nth-child(5) { animation-delay: 0.4s; }
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Responsive Design
|
||||
// ==========================================================================
|
||||
@include media-down($breakpoint-lg) {
|
||||
.features-section {
|
||||
.feature-card {
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions-section {
|
||||
.action-card {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-down($breakpoint-md) {
|
||||
.dashboard-container {
|
||||
padding: $spacing-base 0;
|
||||
}
|
||||
|
||||
.stats-section,
|
||||
.features-section,
|
||||
.recent-activity-section {
|
||||
margin-bottom: $spacing-2xl;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
margin-bottom: $spacing-2xl;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.feature-card {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.recent-activity-section {
|
||||
.activity-item {
|
||||
margin-bottom: $spacing-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
357
resources/sass/pages/_dashboard.scss
Normal file
357
resources/sass/pages/_dashboard.scss
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Dashboard/Home Page - Professional Bootstrap Design
|
||||
* Clean professional homepage styling
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Home Container & Layout (Matching Angular)
|
||||
========================================================================== */
|
||||
|
||||
.home-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 0 24px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Hero Section (Exact Angular Material Match)
|
||||
========================================================================== */
|
||||
|
||||
.hero-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
@include gradient-background(var(--gradient-primary));
|
||||
color: var(--color-text-white);
|
||||
border-radius: var(--border-radius-xl);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-icon .large-icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-white-muted);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: $font-weight-light;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.author-signature {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 32px;
|
||||
opacity: 0.8;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primary-cta, .secondary-cta {
|
||||
@include material-button();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-medium;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: var(--transition-normal);
|
||||
text-decoration: none;
|
||||
|
||||
i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-cta {
|
||||
background-color: var(--color-text-white);
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-text-white);
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-white);
|
||||
border-color: var(--color-text-white);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-cta {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-white);
|
||||
border: 2px solid var(--color-text-white);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-text-white);
|
||||
color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Features Section (Angular Material Grid Match)
|
||||
========================================================================== */
|
||||
|
||||
.features-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: $font-weight-normal;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
@include material-card;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-light);
|
||||
|
||||
.card-header {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-avatar {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-text-white);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--border-radius-round);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-highlights {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.feature-highlights li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--color-success);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Technology Stack Section (Angular Material Match)
|
||||
========================================================================== */
|
||||
|
||||
.tech-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.tech-card {
|
||||
@include material-card;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
|
||||
.card-header {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-title {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
i {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tech-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-surface-variant);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: var(--transition-normal);
|
||||
min-width: 100px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.tech-item i {
|
||||
color: var(--color-primary);
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.tech-item span {
|
||||
font-size: 0.9rem;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tech-description {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 24px;
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Design (Angular Material Breakpoints)
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-container {
|
||||
padding: 24px 16px 0 16px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.primary-cta,
|
||||
.secondary-cta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tech-stack {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
min-width: 80px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.features-grid {
|
||||
.col-lg-4:nth-child(3) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-container {
|
||||
padding: 16px 12px 0 12px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
494
resources/sass/pages/_login.scss
Normal file
494
resources/sass/pages/_login.scss
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Login Page - Professional Bootstrap Design
|
||||
* Clean and modern login interface styling
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Login Container & Layout (Angular Material Match)
|
||||
========================================================================== */
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Hero Background Section (Left Side - Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.hero-background {
|
||||
flex: 1;
|
||||
@include gradient-background(var(--gradient-primary));
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.hero-icon .large-icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-white-muted);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: $font-weight-light;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.author-signature {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login Form Container (Right Side - Angular Material Card)
|
||||
========================================================================== */
|
||||
|
||||
.login-form-container {
|
||||
flex: 0 0 520px;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 32px;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
flex: 1;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@include material-card;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background-color: var(--color-surface);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-radius: var(--border-radius-xl);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 48px 32px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Form Header (Angular Material Avatar & Title)
|
||||
========================================================================== */
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: var(--border-radius-round);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
color: var(--color-text-white);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: $font-weight-normal;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Material Design Form Fields (Angular Material Input Style)
|
||||
========================================================================== */
|
||||
|
||||
.login-form {
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.material-input {
|
||||
@include material-input;
|
||||
width: 100%;
|
||||
padding: 16px 12px 8px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: var(--transition-normal);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
|
||||
outline: none;
|
||||
|
||||
+ .form-label {
|
||||
color: var(--color-primary);
|
||||
transform: translateY(-20px) scale(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown) + .form-label {
|
||||
transform: translateY(-20px) scale(0.85);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
+ .form-label {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-hint);
|
||||
pointer-events: none;
|
||||
transition: var(--transition-normal);
|
||||
transform-origin: left top;
|
||||
background-color: var(--color-surface);
|
||||
padding: 0 4px;
|
||||
margin-left: -4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-outline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
/* Password Toggle Button */
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-hint);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Form Options (Remember Me & Forgot Password)
|
||||
========================================================================== */
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.form-check-input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-radius: 3px;
|
||||
background-color: var(--color-surface);
|
||||
|
||||
&:checked {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login Button (Angular Material Raised Button)
|
||||
========================================================================== */
|
||||
|
||||
.login-btn {
|
||||
@include material-button(var(--color-primary));
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-medium;
|
||||
border-radius: var(--border-radius-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Social Login Section (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.social-login {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: var(--color-surface);
|
||||
padding: 0 16px;
|
||||
color: var(--color-text-hint);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-surface-variant);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.google-btn:hover {
|
||||
border-color: #db4437;
|
||||
color: #db4437;
|
||||
}
|
||||
|
||||
&.github-btn:hover {
|
||||
border-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Register Link
|
||||
========================================================================== */
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.register-link-text {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Messages (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.alert {
|
||||
border-radius: var(--border-radius-md);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&.alert-danger {
|
||||
background-color: var(--color-error-bg);
|
||||
color: var(--color-error-dark);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-error);
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Design
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.login-container {
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 0;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card .card-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
188
resources/sass/pages/_register-complete.scss
Normal file
188
resources/sass/pages/_register-complete.scss
Normal file
@@ -0,0 +1,188 @@
|
||||
/* ==========================================================================
|
||||
Register Button (Angular Material Raised Button)
|
||||
========================================================================== */
|
||||
|
||||
.register-btn {
|
||||
@include material-button(var(--color-success));
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-medium;
|
||||
border-radius: var(--border-radius-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-success-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Social Registration Section (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.social-login {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: var(--color-surface);
|
||||
padding: 0 16px;
|
||||
color: var(--color-text-hint);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-surface-variant);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.google-btn:hover {
|
||||
border-color: #db4437;
|
||||
color: #db4437;
|
||||
}
|
||||
|
||||
&.github-btn:hover {
|
||||
border-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login Link
|
||||
========================================================================== */
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-link-text {
|
||||
color: var(--color-success);
|
||||
text-decoration: none;
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-success-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Messages (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.alert {
|
||||
border-radius: var(--border-radius-md);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&.alert-danger {
|
||||
background-color: var(--color-error-bg);
|
||||
color: var(--color-error-dark);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-error);
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Design
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.register-container {
|
||||
background: var(--gradient-success);
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
margin: 0;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.register-card .card-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
516
resources/sass/pages/_register.scss
Normal file
516
resources/sass/pages/_register.scss
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* Register Page - Professional Bootstrap Design
|
||||
* Clean and modern registration interface styling
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Register Container & Layout (Angular Material Match)
|
||||
========================================================================== */
|
||||
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Hero Background Section (Left Side - Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.hero-background {
|
||||
flex: 1;
|
||||
@include gradient-background(var(--gradient-success));
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.hero-icon .large-icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-white-muted);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: $font-weight-light;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.author-signature {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Register Form Container (Right Side - Angular Material Card)
|
||||
========================================================================== */
|
||||
|
||||
.register-form-container {
|
||||
flex: 0 0 520px;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
flex: 1;
|
||||
background: var(--gradient-success);
|
||||
}
|
||||
}
|
||||
|
||||
.register-card {
|
||||
@include material-card;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background-color: var(--color-surface);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-radius: var(--border-radius-xl);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 40px 32px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Form Header (Angular Material Avatar & Title)
|
||||
========================================================================== */
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.register-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: var(--color-success);
|
||||
border-radius: var(--border-radius-round);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
color: var(--color-text-white);
|
||||
font-size: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: $font-weight-normal;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Material Design Form Fields (Angular Material Input Style)
|
||||
========================================================================== */
|
||||
|
||||
.register-form {
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.material-input {
|
||||
@include material-input;
|
||||
width: 100%;
|
||||
padding: 16px 12px 8px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: var(--transition-normal);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
||||
outline: none;
|
||||
|
||||
+ .form-label {
|
||||
color: var(--color-success);
|
||||
transform: translateY(-20px) scale(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown) + .form-label {
|
||||
transform: translateY(-20px) scale(0.85);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
+ .form-label {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-valid {
|
||||
border-color: var(--color-success);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
+ .form-label {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-hint);
|
||||
pointer-events: none;
|
||||
transition: var(--transition-normal);
|
||||
transform-origin: left top;
|
||||
background-color: var(--color-surface);
|
||||
padding: 0 4px;
|
||||
margin-left: -4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-outline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
/* Password Toggle Button */
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-hint);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition-fast);
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Password Requirements & Validation
|
||||
========================================================================== */
|
||||
|
||||
.password-requirements {
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Form Checkboxes (Terms & Newsletter)
|
||||
========================================================================== */
|
||||
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.form-check-input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-radius: 3px;
|
||||
background-color: var(--color-surface);
|
||||
margin-top: 2px;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
|
||||
a {
|
||||
color: var(--color-success);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Register Button (Angular Material Raised Button)
|
||||
========================================================================== */
|
||||
|
||||
.register-btn {
|
||||
@include material-button(var(--color-success));
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: $font-weight-medium;
|
||||
border-radius: var(--border-radius-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-success-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Social Registration Section (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.social-login {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
span {
|
||||
background-color: var(--color-surface);
|
||||
padding: 0 16px;
|
||||
color: var(--color-text-hint);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-surface-variant);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.google-btn:hover {
|
||||
border-color: #db4437;
|
||||
color: #db4437;
|
||||
}
|
||||
|
||||
&.github-btn:hover {
|
||||
border-color: #333;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login Link
|
||||
========================================================================== */
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-link-text {
|
||||
color: var(--color-success);
|
||||
text-decoration: none;
|
||||
font-weight: $font-weight-medium;
|
||||
transition: var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-success-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Error Messages (Angular Material Style)
|
||||
========================================================================== */
|
||||
|
||||
.alert {
|
||||
border-radius: var(--border-radius-md);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&.alert-danger {
|
||||
background-color: var(--color-error-bg);
|
||||
color: var(--color-error-dark);
|
||||
border-left: 4px solid var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-error);
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Design
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.register-container {
|
||||
background: var(--gradient-success);
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
margin: 0;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.register-card .card-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
529
resources/sass/pages/_resume-create.scss
Normal file
529
resources/sass/pages/_resume-create.scss
Normal file
@@ -0,0 +1,529 @@
|
||||
// ==========================================================================
|
||||
// Resume Builder Create Page Styles
|
||||
// Professional Resume Builder - Template Selection
|
||||
// ==========================================================================
|
||||
|
||||
.resume-create-page {
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
h1 {
|
||||
color: var(--color-dark);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template Cards Grid
|
||||
.templates-grid {
|
||||
.template-card {
|
||||
background: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.template-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.template-preview {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-gray-100) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
text-align: center;
|
||||
color: var(--color-gray-400);
|
||||
|
||||
i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.9) 0%, rgba(59, 130, 246, 0.9) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 2;
|
||||
|
||||
.preview-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||
color: var(--color-white);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
color: var(--color-white);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: start;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.template-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.template-category {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
|
||||
&.bg-primary {
|
||||
background: rgba(102, 126, 234, 0.1) !important;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
&.bg-success {
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
color: var(--brand-success);
|
||||
}
|
||||
|
||||
&.bg-warning {
|
||||
background: rgba(251, 191, 36, 0.1) !important;
|
||||
color: var(--brand-warning);
|
||||
}
|
||||
|
||||
&.bg-info {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: var(--brand-info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: var(--color-gray-600);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.template-features {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
color: var(--brand-success);
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-actions {
|
||||
.select-btn {
|
||||
width: 100%;
|
||||
background: var(--brand-gradient);
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.spinner-border {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
.btn-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: var(--spacing-sm);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template Filters
|
||||
.template-filters {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
.filter-btn {
|
||||
background: var(--color-white);
|
||||
border: 1px solid var(--color-gray-300);
|
||||
color: var(--color-gray-600);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-gray-50);
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-primary-dark);
|
||||
border-color: var(--brand-primary-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.no-templates {
|
||||
text-align: center;
|
||||
padding: var(--spacing-4xl) var(--spacing-lg);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--color-gray-300);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--color-gray-700);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-gray-500);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading States
|
||||
.templates-loading {
|
||||
.loading-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
height: 500px;
|
||||
|
||||
.loading-preview {
|
||||
height: 300px;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.loading-body {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
.loading-title {
|
||||
height: 1.5rem;
|
||||
width: 70%;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.loading-description {
|
||||
height: 1rem;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-of-type {
|
||||
width: 60%;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-button {
|
||||
height: 3rem;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@include media-breakpoint-down(lg) {
|
||||
.template-filters {
|
||||
.filter-options {
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.resume-create-page {
|
||||
.page-header {
|
||||
text-align: center;
|
||||
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-filters {
|
||||
.filter-section {
|
||||
.filter-options {
|
||||
.filter-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
.template-card {
|
||||
.template-preview {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.template-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.template-category {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Template Selection Animations
|
||||
@keyframes selectTemplate {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.template-card.selecting {
|
||||
animation: selectTemplate 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
// Success State
|
||||
.template-selected {
|
||||
border: 2px solid var(--brand-success) !important;
|
||||
|
||||
.template-overlay {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%);
|
||||
opacity: 1;
|
||||
|
||||
.success-message {
|
||||
color: var(--color-white);
|
||||
text-align: center;
|
||||
|
||||
i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
510
resources/sass/pages/_resume-index.scss
Normal file
510
resources/sass/pages/_resume-index.scss
Normal file
@@ -0,0 +1,510 @@
|
||||
// ==========================================================================
|
||||
// Resume Builder Index Page Styles
|
||||
// Professional Resume Builder - Resume Management
|
||||
// ==========================================================================
|
||||
|
||||
.resume-index-page {
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
h1 {
|
||||
color: var(--color-dark);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics Cards
|
||||
.stats-cards {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
.stats-card {
|
||||
background: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1;
|
||||
|
||||
&.text-primary {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
&.text-success {
|
||||
color: var(--brand-success) !important;
|
||||
}
|
||||
|
||||
&.text-warning {
|
||||
color: var(--brand-warning) !important;
|
||||
}
|
||||
|
||||
&.text-info {
|
||||
color: var(--brand-info) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
|
||||
&.bg-primary {
|
||||
background: rgba(102, 126, 234, 0.1) !important;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
&.bg-success {
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
color: var(--brand-success);
|
||||
}
|
||||
|
||||
&.bg-warning {
|
||||
background: rgba(251, 191, 36, 0.1) !important;
|
||||
color: var(--brand-warning);
|
||||
}
|
||||
|
||||
&.bg-info {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: var(--brand-info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume Cards Grid
|
||||
.resumes-grid {
|
||||
.resume-card {
|
||||
background: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.resume-thumbnail {
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-gray-100) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
|
||||
.thumbnail-placeholder {
|
||||
color: var(--color-gray-400);
|
||||
text-align: center;
|
||||
|
||||
i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.resume-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resume-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.meta-item {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-600);
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resume-status {
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
|
||||
&.bg-success {
|
||||
background: var(--brand-success) !important;
|
||||
}
|
||||
|
||||
&.bg-warning {
|
||||
background: var(--brand-warning) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
&.bg-secondary {
|
||||
background: var(--color-gray-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resume-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--brand-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
border-color: var(--brand-success);
|
||||
color: var(--brand-success);
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-success);
|
||||
border-color: var(--brand-success);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
border-color: var(--brand-danger);
|
||||
color: var(--brand-danger);
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-danger);
|
||||
border-color: var(--brand-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-4xl) var(--spacing-lg);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--color-gray-300);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--color-gray-700);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-gray-500);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.875rem 2rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters and Search
|
||||
.resume-filters {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.search-input {
|
||||
position: relative;
|
||||
|
||||
.form-control {
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid var(--color-gray-300);
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-gray-400);
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.btn {
|
||||
border-radius: var(--border-radius-full);
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-gray-300);
|
||||
color: var(--color-gray-600);
|
||||
background: var(--color-white);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: var(--color-gray-50);
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-xl);
|
||||
|
||||
.pagination {
|
||||
.page-item {
|
||||
.page-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-gray-600);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0 0.125rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
&.active .page-link {
|
||||
background: var(--brand-primary);
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-primary-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@include media-breakpoint-down(lg) {
|
||||
.resume-index-page {
|
||||
.page-header {
|
||||
.header-actions {
|
||||
margin-top: var(--spacing-md);
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
.col-xl-3 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.resume-filters {
|
||||
.search-input {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resumes-grid {
|
||||
.resume-card {
|
||||
.resume-actions {
|
||||
.btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading States
|
||||
.loading-skeleton {
|
||||
.skeleton-card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.skeleton-thumbnail {
|
||||
height: 200px;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1rem;
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&.title {
|
||||
height: 1.5rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&.subtitle {
|
||||
height: 1rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
&.meta {
|
||||
height: 0.875rem;
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
618
resources/sass/utilities/_helpers.scss
Normal file
618
resources/sass/utilities/_helpers.scss
Normal file
@@ -0,0 +1,618 @@
|
||||
// ==========================================================================
|
||||
// Utility Classes
|
||||
// Professional Resume Builder - Helper Classes
|
||||
// ==========================================================================
|
||||
|
||||
// Spacing utilities (extending Bootstrap)
|
||||
.spacing-xs { margin: var(--spacing-xs); }
|
||||
.spacing-sm { margin: var(--spacing-sm); }
|
||||
.spacing-md { margin: var(--spacing-md); }
|
||||
.spacing-lg { margin: var(--spacing-lg); }
|
||||
.spacing-xl { margin: var(--spacing-xl); }
|
||||
.spacing-2xl { margin: var(--spacing-2xl); }
|
||||
.spacing-3xl { margin: var(--spacing-3xl); }
|
||||
.spacing-4xl { margin: var(--spacing-4xl); }
|
||||
|
||||
.p-xs { padding: var(--spacing-xs); }
|
||||
.p-sm { padding: var(--spacing-sm); }
|
||||
.p-md { padding: var(--spacing-md); }
|
||||
.p-lg { padding: var(--spacing-lg); }
|
||||
.p-xl { padding: var(--spacing-xl); }
|
||||
.p-2xl { padding: var(--spacing-2xl); }
|
||||
.p-3xl { padding: var(--spacing-3xl); }
|
||||
.p-4xl { padding: var(--spacing-4xl); }
|
||||
|
||||
// Margin utilities
|
||||
.m-xs { margin: var(--spacing-xs); }
|
||||
.m-sm { margin: var(--spacing-sm); }
|
||||
.m-md { margin: var(--spacing-md); }
|
||||
.m-lg { margin: var(--spacing-lg); }
|
||||
.m-xl { margin: var(--spacing-xl); }
|
||||
.m-2xl { margin: var(--spacing-2xl); }
|
||||
|
||||
.mt-xs { margin-top: var(--spacing-xs); }
|
||||
.mt-sm { margin-top: var(--spacing-sm); }
|
||||
.mt-md { margin-top: var(--spacing-md); }
|
||||
.mt-lg { margin-top: var(--spacing-lg); }
|
||||
.mt-xl { margin-top: var(--spacing-xl); }
|
||||
.mt-2xl { margin-top: var(--spacing-2xl); }
|
||||
|
||||
.mb-xs { margin-bottom: var(--spacing-xs); }
|
||||
.mb-sm { margin-bottom: var(--spacing-sm); }
|
||||
.mb-md { margin-bottom: var(--spacing-md); }
|
||||
.mb-lg { margin-bottom: var(--spacing-lg); }
|
||||
.mb-xl { margin-bottom: var(--spacing-xl); }
|
||||
.mb-2xl { margin-bottom: var(--spacing-2xl); }
|
||||
|
||||
// Text utilities
|
||||
.text-primary-custom {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
.text-success-custom {
|
||||
color: var(--brand-success) !important;
|
||||
}
|
||||
|
||||
.text-warning-custom {
|
||||
color: var(--brand-warning) !important;
|
||||
}
|
||||
|
||||
.text-danger-custom {
|
||||
color: var(--brand-danger) !important;
|
||||
}
|
||||
|
||||
.text-info-custom {
|
||||
color: var(--brand-info) !important;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: var(--brand-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.text-gradient-success {
|
||||
background: var(--brand-gradient-success);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.text-shadow-sm {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.text-shadow-lg {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
// Background utilities
|
||||
.bg-gradient-primary {
|
||||
background: var(--brand-gradient) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.bg-gradient-success {
|
||||
background: var(--brand-gradient-success) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.bg-gradient-warning {
|
||||
background: var(--brand-gradient-warning) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.bg-gradient-danger {
|
||||
background: var(--brand-gradient-danger) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.bg-gradient-info {
|
||||
background: var(--brand-gradient-info) !important;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.bg-primary-light {
|
||||
background-color: var(--brand-primary-light) !important;
|
||||
color: var(--brand-primary-dark);
|
||||
}
|
||||
|
||||
.bg-success-light {
|
||||
background-color: var(--brand-success-light) !important;
|
||||
color: var(--brand-success-dark);
|
||||
}
|
||||
|
||||
.bg-warning-light {
|
||||
background-color: var(--brand-warning-light) !important;
|
||||
color: var(--brand-warning-dark);
|
||||
}
|
||||
|
||||
.bg-danger-light {
|
||||
background-color: var(--brand-danger-light) !important;
|
||||
color: var(--brand-danger-dark);
|
||||
}
|
||||
|
||||
.bg-info-light {
|
||||
background-color: var(--brand-info-light) !important;
|
||||
color: var(--brand-info-dark);
|
||||
}
|
||||
|
||||
// Border utilities
|
||||
.border-primary-custom {
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
.border-success-custom {
|
||||
border-color: var(--brand-success) !important;
|
||||
}
|
||||
|
||||
.border-warning-custom {
|
||||
border-color: var(--brand-warning) !important;
|
||||
}
|
||||
|
||||
.border-danger-custom {
|
||||
border-color: var(--brand-danger) !important;
|
||||
}
|
||||
|
||||
.border-info-custom {
|
||||
border-color: var(--brand-info) !important;
|
||||
}
|
||||
|
||||
.border-radius-xs {
|
||||
border-radius: var(--border-radius-xs) !important;
|
||||
}
|
||||
|
||||
.border-radius-sm {
|
||||
border-radius: var(--border-radius-sm) !important;
|
||||
}
|
||||
|
||||
.border-radius-md {
|
||||
border-radius: var(--border-radius-md) !important;
|
||||
}
|
||||
|
||||
.border-radius-lg {
|
||||
border-radius: var(--border-radius-lg) !important;
|
||||
}
|
||||
|
||||
.border-radius-xl {
|
||||
border-radius: var(--border-radius-xl) !important;
|
||||
}
|
||||
|
||||
.border-radius-full {
|
||||
border-radius: var(--border-radius-full) !important;
|
||||
}
|
||||
|
||||
// Shadow utilities
|
||||
.shadow-xs {
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.shadow-xl {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
.shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.shadow-inner {
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.shadow-none {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// Colored shadows
|
||||
.shadow-primary {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15) !important;
|
||||
}
|
||||
|
||||
.shadow-success {
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15) !important;
|
||||
}
|
||||
|
||||
.shadow-warning {
|
||||
box-shadow: 0 4px 12px rgba(251, 191, 36, 0.15) !important;
|
||||
}
|
||||
|
||||
.shadow-danger {
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15) !important;
|
||||
}
|
||||
|
||||
.shadow-info {
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15) !important;
|
||||
}
|
||||
|
||||
// Display utilities
|
||||
.d-grid {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.grid-gap-1 {
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
|
||||
.grid-gap-2 {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.grid-gap-3 {
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.grid-gap-4 {
|
||||
gap: 1.5rem !important;
|
||||
}
|
||||
|
||||
.grid-gap-5 {
|
||||
gap: 2rem !important;
|
||||
}
|
||||
|
||||
// Flexbox utilities
|
||||
.flex-center {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.flex-around {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-around !important;
|
||||
}
|
||||
|
||||
.flex-column-center {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
// Position utilities
|
||||
.position-center {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.position-center-x {
|
||||
position: absolute !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
.position-center-y {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
|
||||
// Visibility utilities
|
||||
.invisible {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.opacity-0 {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.opacity-25 {
|
||||
opacity: 0.25 !important;
|
||||
}
|
||||
|
||||
.opacity-50 {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.opacity-75 {
|
||||
opacity: 0.75 !important;
|
||||
}
|
||||
|
||||
.opacity-100 {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
// Cursor utilities
|
||||
.cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.cursor-wait {
|
||||
cursor: wait !important;
|
||||
}
|
||||
|
||||
.cursor-help {
|
||||
cursor: help !important;
|
||||
}
|
||||
|
||||
// Transform utilities
|
||||
.transform-none {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.scale-90 {
|
||||
transform: scale(0.9) !important;
|
||||
}
|
||||
|
||||
.scale-95 {
|
||||
transform: scale(0.95) !important;
|
||||
}
|
||||
|
||||
.scale-100 {
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
.scale-105 {
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
.scale-110 {
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg) !important;
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg) !important;
|
||||
}
|
||||
|
||||
.rotate-270 {
|
||||
transform: rotate(270deg) !important;
|
||||
}
|
||||
|
||||
// Transition utilities
|
||||
.transition-none {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all 0.15s ease-in-out !important;
|
||||
}
|
||||
|
||||
.transition-slow {
|
||||
transition: all 0.3s ease-in-out !important;
|
||||
}
|
||||
|
||||
.transition-fast {
|
||||
transition: all 0.1s ease-in-out !important;
|
||||
}
|
||||
|
||||
// Animation utilities
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: none;
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-ping {
|
||||
animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
// Hover utilities
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease-in-out !important;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.2s ease-in-out !important;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-rotate {
|
||||
transition: transform 0.2s ease-in-out !important;
|
||||
|
||||
&:hover {
|
||||
transform: rotate(5deg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading states
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive utilities for custom breakpoints
|
||||
@include media-breakpoint-up(xs) {
|
||||
.d-xs-block { display: block !important; }
|
||||
.d-xs-none { display: none !important; }
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.d-sm-block { display: block !important; }
|
||||
.d-sm-none { display: none !important; }
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
.d-md-block { display: block !important; }
|
||||
.d-md-none { display: none !important; }
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.d-lg-block { display: block !important; }
|
||||
.d-lg-none { display: none !important; }
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xl) {
|
||||
.d-xl-block { display: block !important; }
|
||||
.d-xl-none { display: none !important; }
|
||||
}
|
||||
|
||||
// Print utilities
|
||||
@media print {
|
||||
.print-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-visible {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.print-break-before {
|
||||
page-break-before: always !important;
|
||||
}
|
||||
|
||||
.print-break-after {
|
||||
page-break-after: always !important;
|
||||
}
|
||||
|
||||
.print-no-break {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Accessibility utilities
|
||||
.sr-only-focusable {
|
||||
&:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.focus-visible {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode support
|
||||
@media (prefers-contrast: high) {
|
||||
.text-muted {
|
||||
color: var(--color-gray-800) !important;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-color: var(--color-gray-800) !important;
|
||||
}
|
||||
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid var(--color-gray-800) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion support
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
194
resources/views/auth/login.blade.php
Normal file
194
resources/views/auth/login.blade.php
Normal file
@@ -0,0 +1,194 @@
|
||||
{{--
|
||||
Professional Login Page - Professional Bootstrap Design
|
||||
Clean and modern login interface
|
||||
|
||||
@author David Valera Melendez <david@valera-melendez.de>
|
||||
@created 2025-08-08
|
||||
@location Made in Germany 🇩🇪
|
||||
--}}
|
||||
|
||||
@extends('layouts.material-auth')
|
||||
|
||||
@section('title', 'Sign In - Professional Resume Builder')
|
||||
@section('page_class', 'auth-page login-page')
|
||||
|
||||
@section('content')
|
||||
<!-- Professional Login Page - Bootstrap Style -->
|
||||
<div class="login-container">
|
||||
<!-- Hero Background Section -->
|
||||
<div class="hero-background">
|
||||
<div class="hero-overlay">
|
||||
<div class="hero-content">
|
||||
<div class="hero-icon">
|
||||
<i class="bi bi-person-badge large-icon"></i>
|
||||
</div>
|
||||
<h1 class="hero-title">Professional Resume Builder</h1>
|
||||
<p class="hero-subtitle">
|
||||
Create impressive, professional resumes with modern design and expert guidance
|
||||
</p>
|
||||
<p class="author-signature">
|
||||
<strong>Created by David Valera Melendez</strong> | Made in Germany 🇩🇪
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form Container -->
|
||||
<div class="login-form-container">
|
||||
<div class="login-card">
|
||||
<div class="card-body">
|
||||
<!-- Form Header -->
|
||||
<div class="form-header">
|
||||
<div class="login-avatar">
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
</div>
|
||||
<h2 class="form-title">Sign In</h2>
|
||||
<p class="form-subtitle">Welcome back to Resume Builder</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<div>
|
||||
@foreach($errors->all() as $error)
|
||||
<div>{{ $error }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="{{ route('login') }}" class="login-form">
|
||||
@csrf
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="form-group">
|
||||
<div class="form-field">
|
||||
<input
|
||||
type="email"
|
||||
class="form-control material-input @error('email') is-invalid @enderror"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ old('email') }}"
|
||||
required
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
placeholder=" "
|
||||
>
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<div class="form-outline"></div>
|
||||
@error('email')
|
||||
<div class="invalid-feedback">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="form-group">
|
||||
<div class="form-field">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control material-input @error('password') is-invalid @enderror"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder=" "
|
||||
>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="form-outline"></div>
|
||||
<button type="button" class="password-toggle" onclick="togglePassword('password')">
|
||||
<i class="bi bi-eye" id="password-icon"></i>
|
||||
</button>
|
||||
@error('password')
|
||||
<div class="invalid-feedback">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="form-options">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="remember"
|
||||
name="remember"
|
||||
{{ old('remember') ? 'checked' : '' }}
|
||||
>
|
||||
<label class="form-check-label" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<a href="{{ route('password.request') }}" class="forgot-password-link">
|
||||
Forgot password?
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Login Button -->
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary login-btn">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Section -->
|
||||
<div class="social-login">
|
||||
<div class="divider">
|
||||
<span>or continue with</span>
|
||||
</div>
|
||||
|
||||
<div class="social-buttons">
|
||||
<button type="button" class="btn social-btn google-btn">
|
||||
<i class="bi bi-google me-2"></i>
|
||||
Google
|
||||
</button>
|
||||
<button type="button" class="btn social-btn github-btn">
|
||||
<i class="bi bi-github me-2"></i>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Link -->
|
||||
<div class="register-link">
|
||||
<p>Don't have an account?
|
||||
<a href="{{ route('register') }}" class="register-link-text">
|
||||
Sign up here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function togglePassword(fieldId) {
|
||||
const passwordField = document.getElementById(fieldId);
|
||||
const passwordIcon = document.getElementById(fieldId + '-icon');
|
||||
|
||||
if (passwordField.type === 'password') {
|
||||
passwordField.type = 'text';
|
||||
passwordIcon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
passwordField.type = 'password';
|
||||
passwordIcon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user