added the cropping library

This commit is contained in:
Ghassan Yusuf 2026-01-25 21:25:23 +03:00
parent c1fc28087e
commit 7a18eb6588
13 changed files with 887 additions and 192 deletions

120
INSTALLATION_SUMMARY.md Normal file
View File

@ -0,0 +1,120 @@
# Laravel Image Cropper Installation Summary
## Package Installed
- **Package**: `takeone/cropper` from https://git.innovator.bh/ghassan/laravel-image-cropper
- **Version**: dev-main
- **Installation Date**: January 26, 2026
## Changes Made
### 1. composer.json
- Added VCS repository for the package
- Installed `takeone/cropper:@dev`
### 2. resources/views/layouts/app.blade.php
- Added `@stack('modals')` before closing `</body>` tag
- This allows the cropper modal to be injected into the page
### 3. resources/views/family/profile-edit.blade.php
- Replaced the old `<x-image-upload-modal>` component with `<x-takeone-cropper>`
- Configured cropper with:
- **ID**: `profile_picture`
- **Width**: 300px
- **Height**: 400px (portrait rectangle - 3:4 ratio)
- **Shape**: `square` (rectangle viewport)
- **Folder**: `images/profiles`
- **Filename**: `profile_{user_id}`
- **Upload URL**: Custom route `profile.upload-picture`
- Added image display logic to show current profile picture or placeholder
### 4. app/Http/Controllers/FamilyController.php
- Updated `uploadProfilePicture()` method to handle base64 image data from cropper
- Method now:
- Accepts base64 image data instead of file upload
- Decodes and saves the cropped image
- Updates user's `profile_picture` field in database
- Returns JSON response with success status and image path
### 5. resources/views/vendor/takeone/components/widget.blade.php
- Published and customized the package's widget component
- Added support for custom `uploadUrl` parameter
- Added page reload after successful upload to display new image
- Improved error handling with detailed error messages
### 6. Storage
- Ran `php artisan storage:link` to link public storage
## How It Works
1. User clicks "Change Photo" button on profile edit page
2. Modal popup appears with file selector
3. User selects an image file
4. Cropme.js library loads the image in a cropping interface
5. User can:
- Zoom in/out using slider
- Rotate image using slider
- Pan/move image within the viewport
6. Cropping viewport is set to 300x400px (portrait rectangle)
7. User clicks "Crop & Save Image"
8. Image is cropped to base64 format
9. AJAX POST request sent to `/profile/upload-picture`
10. FamilyController processes the base64 image:
- Decodes base64 data
- Saves to `storage/app/public/images/profiles/profile_{user_id}.{ext}`
- Deletes old profile picture if exists
- Updates user's `profile_picture` field
11. Page reloads to display the new profile picture
## File Locations
- **Uploaded Images**: `storage/app/public/images/profiles/`
- **Public Access**: `public/storage/images/profiles/` (via symlink)
- **Database Field**: `users.profile_picture`
## Portrait Rectangle Configuration
The cropper is configured for portrait orientation:
- **Aspect Ratio**: 3:4 (300px width × 400px height)
- **Shape**: `square` (rectangle viewport, not circular)
- **Viewport**: Displays as a rectangle overlay on the image
## Testing Checklist
- [x] Package installed successfully
- [x] Storage linked
- [x] Modal stack added to layout
- [x] Cropper component integrated
- [x] Custom upload route configured
- [x] Controller method updated
- [ ] Test image upload
- [ ] Verify cropped image saves correctly
- [ ] Confirm image displays after upload
- [ ] Test portrait rectangle cropping
- [ ] Verify old images are deleted
## Next Steps
1. Navigate to http://localhost:8000/profile/edit
2. Click "Change Photo" button
3. Select an image
4. Crop in portrait rectangle shape
5. Save and verify the image appears correctly
## Troubleshooting
If the cropper doesn't appear:
- Check browser console for JavaScript errors
- Verify `@stack('modals')` is in layout
- Ensure jQuery is loaded before the cropper script
If upload fails:
- Check `storage/app/public/images/profiles/` directory exists
- Verify storage is linked: `php artisan storage:link`
- Check file permissions on storage directory
- Review Laravel logs in `storage/logs/`
## Package Documentation
For more details, see:
- Package README: `vendor/takeone/cropper/README.md`
- Package Example: `vendor/takeone/cropper/EXAMPLE.md`

251
TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,251 @@
# Laravel Image Cropper - Testing Guide
## Installation Complete ✅
The Laravel Image Cropper package has been successfully installed and integrated into your project.
## What Was Installed
1. **Package**: `takeone/cropper:@dev`
2. **Cropper Library**: Cropme.js (lightweight image cropping)
3. **Portrait Rectangle Configuration**: 300px × 400px (3:4 ratio)
4. **Storage Directory**: `storage/app/public/images/profiles/`
## Files Modified
### 1. composer.json
- Added VCS repository
- Added package dependency
### 2. resources/views/layouts/app.blade.php
- Added `@stack('modals')` for modal injection
### 3. resources/views/family/profile-edit.blade.php
- Replaced old image upload modal with `<x-takeone-cropper>` component
- Configured for portrait rectangle cropping
- Added image display with fallback to default avatar
### 4. app/Http/Controllers/FamilyController.php
- Updated `uploadProfilePicture()` method to handle base64 images
- Saves cropped images to `storage/app/public/images/profiles/`
- Updates user's `profile_picture` field in database
### 5. resources/views/vendor/takeone/components/widget.blade.php
- Published and customized widget component
- Added custom upload URL support
- Added page reload after successful upload
- Improved error handling
## How to Test
### Step 1: Start the Development Server
```bash
php artisan serve
```
### Step 2: Navigate to Profile Edit Page
Open your browser and go to:
```
http://localhost:8000/profile/edit
```
### Step 3: Test the Cropper
1. **Click "Change Photo" button**
- A modal should popup with a file selector
2. **Select an Image**
- Click "Choose File" and select any image from your computer
- The image should load in the cropping interface
3. **Crop the Image**
- You'll see a **portrait rectangle** overlay (300×400px)
- Use the **Zoom Level** slider to zoom in/out
- Use the **Rotation** slider to rotate the image
- Drag the image to position it within the rectangle
4. **Save the Image**
- Click "Crop & Save Image" button
- Button should show "Uploading..." while processing
- You should see "Saved successfully!" alert
- Modal should close automatically
- Page should reload
- Your new profile picture should appear in the profile picture box
### Step 4: Verify the Upload
Check that the image was saved:
```bash
dir storage\app\public\images\profiles\
```
You should see a file named `profile_{user_id}.png`
### Step 5: Verify Database Update
The `users` table should have the `profile_picture` field updated with:
```
images/profiles/profile_{user_id}.png
```
## Expected Behavior
### ✅ Success Indicators
- Modal opens when clicking "Change Photo"
- Image loads in cropper after selection
- Portrait rectangle viewport is visible (taller than wide)
- Zoom and rotation sliders work smoothly
- Image can be dragged/positioned
- "Crop & Save Image" button uploads successfully
- Success alert appears
- Page reloads automatically
- New profile picture displays in the profile box
### ❌ Potential Issues
**Modal doesn't appear:**
- Check browser console (F12) for JavaScript errors
- Verify `@stack('modals')` is in `resources/views/layouts/app.blade.php`
- Ensure jQuery and Bootstrap are loaded
**Upload fails:**
- Check `storage/app/public/images/profiles/` directory exists
- Verify storage link: `php artisan storage:link`
- Check file permissions on storage directory
- Review Laravel logs: `storage/logs/laravel.log`
**Image doesn't display after upload:**
- Verify storage is linked
- Check the `profile_picture` field in database
- Ensure the file exists in `public/storage/images/profiles/`
- Clear browser cache
**Cropper shows square instead of rectangle:**
- Verify the component has `width="300"` and `height="400"`
- Check that `shape="square"` (not "circle")
## Package Bug Note
⚠️ **Known Issue**: The package's service provider has a namespace bug in the route registration. This doesn't affect functionality because we're using our own custom route (`profile.upload-picture`) instead of the package's default route.
The error you might see when running `php artisan route:list`:
```
Class "takeone\cropper\Http\Controllers\ImageController" does not exist
```
This can be safely ignored as we're not using that route.
## Customization Options
### Change Aspect Ratio
Edit `resources/views/family/profile-edit.blade.php`:
```html
<!-- Current: 3:4 portrait -->
<x-takeone-cropper
width="300"
height="400"
/>
<!-- Square: 1:1 -->
<x-takeone-cropper
width="300"
height="300"
/>
<!-- Wide rectangle: 16:9 -->
<x-takeone-cropper
width="400"
height="225"
/>
<!-- Tall portrait: 2:3 -->
<x-takeone-cropper
width="300"
height="450"
/>
```
### Change to Circular Crop
```html
<x-takeone-cropper
width="300"
height="300"
shape="circle"
/>
```
### Change Storage Folder
```html
<x-takeone-cropper
folder="avatars"
/>
```
### Custom Filename Pattern
```html
<x-takeone-cropper
filename="user_{{ auth()->id() }}_{{ time() }}"
/>
```
## File Structure
```
takeone/
├── storage/
│ └── app/
│ └── public/
│ └── images/
│ └── profiles/ ← Uploaded images here
│ └── profile_1.png
├── public/
│ └── storage/ ← Symlink to storage/app/public
│ └── images/
│ └── profiles/
│ └── profile_1.png ← Publicly accessible
├── resources/
│ └── views/
│ ├── family/
│ │ └── profile-edit.blade.php ← Profile edit page
│ ├── layouts/
│ │ └── app.blade.php ← Main layout with @stack('modals')
│ └── vendor/
│ └── takeone/
│ └── components/
│ └── widget.blade.php ← Customized cropper widget
└── app/
└── Http/
└── Controllers/
└── FamilyController.php ← Upload handler
```
## Support
If you encounter any issues:
1. Check the browser console (F12) for JavaScript errors
2. Review Laravel logs: `storage/logs/laravel.log`
3. Verify all files were modified correctly
4. Ensure storage permissions are correct
5. Clear all caches: `php artisan config:clear && php artisan route:clear && php artisan view:clear`
## Next Steps
1. Test the cropper functionality
2. Upload a test image
3. Verify it displays correctly
4. Customize the aspect ratio if needed
5. Add additional validation if required
6. Consider adding image optimization/compression
---
**Installation Date**: January 26, 2026
**Package Version**: dev-main
**Laravel Version**: 12.0
**Status**: ✅ Ready for Testing

View File

@ -158,39 +158,44 @@ class FamilyController extends Controller
public function uploadProfilePicture(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120', // 5MB max
'image' => 'required',
'folder' => 'required|string',
'filename' => 'required|string',
]);
$user = Auth::user();
try {
$user = Auth::user();
if ($request->hasFile('image')) {
$image = $request->file('image');
// Handle base64 image from cropper
$imageData = $request->image;
$imageParts = explode(";base64,", $imageData);
$imageTypeAux = explode("image/", $imageParts[0]);
$extension = $imageTypeAux[1];
$imageBinary = base64_decode($imageParts[1]);
// Generate unique filename
$filename = 'profile_' . $user->id . '_' . time() . '.' . $image->getClientOriginalExtension();
// Store in public/images/profiles
$path = $image->storeAs('images/profiles', $filename, 'public');
$folder = trim($request->folder, '/');
$fileName = $request->filename . '.' . $extension;
$fullPath = $folder . '/' . $fileName;
// Delete old profile picture if exists
if ($user->profile_picture && \Storage::disk('public')->exists($user->profile_picture)) {
\Storage::disk('public')->delete($user->profile_picture);
}
// Update user
$user->update(['profile_picture' => $path]);
// Store in the public disk (storage/app/public)
\Storage::disk('public')->put($fullPath, $imageBinary);
// Update user's profile_picture field
$user->update(['profile_picture' => $fullPath]);
return response()->json([
'success' => true,
'message' => 'Profile picture uploaded successfully.',
'path' => $path,
'path' => $fullPath,
'url' => asset('storage/' . $fullPath)
]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return response()->json([
'success' => false,
'message' => 'No image file provided.',
], 400);
}
/**

View File

@ -5,11 +5,18 @@
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper"
}
],
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"takeone/cropper": "@dev"
},
"require-dev": {
"fakerphp/faker": "^1.23",

46
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42",
"content-hash": "d81f57cbd65389eb9d9702b5825564c1",
"packages": [
{
"name": "brick/math",
@ -5854,6 +5854,44 @@
],
"time": "2025-12-18T07:04:31+00:00"
},
{
"name": "takeone/cropper",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper",
"reference": "155876bd2165271116f7d8ea3cf3afddd9511ec9"
},
"require": {
"laravel/framework": "^11.0|^12.0",
"php": "^8.2"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"Takeone\\Cropper\\CropperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Takeone\\Cropper\\": "src/"
}
},
"license": [
"MIT"
],
"authors": [
{
"name": "Ghassan",
"email": "ghassan.yousif.83@gmail.com"
}
],
"description": "A professional image cropping component for Laravel using Bootstrap and Cropme.",
"time": "2026-01-25T11:58:48+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@ -8434,12 +8472,14 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"stability-flags": {
"takeone/cropper": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

2
public/storage/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -285,15 +285,6 @@
}
</style>
<!-- Flag Icons CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@6.6.6/css/flag-icons.min.css">
<!-- Select2 CSS (for nationality dropdown) -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<!-- Flatpickr CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<div class="login-page">
<div class="login-box">
<div class="card">
@ -311,11 +302,11 @@
<!-- Full Name -->
<div class="mb-3">
<label for="full_name" class="form-label">Full Name</label>
<input id="full_name" type="text"
class="form-control @error('full_name') is-invalid @enderror"
name="full_name"
value="{{ old('full_name') }}"
placeholder="Full Name"
required autocomplete="name">
@error('full_name')
<span class="invalid-feedback" role="alert">
@ -326,11 +317,11 @@
<!-- Email Address -->
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input id="email" type="email"
class="form-control @error('email') is-invalid @enderror"
name="email"
value="{{ old('email') }}"
placeholder="Email Address"
required autocomplete="email">
@error('email')
<span class="invalid-feedback" role="alert">
@ -341,10 +332,10 @@
<!-- Password -->
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" type="password"
class="form-control @error('password') is-invalid @enderror"
name="password"
placeholder="Password"
required autocomplete="new-password">
@error('password')
<span class="invalid-feedback" role="alert">
@ -355,15 +346,16 @@
<!-- Confirm Password -->
<div class="mb-3">
<label for="password-confirm" class="form-label">Confirm Password</label>
<input id="password-confirm" type="password"
class="form-control"
name="password_confirmation"
placeholder="Confirm Password"
required autocomplete="new-password">
</div>
<!-- Mobile Number with Country Code -->
<div class="mb-3">
<label for="mobile_number" class="form-label">Mobile Number</label>
<x-country-code-dropdown
name="country_code"
id="country_code"
@ -374,7 +366,6 @@
class="form-control @error('mobile_number') is-invalid @enderror"
name="mobile_number"
value="{{ old('mobile_number') }}"
placeholder="Mobile Number"
required autocomplete="tel">
</x-country-code-dropdown>
@error('mobile_number')
@ -386,11 +377,12 @@
<!-- Gender -->
<div class="mb-3">
<label for="gender" class="form-label">Gender</label>
<select id="gender" class="form-select @error('gender') is-invalid @enderror"
name="gender" required>
<option value="">Select Gender</option>
<option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>Male</option>
<option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>Female</option>
<option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>♂️ Male</option>
<option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>♀️ Female</option>
</select>
@error('gender')
<span class="invalid-feedback" role="alert">
@ -401,15 +393,54 @@
<!-- Birthdate -->
<div class="mb-3">
<input id="birthdate" type="text"
class="flatpickr-input @error('birthdate') is-invalid @enderror"
name="birthdate"
value="{{ old('birthdate') }}"
placeholder="Birthdate"
required
readonly>
<label for="birthdate" class="form-label">Birthdate</label>
<div class="row g-2">
<div class="col-4">
<select id="birth_day" class="form-select @error('birthdate') is-invalid @enderror" required>
<option value="">Day</option>
@for($day = 1; $day <= 31; $day++)
<option value="{{ str_pad($day, 2, '0', STR_PAD_LEFT) }}" {{ old('birth_day') == str_pad($day, 2, '0', STR_PAD_LEFT) ? 'selected' : '' }}>
{{ $day }}
</option>
@endfor
</select>
</div>
<div class="col-4">
<select id="birth_month" class="form-select @error('birthdate') is-invalid @enderror" required>
<option value="">Month</option>
<option value="01" {{ old('birth_month') == '01' ? 'selected' : '' }}>January</option>
<option value="02" {{ old('birth_month') == '02' ? 'selected' : '' }}>February</option>
<option value="03" {{ old('birth_month') == '03' ? 'selected' : '' }}>March</option>
<option value="04" {{ old('birth_month') == '04' ? 'selected' : '' }}>April</option>
<option value="05" {{ old('birth_month') == '05' ? 'selected' : '' }}>May</option>
<option value="06" {{ old('birth_month') == '06' ? 'selected' : '' }}>June</option>
<option value="07" {{ old('birth_month') == '07' ? 'selected' : '' }}>July</option>
<option value="08" {{ old('birth_month') == '08' ? 'selected' : '' }}>August</option>
<option value="09" {{ old('birth_month') == '09' ? 'selected' : '' }}>September</option>
<option value="10" {{ old('birth_month') == '10' ? 'selected' : '' }}>October</option>
<option value="11" {{ old('birth_month') == '11' ? 'selected' : '' }}>November</option>
<option value="12" {{ old('birth_month') == '12' ? 'selected' : '' }}>December</option>
</select>
</div>
<div class="col-4">
<select id="birth_year" class="form-select @error('birthdate') is-invalid @enderror" required>
<option value="">Year</option>
@php
$currentYear = date('Y');
$startYear = $currentYear - 10; // Start from 10 years ago
$endYear = 1900;
@endphp
@for($year = $startYear; $year >= $endYear; $year--)
<option value="{{ $year }}" {{ old('birth_year') == $year ? 'selected' : '' }}>
{{ $year }}
</option>
@endfor
</select>
</div>
</div>
<input type="hidden" id="birthdate" name="birthdate" value="{{ old('birthdate') }}">
@error('birthdate')
<span class="invalid-feedback" role="alert">
<span class="invalid-feedback d-block" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
@ -417,7 +448,7 @@
<!-- Nationality -->
<div class="mb-3">
<x-country-dropdown
<x-nationality-dropdown
name="nationality"
id="nationality"
:value="old('nationality')"
@ -434,38 +465,40 @@
<!-- /.login-box -->
</div>
<!-- jQuery (required for Select2) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Select2 JS -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Flatpickr for birthdate
flatpickr('#birthdate', {
dateFormat: 'Y-m-d',
maxDate: 'today',
yearRange: [1900, new Date().getFullYear()],
disableMobile: true,
showMonths: 1,
clickOpens: true,
onReady: function(selectedDates, dateStr, instance) {
const calendar = instance.calendarContainer;
calendar.style.fontSize = '14px';
}
});
// Combine birth date dropdowns into hidden field
const birthDay = document.getElementById('birth_day');
const birthMonth = document.getElementById('birth_month');
const birthYear = document.getElementById('birth_year');
const birthdateHidden = document.getElementById('birthdate');
// Form submission handler
$('#registrationForm').on('submit', function(e) {
console.log('Form submitting...');
console.log('Country code:', $('#country_code').val());
console.log('Nationality:', $('#nationality').val());
console.log('Mobile number:', $('#mobile_number').val());
});
function updateBirthdate() {
const day = birthDay.value;
const month = birthMonth.value;
const year = birthYear.value;
if (day && month && year) {
birthdateHidden.value = `${year}-${month}-${day}`;
} else {
birthdateHidden.value = '';
}
}
// Update hidden field when any dropdown changes
birthDay.addEventListener('change', updateBirthdate);
birthMonth.addEventListener('change', updateBirthdate);
birthYear.addEventListener('change', updateBirthdate);
// Initialize from old value if exists
if (birthdateHidden.value) {
const parts = birthdateHidden.value.split('-');
if (parts.length === 3) {
birthYear.value = parts[0];
birthMonth.value = parts[1];
birthDay.value = parts[2];
}
}
// Error handler
window.onerror = function(message, source, lineno, colno, error) {
@ -475,8 +508,16 @@
console.error('Error:', error);
};
});
// Form submission handler
document.getElementById('registrationForm').addEventListener('submit', function(e) {
console.log('Form submitting...');
console.log('Country code:', document.getElementById('country_code').value);
console.log('Nationality:', document.getElementById('nationality').value);
console.log('Mobile number:', document.getElementById('mobile_number').value);
console.log('Birthdate:', document.getElementById('birthdate').value);
});
</script>
@stack('styles')
@stack('scripts')
@endsection

View File

@ -162,8 +162,8 @@
}
}
// Initialize all country code dropdowns on the page
document.querySelectorAll('[id$="List"]').forEach(function(listElement) {
// Initialize only country code dropdowns (not nationality dropdowns)
document.querySelectorAll('[id$="country_codeList"]').forEach(function(listElement) {
const componentId = listElement.id.replace('List', '');
initializeCountryDropdown(componentId, countries);
});

View File

@ -1,114 +1,166 @@
@props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null])
@props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null, 'label' => 'Nationality'])
<select id="{{ $id }}"
class="form-select nationality-select @error($name) is-invalid @enderror"
name="{{ $name }}"
{{ $required ? 'required' : '' }}>
<option value="">Select Nationality</option>
</select>
<label for="{{ $id }}" class="form-label">{{ $label }}</label>
<div class="dropdown w-100" onclick="event.stopPropagation()">
<button class="form-select dropdown-toggle d-flex align-items-center justify-content-between @error($name) is-invalid @enderror"
type="button"
id="{{ $id }}Dropdown"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
style="text-align: left; background-color: rgba(255,255,255,0.8);">
<span class="d-flex align-items-center">
<span id="{{ $id }}SelectedFlag"></span>
<span class="country-label" id="{{ $id }}SelectedCountry">Select Nationality</span>
</span>
</button>
<div class="dropdown-menu p-2 w-100" aria-labelledby="{{ $id }}Dropdown" onclick="event.stopPropagation()">
<input type="text"
class="form-control form-control-sm mb-2"
placeholder="Search country..."
id="{{ $id }}Search"
onmousedown="event.stopPropagation()"
onfocus="event.stopPropagation()"
oninput="event.stopPropagation()"
onkeydown="event.stopPropagation()"
onkeyup="event.stopPropagation()">
<div class="country-list" id="{{ $id }}List" style="max-height: 300px; overflow-y: auto;">
<!-- Countries will be populated by JavaScript -->
</div>
</div>
<input type="hidden" id="{{ $id }}" name="{{ $name }}" value="{{ $value }}" {{ $required ? 'required' : '' }}>
</div>
@if($error)
<span class="invalid-feedback" role="alert">
<span class="invalid-feedback d-block" role="alert">
<strong>{{ $error }}</strong>
</span>
@endif
@once
@push('styles')
<style>
.country-dropdown-btn {
min-width: 150px;
display: flex;
align-items: center;
justify-content: space-between;
}
.country-list {
max-height: 300px;
overflow-y: auto;
}
.dropdown-item {
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
</style>
@endpush
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Country data for nationality
const countries = [
{ name: 'United States', flagCode: 'us' },
{ name: 'Canada', flagCode: 'ca' },
{ name: 'United Kingdom', flagCode: 'gb' },
{ name: 'United Arab Emirates', flagCode: 'ae' },
{ name: 'Saudi Arabia', flagCode: 'sa' },
{ name: 'Qatar', flagCode: 'qa' },
{ name: 'Kuwait', flagCode: 'kw' },
{ name: 'Bahrain', flagCode: 'bh' },
{ name: 'Oman', flagCode: 'om' },
{ name: 'Egypt', flagCode: 'eg' },
{ name: 'India', flagCode: 'in' },
{ name: 'Pakistan', flagCode: 'pk' },
{ name: 'Bangladesh', flagCode: 'bd' },
{ name: 'Malaysia', flagCode: 'my' },
{ name: 'Singapore', flagCode: 'sg' },
{ name: 'Japan', flagCode: 'jp' },
{ name: 'China', flagCode: 'cn' },
{ name: 'South Korea', flagCode: 'kr' },
{ name: 'Australia', flagCode: 'au' },
{ name: 'Germany', flagCode: 'de' },
{ name: 'France', flagCode: 'fr' },
{ name: 'Italy', flagCode: 'it' },
{ name: 'Spain', flagCode: 'es' },
{ name: 'Netherlands', flagCode: 'nl' },
{ name: 'Sweden', flagCode: 'se' },
{ name: 'Norway', flagCode: 'no' },
{ name: 'Denmark', flagCode: 'dk' },
{ name: 'Finland', flagCode: 'fi' },
{ name: 'Switzerland', flagCode: 'ch' },
{ name: 'Austria', flagCode: 'at' },
{ name: 'Poland', flagCode: 'pl' },
{ name: 'Czech Republic', flagCode: 'cz' },
{ name: 'Hungary', flagCode: 'hu' },
{ name: 'Romania', flagCode: 'ro' },
{ name: 'Greece', flagCode: 'gr' },
{ name: 'Turkey', flagCode: 'tr' },
{ name: 'Russia', flagCode: 'ru' },
{ name: 'Brazil', flagCode: 'br' },
{ name: 'Mexico', flagCode: 'mx' },
{ name: 'Argentina', flagCode: 'ar' },
{ name: 'Chile', flagCode: 'cl' },
{ name: 'Colombia', flagCode: 'co' },
{ name: 'South Africa', flagCode: 'za' },
{ name: 'Nigeria', flagCode: 'ng' },
{ name: 'Kenya', flagCode: 'ke' },
{ name: 'Sri Lanka', flagCode: 'lk' },
{ name: 'Vietnam', flagCode: 'vn' },
{ name: 'Thailand', flagCode: 'th' },
{ name: 'Indonesia', flagCode: 'id' },
{ name: 'Philippines', flagCode: 'ph' },
{ name: 'New Zealand', flagCode: 'nz' },
{ name: 'Portugal', flagCode: 'pt' },
{ name: 'Ireland', flagCode: 'ie' },
{ name: 'Israel', flagCode: 'il' },
{ name: 'Jordan', flagCode: 'jo' },
{ name: 'Lebanon', flagCode: 'lb' },
{ name: 'Iraq', flagCode: 'iq' },
];
// Initialize all nationality dropdowns on the page
document.querySelectorAll('.nationality-select').forEach(function(selectElement) {
if (typeof $ !== 'undefined' && $.fn.select2) {
const $select = $(selectElement);
$select.select2({
data: countries.map(country => ({
id: country.name,
text: country.name,
flagCode: country.flagCode
})),
templateResult: function(data) {
if (!data.id) return data.text;
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
},
templateSelection: function(data) {
if (!data.id) return data.text;
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
},
placeholder: 'Select Nationality',
allowClear: true,
width: '100%'
// Load countries from JSON file
fetch('/data/countries.json')
.then(response => response.json())
.then(countries => {
// Initialize only nationality dropdowns (not country code dropdowns)
document.querySelectorAll('[id$="nationalityList"]').forEach(function(listElement) {
const componentId = listElement.id.replace('List', '');
initializeNationalityDropdown(componentId, countries);
});
})
.catch(error => console.error('Error loading countries:', error));
// Restore value if provided
const initialValue = selectElement.getAttribute('data-value') || '{{ $value }}';
if (initialValue) {
$select.val(initialValue).trigger('change');
function initializeNationalityDropdown(componentId, countries) {
const countryList = document.getElementById(componentId + 'List');
if (!countryList) return;
// Clear existing items
countryList.innerHTML = '';
// Populate country dropdown
countries.forEach(country => {
const button = document.createElement('button');
button.className = 'dropdown-item d-flex align-items-center';
button.type = 'button';
button.setAttribute('data-country-name', country.name);
button.setAttribute('data-flag', country.flag);
button.setAttribute('data-search', country.name.toLowerCase());
// Convert flag code to emoji
const flagEmoji = country.iso2
.toUpperCase()
.split('')
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
button.innerHTML = `
<span class="me-2">${flagEmoji}</span>
<span>${country.name}</span>
`;
button.addEventListener('click', function() {
selectNationality(componentId, country.name, flagEmoji);
});
countryList.appendChild(button);
});
// Search functionality
const searchInput = document.getElementById(componentId + 'Search');
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const items = countryList.querySelectorAll('.dropdown-item');
items.forEach(item => {
const searchText = item.getAttribute('data-search') || '';
if (searchText.includes(searchTerm)) {
item.classList.remove('d-none');
} else {
item.classList.add('d-none');
}
});
});
}
// Set initial value if provided
const hiddenInput = document.getElementById(componentId);
if (hiddenInput && hiddenInput.value) {
const initialCountry = countries.find(c => c.name === hiddenInput.value);
if (initialCountry) {
const flagEmoji = initialCountry.iso2
.toUpperCase()
.split('')
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
selectNationality(componentId, initialCountry.name, flagEmoji);
}
}
});
}
function selectNationality(componentId, name, flag) {
const flagElement = document.getElementById(componentId + 'SelectedFlag');
const countryElement = document.getElementById(componentId + 'SelectedCountry');
const hiddenInput = document.getElementById(componentId);
if (flagElement) flagElement.textContent = flag + ' ';
if (countryElement) countryElement.textContent = name;
if (hiddenInput) hiddenInput.value = name;
// Close the dropdown after selection
const dropdownButton = document.getElementById(componentId + 'Dropdown');
if (dropdownButton) {
const dropdown = bootstrap.Dropdown.getInstance(dropdownButton);
if (dropdown) dropdown.hide();
}
}
});
</script>
@endpush

View File

@ -12,14 +12,40 @@
<!-- Profile Picture Section -->
<div class="mb-4 text-center">
<div class="mb-3">
<img src="{{ $user->profile_picture ? asset('storage/' . $user->profile_picture) : asset('images/default-avatar.png') }}"
alt="Profile Picture"
class="rounded-circle"
style="width: 120px; height: 120px; object-fit: cover; border: 3px solid #dee2e6;">
@if($user->profile_picture && file_exists(public_path('storage/' . $user->profile_picture)))
<img src="{{ asset('storage/' . $user->profile_picture) }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.png')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.png') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpg')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpg') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpeg')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpeg') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@else
<div style="width: 300px; height: 400px; background-color: #f0f0f0; border: 3px solid #dee2e6; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto;">
<div class="text-center">
<i class="bi bi-person-circle" style="font-size: 100px; color: #dee2e6;"></i>
<p class="text-muted mt-2">No profile picture</p>
</div>
</div>
@endif
</div>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#profilePictureModal">
<i class="fas fa-camera"></i> Change Profile Picture
</button>
<x-takeone-cropper
id="profile_picture"
width="300"
height="400"
shape="square"
folder="images/profiles"
filename="profile_{{ $user->id }}"
uploadUrl="{{ route('profile.upload-picture') }}"
/>
</div>
<form method="POST" action="{{ route('profile.update') }}">
@ -196,14 +222,6 @@
</div>
</div>
<!-- Profile Picture Upload Modal -->
<x-image-upload-modal
id="profilePictureModal"
aspectRatio="1"
maxSizeMB="1"
title="Upload Profile Picture"
uploadUrl="{{ route('profile.upload-picture') }}"
/>
</div>
@push('scripts')

View File

@ -42,8 +42,8 @@
<div class="d-flex">
<!-- Profile Picture -->
<div style="width: 180px; min-height: 250px; border-radius: 0.375rem 0 0 0.375rem;">
@if($relationship->dependent->media_gallery[0] ?? false)
<img src="{{ $relationship->dependent->media_gallery[0] }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;">
@if($relationship->dependent->profile_picture)
<img src="{{ asset('storage/' . $relationship->dependent->profile_picture) }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;">
@else
<div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold" style="font-size: 3rem; background: linear-gradient(135deg, {{ $relationship->dependent->gender == 'm' ? '#0d6efd 0%, #0a58ca 100%' : '#d63384 0%, #a61e4d 100%' }}); border-radius: 0.375rem 0 0 0.375rem;">
{{ strtoupper(substr($relationship->dependent->full_name, 0, 1)) }}

View File

@ -447,5 +447,8 @@
@vite(['resources/js/app.js'])
@stack('scripts')
<!-- Modals Stack (for cropper and other modals) -->
@stack('modals')
</body>
</html>

View File

@ -0,0 +1,156 @@
@php
$id = $attributes->get('id');
$width = $attributes->get('width', 300);
$height = $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle');
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'cropped_' . time());
$uploadUrl = $attributes->get('uploadUrl', route('profile.upload-picture'));
@endphp
@once
<link rel="stylesheet" href="https://unpkg.com/cropme@1.4.1/dist/cropme.min.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://unpkg.com/cropme@1.4.1/dist/cropme.min.js"></script>
<style>
.modal-content-clean { border: none; border-radius: 15px; overflow: hidden; }
.cropme-wrapper { overflow: hidden !important; border-radius: 8px; }
.cropme-slider { display: none !important; }
.takeone-canvas {
height: 400px;
background: #111;
border-radius: 8px;
position: relative;
border: 1px solid #222;
}
.custom-slider-label {
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
color: #6c757d;
letter-spacing: 0.5px;
}
.form-range::-webkit-slider-thumb { background: #198754; }
.form-range::-moz-range-thumb { background: #198754; }
</style>
@endonce
<button type="button" class="btn btn-success px-4 fw-bold shadow-sm" data-bs-toggle="modal" data-bs-target="#cropperModal_{{ $id }}">
Change Photo
</button>
@push('modals')
<div class="modal fade" id="cropperModal_{{ $id }}" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content modal-content-clean shadow-lg">
<div class="modal-body p-4 text-start">
<div class="mb-3 d-flex align-items-center">
<input type="file" id="input_{{ $id }}" class="form-control form-control-sm" accept="image/*">
<button type="button" class="btn-close ms-2" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="box_{{ $id }}" class="takeone-canvas"></div>
<div class="row mt-4">
<div class="col-md-6 mb-3">
<label class="custom-slider-label d-block mb-2">Zoom Level</label>
<input type="range" class="form-range" id="zoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="col-md-6 mb-3">
<label class="custom-slider-label d-block mb-2">Rotation</label>
<input type="range" class="form-range" id="rot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
<div class="d-grid gap-2 mt-2">
<button type="button" class="btn btn-success btn-lg fw-bold py-3" id="save_{{ $id }}">
Crop & Save Image
</button>
</div>
</div>
</div>
</div>
</div>
<script>
$(function() {
let cropper_{{ $id }} = null;
const el_{{ $id }} = document.getElementById("box_{{ $id }}");
const zoomMin_{{ $id }} = 0.01;
const zoomMax_{{ $id }} = 3;
function applyTransform_{{ $id }}(instance) {
if (!instance.properties.image) return;
const p = instance.properties;
const t = `translate3d(${p.x}px, ${p.y}px, 0) scale(${p.scale}) rotate(${p.deg}deg)`;
p.image.style.transform = t;
}
$('#input_{{ $id }}').on('change', function() {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(event) {
if (cropper_{{ $id }}) cropper_{{ $id }}.destroy();
cropper_{{ $id }} = new Cropme(el_{{ $id }}, {
container: { width: '100%', height: 400 },
viewport: {
width: {{ $width }},
height: {{ $height }},
type: '{{ $shape }}',
border: { enable: true, width: 2, color: '#fff' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin_{{ $id }}, max: zoomMax_{{ $id }}, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropper_{{ $id }}.bind({ url: event.target.result }).then(() => {
$('#zoom_{{ $id }}').val(0);
$('#rot_{{ $id }}').val(0);
});
};
reader.readAsDataURL(this.files[0]);
}
});
$('#zoom_{{ $id }}').on('input', function() {
if (!cropper_{{ $id }} || !cropper_{{ $id }}.properties.image) return;
const p = parseFloat($(this).val());
const scale = zoomMin_{{ $id }} + (zoomMax_{{ $id }} - zoomMin_{{ $id }}) * (p / 100);
cropper_{{ $id }}.properties.scale = Math.min(Math.max(scale, zoomMin_{{ $id }}), zoomMax_{{ $id }});
applyTransform_{{ $id }}(cropper_{{ $id }});
});
$('#rot_{{ $id }}').on('input', function() {
if (cropper_{{ $id }}) {
cropper_{{ $id }}.rotate(parseInt($(this).val(), 10));
}
});
$('#save_{{ $id }}').on('click', function() {
if (!cropper_{{ $id }}) return;
const btn = $(this);
btn.prop('disabled', true).text('Uploading...');
cropper_{{ $id }}.crop({ type: 'base64' }).then(base64 => {
$.post("{{ $uploadUrl }}", {
_token: "{{ csrf_token() }}",
image: base64,
folder: '{{ $folder }}',
filename: '{{ $filename }}'
}).done((res) => {
alert('Saved successfully!');
$('#cropperModal_{{ $id }}').modal('hide');
// Reload page to show new image
location.reload();
}).fail((err) => {
alert('Upload failed: ' + (err.responseJSON?.message || 'Unknown error'));
}).always(() => {
btn.prop('disabled', false).text('Crop & Save Image');
});
});
});
});
</script>
@endpush