ghassan 0b2e95ea65 Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00

407 lines
18 KiB
PHP

@php
$id = $attributes->get('id', 'cropper_' . Str::random(6));
$width = (int) $attributes->get('width', 300);
$height = (int) $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'image_' . time());
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
@endphp
@once
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* ── TakeOne Cropper Modal ─────────────────────────── */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
@endonce
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
<div class="tc-modal">
<div class="tc-modal-header">
<span class="tc-modal-title">
<i class="bi bi-crop"></i>
{{ $title }}
</span>
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="tc-modal-body">
<div class="tc-file-row">
<label class="tc-file-label" for="tcInput_{{ $id }}">
<i class="bi bi-upload"></i>
Choose image
</label>
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
</div>
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
<i class="bi bi-image"></i>
<span>Upload an image to start cropping</span>
</div>
</div>
<div class="tc-controls">
<div class="tc-control">
<label class="tc-control-label" for="tcZoom_{{ $id }}">
<i class="bi bi-zoom-in"></i> Zoom
</label>
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="tc-control">
<label class="tc-control-label" for="tcRot_{{ $id }}">
<i class="bi bi-arrow-clockwise"></i> Rotate
</label>
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
</div>
<div class="tc-modal-footer">
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
onclick="tcUploadAsIs_{{ $id }}()">
<i class="bi bi-image"></i>
Upload as-is
</button>
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
Cancel
</button>
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
onclick="tcSave_{{ $id }}()">
<i class="bi bi-check-lg"></i>
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
</button>
</div>
</div>
</div>
<script>
(function () {
var cropperInst = null;
var originalFile = null;
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
var zoomMin = 0.01, zoomMax = 3;
var id = '{{ $id }}';
var vw = {{ $width }};
var vh = {{ $height }};
var shape = '{{ $shape }}';
var folder = '{{ $folder }}';
var filename = '{{ $filename }}';
var uploadUrl = '{{ route('image.upload') }}';
var updateUrl = '{{ $updateUrl }}';
var callbackFn = '{{ $callback }}';
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}';
var isFormMode = targetInputId !== '';
function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
/* ── Open / close ── */
function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open');
document.body.style.overflow = 'hidden';
}
window['openCropperModal_' + id] = openModal;
window.closeCropperModal = window.closeCropperModal || function (i) {
var el = document.getElementById('tcOverlay_' + i);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
};
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
if (e.target === this) window.closeCropperModal(id);
});
/* ── Preload a File into the cropper ── */
function preloadFile(file) {
if (!file) return;
originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader();
reader.onload = function (e) { initCropper(e.target.result); };
reader.readAsDataURL(file);
}
window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
cropperInst = new Cropme(canvas, {
container: { width: '100%', height: 320 },
viewport: {
width: vw, height: vh,
type: shape,
border: { enable: true, width: 2, color: '#ef4444' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropperInst.bind({ url: dataUrl }).then(function () {
document.getElementById('tcZoom_' + id).value = 0;
document.getElementById('tcRot_' + id).value = 0;
});
}
/* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]);
});
/* ── Form-mode: expose open helper for external callers ── */
// Callers should invoke window['openCropperModal_' + id]() directly from their
// dropzone click/drop handlers rather than relying on event interception.
/* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return;
var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
var s = cropperInst.properties;
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
});
document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
});
/* ── Helpers ── */
function base64ToFile(base64, name) {
var arr = base64.split(',');
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
var bin = atob(arr[1]);
var u8 = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new File([u8], name || 'cropped.png', { type: mime });
}
function setOnTargetInput(file) {
var el = document.getElementById(targetInputId);
if (!el) return;
try {
var dt = new DataTransfer();
dt.items.add(file);
_intercept = true;
el.files = dt.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
_intercept = false;
}
if (previewImgId) {
var prev = document.getElementById(previewImgId);
if (prev) prev.src = URL.createObjectURL(file);
}
}
function uploadToServer(base64, onDone, onFail) {
fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.message || 'Upload failed');
if (updateUrl) {
return fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ path: res.path })
}).then(function () { return res; });
}
return res;
})
.then(onDone)
.catch(onFail);
}
/* ── Crop & Save ── */
window['tcSave_' + id] = function () {
if (!cropperInst) return;
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true;
txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
cropperInst.crop(cropOpts).then(function (base64) {
if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png';
setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
btn.disabled = false;
txt.textContent = 'Crop & Save';
} else {
uploadToServer(base64, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
txt.textContent = 'Crop & Save';
});
}
});
};
/* ── Upload as-is ── */
window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; }
if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile);
window.closeCropperModal(id);
} else {
// Server mode: read original file as base64 and upload unchanged
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
btn.disabled = true;
aisBtn.disabled = true;
txt.textContent = 'Uploading…';
var reader = new FileReader();
reader.onload = function (e) {
uploadToServer(e.target.result, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
});
};
reader.readAsDataURL(originalFile);
}
};
})();
</script>