idd / web /web_storage /index.html
esmaill1
last edti
8c43300
Raw
History Blame Contribute Delete
152 kB
<!doctype html>
<html lang="ar" dir="rtl" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ستوديو الهلال — نظام معالجة الصور</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ["selector", '[data-theme="dark"]'],
};
</script>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
rel="stylesheet"
/>
<!-- Cropper.js -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<!-- JSZip for Download All -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700&display=swap");
/* ── Theme Variables — Refined slate-based dark and clean light palette ── */
:root {
--bg-body: #0b0f19;
--bg-card: rgba(20, 30, 48, 0.7);
--bg-card-alt: rgba(15, 23, 42, 0.65);
--bg-input: #1e293b;
--border-card: rgba(255, 255, 255, 0.08);
--border-input: rgba(255, 255, 255, 0.12);
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-muted: #64748b;
--scrollbar-track: #0b0f19;
--scrollbar-thumb: #334155;
--accent: #f59e0b;
--accent-hover: #d97706;
--accent-dim: rgba(245, 158, 11, 0.15);
--danger: #ef4444;
--success: #10b981;
--info: #3b82f6;
}
[data-theme="light"] {
--bg-body: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.75);
--bg-card-alt: rgba(241, 245, 249, 0.7);
--bg-input: #ffffff;
--border-card: rgba(15, 23, 42, 0.08);
--border-input: rgba(15, 23, 42, 0.12);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--scrollbar-track: #f1f5f9;
--scrollbar-thumb: #cbd5e1;
--accent: #d97706;
--accent-hover: #b45309;
--accent-dim: rgba(217, 119, 6, 0.12);
--danger: #dc2626;
--success: #059669;
--info: #2563eb;
}
body {
font-family: "Cairo", sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
transition:
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.studio-card {
background: var(--bg-card);
border: 1px solid var(--border-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
transition:
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease,
transform 0.3s ease;
}
.studio-card:hover {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.studio-input {
background: var(--bg-input);
border: 1px solid var(--border-input);
color: var(--text-primary);
transition: all 0.2s ease;
}
.studio-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.text-theme-primary {
color: var(--text-primary);
}
.text-theme-secondary {
color: var(--text-secondary);
}
.text-theme-muted {
color: var(--text-muted);
}
.gradient-text {
background: linear-gradient(to left, #fbbf24, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.cropper-view-box,
.cropper-face {
border-radius: 2px;
outline-color: var(--accent);
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 10px;
}
/* Indicators & Glow */
.badge {
font-size: 8px;
padding: 1px 4px;
border-radius: 3px;
font-weight: 800;
text-transform: uppercase;
}
.badge-rmbg {
background: #10b981;
color: #064e3b;
}
.badge-retouch {
background: #3b82f6;
color: #1e3a8a;
}
.badge-crop {
background: #f59e0b;
color: #78350f;
}
.badge-color {
background: #a855f7;
color: #4c1d95;
}
.badge-quality {
font-size: 7px;
padding: 1px 3px;
border-radius: 2px;
font-weight: 700;
}
.badge-hd {
background: #10b981;
color: white;
}
.badge-lowres {
background: #ef4444;
color: white;
}
.badge-mid {
background: #f59e0b;
color: white;
}
/* Drop zone */
#queue-card {
transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s ease;
}
.drop-glow {
border-color: var(--accent) !important;
background-color: var(--accent-dim) !important;
box-shadow: 0 0 20px var(--accent-dim);
}
/* Settings Panel Range Slider Styling */
.settings-panel input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: var(--bg-input);
border-radius: 9999px;
height: 6px;
border: 1px solid var(--border-input);
outline: none;
transition: all 0.2s ease;
}
.settings-panel input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-card);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease, background-color 0.2s ease;
}
.settings-panel input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: var(--accent-hover);
}
.settings-panel input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-card);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease, background-color 0.2s ease;
}
.settings-panel input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.2);
background: var(--accent-hover);
}
/* Skeleton Animation */
@keyframes shimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
.skeleton {
background: var(--bg-card);
background-image: linear-gradient(
to right,
var(--bg-card) 0%,
var(--border-card) 20%,
var(--bg-card) 40%,
var(--bg-card) 100%
);
background-repeat: no-repeat;
background-size: 800px 104px;
animation: shimmer 1.5s infinite linear;
}
.preview-transition {
transition:
opacity 0.3s ease-in-out,
transform 0.3s ease-in-out;
}
.processing-spin {
animation: spin 2s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── Toast Notifications ── */
#toast-container {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column-reverse;
gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
animation: toastIn 0.35s ease-out;
max-width: 420px;
}
.toast-success {
background: #065f46;
color: #d1fae5;
}
.toast-error {
background: #7f1d1d;
color: #fecaca;
}
.toast-info {
background: #1e3a5f;
color: #bfdbfe;
}
.toast-warning {
background: #78350f;
color: #fef3c7;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
/* ── Upload Progress ── */
.upload-progress-bar {
height: 4px;
border-radius: 2px;
background: var(--border-card);
overflow: hidden;
margin-top: 8px;
}
.upload-progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
}
/* ── Zoom Modal ── */
#zoom-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
background: rgba(0, 0, 0, 0.9);
cursor: zoom-out;
align-items: center;
justify-content: center;
}
#zoom-modal.active {
display: flex;
}
#zoom-modal img {
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
transform-origin: center;
transition: transform 0.2s;
}
/* ── Settings Panel ── */
.settings-panel {
max-height: 0;
overflow: hidden;
transition:
max-height 0.35s ease-out,
opacity 0.25s;
opacity: 0;
}
.settings-panel.open {
max-height: 500px;
opacity: 1;
}
/* ── Mobile Drawer ── */
.mobile-drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 80;
display: none;
}
.mobile-drawer-overlay.active {
display: block;
}
.mobile-drawer {
position: fixed;
top: 0;
bottom: 0;
width: 85%;
max-width: 380px;
z-index: 81;
transition: transform 0.3s ease;
overflow-y: auto;
padding: 20px;
background: var(--bg-card);
}
/* RTL: drawer slides from left */
[dir="rtl"] .mobile-drawer {
left: 0;
right: auto;
transform: translateX(-100%);
}
.mobile-drawer.active {
transform: translateX(0) !important;
}
/* Empty state */
.empty-state-icon {
font-size: 32px;
color: var(--text-muted);
opacity: 0.4;
display: block;
margin-bottom: 10px;
}
/* Phase 3.4: Jump popover */
#jump-popover {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border-card);
border-radius: 12px;
padding: 12px;
z-index: 50;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
min-width: 200px;
display: none;
}
#jump-popover.active {
display: block;
}
#jump-popover input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-input);
border: 1px solid var(--border-input);
color: var(--text-primary);
text-align: center;
font-size: 16px;
outline: none;
}
#jump-popover input:focus {
border-color: var(--accent);
}
/* Phase 3.5: Live preview thumbnail */
#settings-live-preview {
width: 100%;
height: 80px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-card);
background: var(--bg-card-alt);
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
#settings-live-preview img {
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
/* Phase 3.6: Undo toast */
.undo-toast {
pointer-events: auto;
padding: 10px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
animation: toastIn 0.3s ease-out;
background: #1e3a5f;
color: #bfdbfe;
max-width: 340px;
}
.undo-toast button {
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.undo-btn {
background: var(--accent);
color: #1a1a1a;
}
.undo-btn:hover {
background: var(--accent-hover);
}
/* Focus */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ── Keyboard Shortcuts Modal ── */
#shortcuts-modal {
position: fixed;
inset: 0;
z-index: 90;
display: none;
background: rgba(0, 0, 0, 0.6);
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#shortcuts-modal.active {
display: flex;
}
/* Horizontal queue slider */
.queue-slider {
display: flex;
flex-direction: row;
gap: 10px;
flex-wrap: wrap;
max-height: 340px;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px;
scroll-behavior: smooth;
scrollbar-width: thin;
}
.queue-slider::-webkit-scrollbar {
width: 5px;
}
.queue-slider::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 10px;
}
.queue-slider::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 10px;
}
.queue-slider::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
.queue-slide {
flex: 0 0 auto;
width: 72px;
cursor: pointer;
position: relative;
border-radius: 10px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
/* Phase 3.2 microinteraction */
}
.queue-slide:hover {
transform: translateY(-2px);
}
.queue-slide.active {
box-shadow: 0 0 0 2px var(--accent);
}
.queue-slide img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 10px;
display: block;
border: 1px solid var(--border-card);
}
.queue-slide .slide-status {
position: absolute;
top: -4px;
right: -4px;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
z-index: 5;
background: var(--bg-card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.queue-slide .slide-delete {
position: absolute;
top: -4px;
left: -4px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ef4444;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
z-index: 5;
opacity: 0;
transition: opacity 0.15s;
border: none;
cursor: pointer;
}
.queue-slide:hover .slide-delete {
opacity: 1;
}
.queue-slide .slide-name {
font-size: 9px;
text-align: center;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
font-weight: 600;
max-width: 72px;
}
/* Per-image countdown on queue slide */
.slide-countdown {
position: absolute;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: var(--accent);
font-size: 8px;
font-weight: 700;
font-family: monospace;
padding: 1px 5px;
border-radius: 2px;
z-index: 6;
white-space: nowrap;
display: none;
}
/* Retry button on queue slide */
.slide-retry {
position: absolute;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
background: #f59e0b;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
z-index: 5;
opacity: 0;
transition: opacity 0.15s;
border: none;
cursor: pointer;
}
.queue-slide:hover .slide-retry {
opacity: 1;
}
/* Error detail tooltip */
.slide-error-msg {
position: absolute;
bottom: 22px;
right: -4px;
background: #7f1d1d;
color: #fecaca;
font-size: 7px;
padding: 3px 6px;
border-radius: 6px;
max-width: 120px;
white-space: normal;
text-align: right;
z-index: 10;
display: none;
pointer-events: none;
}
.slide-error-msg::before {
content: "";
position: absolute;
bottom: 4px;
left: -4px;
border: 4px solid transparent;
border-left-color: #7f1d1d;
}
.queue-slide.has-error:hover .slide-error-msg {
display: block;
}
/* Queue item slide-in animation */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.queue-slide-new {
animation: slideInRight 0.25s ease-out;
}
/* Queue item fade-out animation */
@keyframes fadeOutCollapse {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
.queue-slide-removing {
animation: fadeOutCollapse 0.2s ease-in forwards;
}
/* Phase 2.1: Frame thumbnail grid */
.frame-thumb {
width: 44px;
height: 44px;
border-radius: 8px;
border: 2px solid var(--border-card);
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.frame-thumb:hover {
border-color: #fbbf24;
transform: scale(1.05);
}
.frame-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.frame-thumb-selected {
border-color: #fbbf24;
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.3);
}
/* Phase 2.4: Color swatch delete button */
.color-swatch .color-delete-btn {
position: absolute;
top: -3px;
left: -3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ef4444;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
font-size: 6px;
z-index: 2;
}
.color-swatch:hover .color-delete-btn {
opacity: 1;
}
/* ── Custom CropperJS Overrides ── */
.cropper-view-box {
outline: 2px solid var(--accent) !important;
outline-color: var(--accent) !important;
}
.cropper-line {
background-color: var(--accent) !important;
}
.cropper-point {
background-color: var(--accent) !important;
width: 8px !important;
height: 8px !important;
}
.cropper-point.point-se {
width: 12px !important;
height: 12px !important;
opacity: 0.9 !important;
}
.cropper-bg {
background-image: repeating-linear-gradient(45deg, #222 25%, transparent 25%, transparent 75%, #222 75%, #222), repeating-linear-gradient(45deg, #222 25%, #111 25%, #111 75%, #222 75%, #222) !important;
background-position: 0 0, 10px 10px !important;
background-size: 20px 20px !important;
}
/* ── Modern Centered Cropper Modal ── */
#cropper-container {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: 100dvh !important;
z-index: 9999 !important;
background: rgba(11, 15, 25, 0.85) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
transition: opacity 0.3s ease;
}
#cropper-container.hidden {
display: none !important;
}
.cropper-modal-box {
background: var(--bg-card);
border: 1px solid var(--border-card);
border-radius: 20px;
width: 100%;
max-width: 900px;
height: 100%;
max-height: 85vh;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
/* ── UX Improvement 1: Guided Empty State ── */
.empty-drop-zone {
border: 2px dashed var(--border-input);
border-radius: 16px;
padding: 40px 24px;
cursor: pointer;
transition: border-color 0.3s ease, background-color 0.3s ease, transform 0.2s ease;
text-align: center;
}
.empty-drop-zone:hover {
border-color: var(--accent);
background-color: var(--accent-dim);
transform: translateY(-2px);
}
.empty-drop-zone.drop-glow {
border-color: var(--accent) !important;
background-color: var(--accent-dim) !important;
box-shadow: 0 0 24px var(--accent-dim);
}
@keyframes float-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.float-icon {
animation: float-gentle 3s ease-in-out infinite;
}
.onboard-steps {
display: flex;
justify-content: center;
gap: 32px;
margin-top: 20px;
flex-wrap: wrap;
}
.onboard-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.onboard-step-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
font-size: 14px;
}
.onboard-step-arrow {
color: var(--text-muted);
opacity: 0.3;
font-size: 12px;
align-self: center;
margin-top: -10px;
}
/* ── UX Improvement 4: Processing overlay on preview ── */
.preview-processing-overlay {
position: absolute;
inset: 0;
z-index: 15;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
border-radius: 16px;
}
.preview-processing-overlay.active {
display: flex;
}
/* ── UX Improvement 6: Queue status bottom-border ── */
.queue-slide.status-done {
border-bottom: 3px solid var(--success);
}
.queue-slide.status-processing {
border-bottom: 3px solid var(--accent);
}
.queue-slide.status-error,
.queue-slide.status-upload-failed {
border-bottom: 3px solid var(--danger);
}
.queue-slide .slide-status {
width: 20px;
height: 20px;
font-size: 10px;
}
/* ── UX Improvement 7: Mobile sticky action bar ── */
.mobile-sticky-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 70;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: var(--bg-card);
border-top: 1px solid var(--border-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: none;
}
@media (max-width: 1023px) {
.mobile-sticky-bar.has-images {
display: flex;
gap: 8px;
}
body {
padding: 8px !important;
padding-bottom: 80px !important;
}
main {
gap: 16px !important;
}
/* Mobile drawer queue slide layout transformation */
.mobile-drawer .queue-slide {
flex: 0 0 auto;
width: 100% !important;
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
position: relative;
transform: none !important;
}
.mobile-drawer .queue-slide:hover {
transform: none !important;
}
.mobile-drawer .queue-slide.active {
background: rgba(245, 158, 11, 0.08);
border-color: var(--accent);
box-shadow: none;
}
.mobile-drawer .queue-slide img {
width: 48px !important;
height: 48px !important;
border-radius: 8px;
flex-shrink: 0;
}
.mobile-drawer .queue-slide .slide-name {
flex-grow: 1;
text-align: right;
margin-top: 0;
font-size: 13px;
color: var(--text-primary);
max-width: none !important;
white-space: normal;
word-break: break-all;
}
.mobile-drawer .queue-slide .slide-status {
position: static !important;
width: 24px;
height: 24px;
font-size: 12px;
background: transparent !important;
box-shadow: none !important;
flex-shrink: 0;
}
.mobile-drawer .queue-slide .slide-delete {
position: static !important;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.15) !important;
color: #ef4444 !important;
display: flex !important;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 1 !important;
flex-shrink: 0;
}
.mobile-drawer .queue-slide .slide-delete:hover {
background: #ef4444 !important;
color: white !important;
}
.mobile-drawer .queue-slide .slide-retry {
position: static !important;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(245, 158, 11, 0.15) !important;
color: var(--accent) !important;
display: flex !important;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 1 !important;
flex-shrink: 0;
}
.mobile-drawer .queue-slide .slide-countdown {
position: static !important;
transform: none !important;
margin-left: auto;
font-size: 10px;
}
/* Make delete buttons visible on hover-less mobile viewport */
/* Make delete buttons visible on hover-less mobile viewport */
.queue-slide .slide-delete {
opacity: 1 !important;
}
/* ── Mobile Fullscreen Cropper ── */
#cropper-container {
padding: 8px !important;
}
.cropper-modal-box {
max-width: 100% !important;
max-height: 100% !important;
height: 100% !important;
border-radius: 0 !important;
border: none !important;
padding: 12px !important;
gap: 8px !important;
}
/* Compact header on mobile */
#cropper-container .cropper-header-row {
padding-bottom: 8px !important;
gap: 8px;
}
#cropper-container .cropper-header-row h3 {
font-size: 14px !important;
}
#cropper-container .cropper-header-row p {
display: none !important;
}
#cropper-container .cropper-header-row .badge {
font-size: 10px !important;
padding: 2px 6px !important;
}
/* Smaller toolbar buttons on mobile */
#cropper-container .cropper-toolbar button {
width: 34px !important;
height: 34px !important;
font-size: 13px !important;
}
#cropper-container .cropper-toolbar .cropper-reset-btn {
width: auto !important;
padding: 0 8px !important;
font-size: 11px !important;
height: 34px !important;
}
#cropper-container .cropper-toolbar {
padding: 8px !important;
gap: 6px !important;
}
#cropper-container .cropper-toolbar .flex.items-center {
gap: 4px !important;
}
/* Compact action buttons on mobile */
#cropper-container .cropper-actions {
padding-top: 8px !important;
gap: 8px !important;
}
#cropper-container .cropper-actions button {
padding: 8px 14px !important;
font-size: 13px !important;
}
}
/* ── UX Improvement 2: Process button pulse ── */
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
}
.pulse-attention:not(:disabled) {
animation: pulse-ring 2s ease-out infinite;
}
</style>
</head>
<body class="min-h-screen p-4 md:p-10">
<!-- Toast Container -->
<div id="toast-container"></div>
<!-- Zoom Modal (#14) -->
<div id="zoom-modal" onclick="closeZoom()">
<img id="zoom-img" src="" alt="Zoomed preview" />
</div>
<!-- Cropper Modal Backdrop -->
<div id="cropper-container" class="hidden">
<!-- Cropper Modal Box -->
<div class="cropper-modal-box">
<!-- Cropper Header -->
<div class="cropper-header-row flex justify-between items-center border-b border-[var(--border-card)] pb-3">
<div class="text-right">
<h3 class="text-md font-bold text-theme-primary flex items-center gap-2">
<i class="fa-solid fa-crop-simple text-[var(--accent)]"></i>
تعديل القص اليدوي للوجه
</h3>
<p class="text-xs text-theme-secondary mt-0.5">
اضبط المستطيل ليغطي الرأس والكتفين بوضوح للحصول على نسبة مثالية.
</p>
</div>
<div class="flex items-center gap-2">
<span class="badge bg-[var(--accent-dim)] text-[var(--accent)] border border-[var(--accent)]/30 px-2.5 py-1 rounded text-xs font-semibold">
نسبة ثابتة: 5:7 (أبعاد الهوية)
</span>
</div>
</div>
<!-- Cropper Viewport -->
<div class="flex-1 min-h-0 overflow-hidden rounded-xl border border-[var(--border-card)] bg-black relative flex items-center justify-center">
<div id="cropper-loader" class="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10 gap-3">
<i class="fa-solid fa-circle-notch fa-spin text-3xl text-[var(--accent)]"></i>
<span class="text-xs text-theme-secondary">جاري تحميل الصورة عالية الدقة...</span>
</div>
<img id="cropper-img" class="max-w-full max-h-full block" src="" />
</div>
<!-- Cropper Toolbar (Controls) -->
<div class="cropper-toolbar flex flex-wrap items-center justify-between gap-3 bg-[var(--bg-card-alt)] p-3 rounded-xl border border-[var(--border-card)]" dir="ltr">
<!-- Transform Controls (Rotate & Flip) -->
<div class="flex items-center gap-2">
<button
type="button"
onclick="cropper.rotate(-90)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Rotate 90 left"
>
<i class="fa-solid fa-rotate-left"></i>
</button>
<button
type="button"
onclick="cropper.rotate(90)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Rotate 90 right"
>
<i class="fa-solid fa-rotate-right"></i>
</button>
<div class="h-6 w-px bg-[var(--border-card)] mx-1"></div>
<button
type="button"
onclick="cropper.scaleX(cropper.getData().scaleX === -1 ? 1 : -1)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Flip Horizontal"
>
<i class="fa-solid fa-arrows-left-right"></i>
</button>
<button
type="button"
onclick="cropper.scaleY(cropper.getData().scaleY === -1 ? 1 : -1)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Flip Vertical"
>
<i class="fa-solid fa-arrows-up-down"></i>
</button>
</div>
<!-- Zoom & Reset Controls -->
<div class="flex items-center gap-2">
<button
type="button"
onclick="cropper.zoom(0.1)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Zoom In"
>
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button
type="button"
onclick="cropper.zoom(-0.1)"
class="w-10 h-10 rounded-lg flex items-center justify-center bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary"
title="Zoom Out"
>
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<div class="h-6 w-px bg-[var(--border-card)] mx-1"></div>
<button
type="button"
onclick="cropper.reset()"
class="cropper-reset-btn px-3 h-10 rounded-lg flex items-center gap-1.5 bg-[var(--bg-input)] hover:bg-[var(--accent-dim)] hover:text-[var(--accent)] border border-[var(--border-input)] transition-all text-theme-primary text-xs font-bold"
title="Reset Crop Box"
>
<i class="fa-solid fa-arrow-rotate-left"></i>
Reset
</button>
</div>
</div>
<!-- Cropper Actions -->
<div class="cropper-actions flex justify-end gap-3 border-t border-[var(--border-card)] pt-3">
<button
onclick="cancelCrop()"
class="px-6 py-2.5 bg-slate-700 hover:bg-slate-600 rounded-xl font-bold text-white transition-all text-sm flex items-center gap-2"
>
<i class="fa-solid fa-xmark"></i>
إلغاء
</button>
<button
onclick="applyCrop()"
class="px-6 py-2.5 font-bold rounded-xl transition-all hover:scale-[1.02] text-sm flex items-center gap-2"
style="background: var(--accent); color: #1a1a1a"
>
<i class="fa-solid fa-circle-check"></i>
حفظ وإعادة معالجة الصورة
</button>
</div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div id="shortcuts-modal" onclick="this.classList.remove('active')">
<div
class="studio-card rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl"
onclick="event.stopPropagation()"
>
<h3 class="font-bold text-lg mb-4 text-right gradient-text">
اختصارات لوحة المفاتيح
</h3>
<div class="space-y-3 text-sm" dir="ltr">
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>&larr; &rarr;</kbd
><span class="text-theme-secondary">Navigate images</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>G</kbd
><span class="text-theme-secondary">Jump to image</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>Delete</kbd
><span class="text-theme-secondary">Delete selected</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>Enter</kbd
><span class="text-theme-secondary">Save &amp; Next</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>Ctrl+S</kbd
><span class="text-theme-secondary">Process current</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>Ctrl+Z</kbd
><span class="text-theme-secondary">Undo last action</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs"
>Escape</kbd
><span class="text-theme-secondary">Close modals</span>
</div>
<!-- Cropping specific shortcuts -->
<div class="border-t border-[var(--border-card)] my-2 pt-2 text-xs text-[var(--accent)] font-bold text-right" dir="rtl">
أثناء تعديل القص اليدوي:
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Enter</kbd>
<span class="text-theme-secondary">Apply crop &amp; reprocess</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">Escape</kbd>
<span class="text-theme-secondary">Cancel crop</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">+ / -</kbd>
<span class="text-theme-secondary">Zoom in / out</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">R</kbd>
<span class="text-theme-secondary">Rotate 90 deg</span>
</div>
<div class="flex justify-between">
<kbd class="bg-slate-700 text-white px-2 py-1 rounded text-xs">&larr; &rarr; &uarr; &darr;</kbd>
<span class="text-theme-secondary">Pan image</span>
</div>
</div>
<button
onclick="
document
.getElementById('shortcuts-modal')
.classList.remove('active')
"
class="mt-5 w-full py-2 font-bold rounded"
style="background: var(--accent); color: #1a1a1a"
>
حسناً
</button>
</div>
</div>
<!-- Mobile Drawer Overlay -->
<div
class="mobile-drawer-overlay lg:hidden"
id="drawer-overlay"
onclick="toggleDrawer(false)"
></div>
<div class="mobile-drawer lg:hidden studio-card" id="mobile-drawer">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-sm text-theme-secondary">قائمة المعالجة</h3>
<div class="flex items-center gap-2">
<button
onclick="fileInput.click()"
class="px-2.5 py-1.5 bg-yellow-500 hover:bg-yellow-400 text-slate-900 rounded-lg text-xs font-bold transition-all flex items-center gap-1 shadow-sm active:scale-95"
>
<i class="fa-solid fa-plus text-[9px]"></i> إضافة صور
</button>
<button
onclick="toggleDrawer(false)"
class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center"
>
<i class="fa-solid fa-xmark text-white text-sm"></i>
</button>
</div>
</div>
<div
id="mobile-image-list"
class="flex flex-col gap-3 overflow-y-auto"
></div>
</div>
<!-- Header -->
<header
class="max-w-[1400px] mx-auto mb-8 flex flex-col md:flex-row justify-between items-center gap-4"
>
<div>
<h1
class="text-3xl font-bold text-theme-primary text-right"
style="letter-spacing: -0.02em"
>
ستوديو الهلال
</h1>
<p class="text-theme-secondary text-right">سير عمل متكامل</p>
</div>
<div class="flex flex-wrap justify-center md:justify-end gap-2">
<a
href="https://esmailx50-job.hf.space"
target="_blank"
class="flex items-center gap-2 px-3 py-2 rounded-full bg-indigo-900/30 border border-indigo-500/50 text-sm text-indigo-300 hover:bg-indigo-500 hover:text-white transition-all font-bold"
>
<i class="fa-solid fa-wand-magic-sparkles"></i>
<span class="hidden sm:inline">أداة تحسين الجودة</span>
</a>
<a
href="https://esmailx50-rmbg2.hf.space"
target="_blank"
class="flex items-center gap-2 px-3 py-2 rounded-full bg-rose-900/30 border border-rose-500/50 text-sm text-rose-300 hover:bg-rose-500 hover:text-white transition-all font-bold"
>
<i class="fa-solid fa-eraser"></i>
<span class="hidden sm:inline">أداة إزالة الخلفية</span>
</a>
<!-- Theme Toggle (#11) -->
<button
id="theme-toggle"
onclick="toggleTheme()"
title="تبديل السمة"
aria-label="تبديل بين الوضع الداكن والفاتح"
class="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center hover:bg-slate-700 transition-all"
style="transition: background-color 0.2s ease"
>
<i
class="fa-solid fa-sun text-sm"
id="theme-icon"
style="color: var(--accent)"
></i>
</button>
<!-- Keyboard Shortcuts Hint -->
<button
onclick="
document.getElementById('shortcuts-modal').classList.add('active')
"
title="اختصارات لوحة المفاتيح"
aria-label="عرض اختصارات لوحة المفاتيح"
class="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center hover:bg-slate-700 transition-all"
style="transition: background-color 0.2s ease"
>
<i class="fa-regular fa-keyboard text-slate-400 text-sm"></i>
</button>
<div
id="ai-status"
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm flex-wrap"
style="
background: var(--bg-card);
border: 1px solid var(--border-card);
color: var(--text-muted);
"
>
<div
class="w-3 h-3 rounded-full animate-pulse"
style="background: var(--accent)"
></div>
</div>
</div>
</header>
<main
class="max-w-[1400px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-6"
>
<!-- ── COLUMN 1: QUEUE (RIGHT SIDE) ── -->
<div class="hidden lg:flex lg:col-span-3 flex-col gap-4 order-1">
<input
type="file"
id="file-input"
multiple
class="hidden"
accept="image/*"
/>
<div class="studio-card rounded-2xl p-4 flex flex-col" id="queue-card">
<div class="flex justify-between items-center mb-4 px-1">
<h3
class="font-bold uppercase text-[10px] tracking-widest text-theme-muted"
>
قائمة المعالجة
</h3>
<div class="flex items-center gap-2">
<span
id="queue-count"
class="text-[10px] bg-slate-700 text-white px-2 py-0.5 rounded"
>٠ صور</span
>
<button
onclick="fileInput.click()"
class="px-2.5 py-1.5 bg-yellow-500 hover:bg-yellow-400 text-slate-900 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-sm active:scale-95"
>
<i class="fa-solid fa-plus text-[9px]"></i> إضافة صور
</button>
</div>
</div>
<!-- Desktop Upload Progress -->
<div id="upload-progress-wrapper" class="hidden mb-3 p-3 rounded-xl bg-slate-800/40 border border-slate-700/50">
<div class="upload-progress-bar">
<div id="upload-progress-fill" class="upload-progress-fill"></div>
</div>
<p id="upload-progress-text" class="text-xs text-theme-muted mt-1 text-center">
جاري الرفع...
</p>
</div>
<div id="image-list" class="queue-slider">
<div class="text-center w-full py-6" id="empty-queue-placeholder">
<p class="text-theme-muted text-xs font-medium">القائمة فارغة</p>
</div>
</div>
</div>
</div>
<!-- Mobile Queue & Upload Row -->
<div class="lg:hidden flex gap-3">
<button
onclick="toggleDrawer(true)"
class="flex-1 studio-card rounded-xl p-3 flex items-center justify-center gap-3 active:scale-95 transition-transform"
>
<i class="fa-solid fa-list text-yellow-500"></i>
<span class="font-bold text-sm text-theme-secondary"
>قائمة الصور</span
>
<span
id="mobile-queue-count"
class="text-xs px-2 py-0.5 rounded-full font-bold"
style="background: var(--accent); color: #1a1a1a"
>0</span
>
</button>
<button
onclick="fileInput.click()"
class="flex-1 studio-card rounded-xl p-3 flex items-center justify-center gap-3 active:scale-95 transition-transform"
style="background: var(--bg-card); border: 1px dashed rgba(245, 158, 11, 0.4);"
>
<i class="fa-solid fa-plus text-yellow-500"></i>
<span class="font-bold text-sm text-theme-secondary">إضافة صور</span>
</button>
</div>
<!-- Mobile Upload Progress (shown only when uploading) -->
<div id="upload-progress-wrapper-mobile" class="hidden lg:hidden studio-card rounded-xl p-3 mt-2">
<div class="upload-progress-bar">
<div
id="upload-progress-fill-mobile"
class="upload-progress-fill"
></div>
</div>
<p
id="upload-progress-text-mobile"
class="text-xs text-theme-muted mt-1 text-center"
>
جاري الرفع...
</p>
</div>
<!-- ── COLUMN 2: CENTER (inputs + preview) ── -->
<div class="lg:col-span-6 flex flex-col-reverse lg:flex-col gap-6 order-2">
<!-- Data Input Card -->
<div class="studio-card rounded-2xl p-4 shadow-lg">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<div class="flex items-center gap-3">
<h3
class="font-bold uppercase text-xs tracking-widest text-theme-muted"
>
بيانات الطالب
</h3>
<span
id="current-filename"
class="text-xs font-bold text-theme-secondary truncate max-w-[140px]"
style="
border-right: 1px solid var(--border-card);
padding-right: 12px;
"
></span>
</div>
<!-- Moved navigation here -->
<div class="flex items-center gap-2" dir="ltr">
<button
onclick="navigate(-1)"
class="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 border border-slate-700 flex items-center justify-center transition-all"
style="
transition:
background-color 0.2s ease,
transform 0.15s ease;
"
aria-label="الصورة السابقة"
>
<i class="fa-solid fa-chevron-left text-slate-400 text-xs"></i>
</button>
<span
id="nav-counter"
class="text-xs font-mono font-bold text-yellow-500 mx-1 cursor-pointer"
onclick="openJumpPopover(event)"
title="انقر للقفز إلى صورة معينة"
>0/0</span
>
<button
onclick="navigate(1)"
class="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 border border-slate-700 flex items-center justify-center transition-all"
style="
transition:
background-color 0.2s ease,
transform 0.15s ease;
"
aria-label="الصورة التالية"
>
<i class="fa-solid fa-chevron-right text-slate-400 text-xs"></i>
</button>
<!-- Phase 3.4: Jump Popover -->
<div id="jump-popover" onclick="event.stopPropagation()">
<p
class="text-[10px] font-bold text-theme-muted mb-2 text-center uppercase tracking-widest"
>
انتقل إلى
</p>
<input
type="number"
id="jump-input"
min="1"
max="999"
placeholder="رقم الصورة"
aria-label="رقم الصورة للقفز إليها"
/>
<p class="text-[9px] text-theme-muted mt-2 text-center">
من <span id="jump-total">0</span> صورة
</p>
<button
onclick="doJump()"
class="mt-2 w-full py-1.5 font-bold text-xs rounded transition-colors"
style="background: var(--accent); color: #1a1a1a"
>
انتقال
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
class="block text-xs font-bold text-theme-muted uppercase mb-2 tracking-widest text-right"
>اسم الطالب</label
>
<input
type="text"
id="student-name"
dir="rtl"
class="w-full studio-input border rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-yellow-500 outline-none transition-all text-right"
placeholder="الاسم هنا..."
/>
</div>
<div>
<label
class="block text-xs font-bold text-theme-muted uppercase mb-2 tracking-widest text-right"
>الرقم القومي</label
>
<input
type="text"
id="student-id"
class="w-full studio-input border rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-yellow-500 outline-none transition-all text-right"
placeholder="٣٠٠٠..."
/>
</div>
</div>
</div>
<!-- Editor/Preview Area -->
<div
class="studio-card rounded-2xl p-2 flex-grow relative min-h-[320px] md:min-h-[450px] flex items-center justify-center overflow-hidden"
id="main-area"
>
<!-- Zoom Hint (#14) -->
<button
onclick="openZoom()"
id="zoom-hint"
class="hidden absolute bottom-3 left-3 z-10 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-2 transition-all hover:scale-105"
style="
background: rgba(0, 0, 0, 0.6);
color: white;
border: 1px solid rgba(255, 255, 255, 0.15);
"
>
<i class="fa-solid fa-magnifying-glass-plus"></i>
تكبير
</button>
<!-- Preview Mode -->
<div
id="preview-container"
class="w-full h-full flex items-center justify-center relative"
>
<!-- UX4: Processing overlay -->
<div id="preview-processing-overlay" class="preview-processing-overlay">
<div class="w-10 h-10 border-2 border-t-transparent rounded-full animate-spin" style="border-color: var(--accent); border-top-color: transparent"></div>
<p class="text-sm font-bold" style="color: var(--accent)">جاري المعالجة...</p>
<p class="text-xs" style="color: var(--text-muted)" id="preview-processing-filename"></p>
</div>
<div
id="preview-skeleton"
class="hidden w-[80%] h-[80%] skeleton rounded-lg shadow-2xl opacity-50"
></div>
<img
id="main-preview"
src=""
class="max-w-full max-h-[350px] md:max-h-[500px] hidden rounded shadow-2xl preview-transition opacity-0 cursor-zoom-in"
onclick="openZoom()"
/>
<!-- Professional Empty State -->
<!-- UX1: Guided Empty State with drop zone -->
<div id="preview-placeholder" class="text-center px-4 w-full">
<div class="empty-drop-zone" id="preview-drop-zone" onclick="fileInput.click()">
<i class="fa-solid fa-cloud-arrow-up text-5xl float-icon" style="color: var(--accent); opacity: 0.7;"></i>
<h3 class="text-lg font-bold mt-4" style="color: var(--text-primary)">ارفع صور للبدء</h3>
<p class="text-xs mt-1" style="color: var(--text-muted)">اسحب وأفلت هنا أو انقر للاختيار</p>
<div class="onboard-steps">
<div class="onboard-step">
<div class="onboard-step-icon"><i class="fa-solid fa-upload"></i></div>
<span>ارفع الصور</span>
</div>
<span class="onboard-step-arrow"><i class="fa-solid fa-chevron-left"></i></span>
<div class="onboard-step">
<div class="onboard-step-icon"><i class="fa-solid fa-sliders"></i></div>
<span>اختر الإعدادات</span>
</div>
<span class="onboard-step-arrow"><i class="fa-solid fa-chevron-left"></i></span>
<div class="onboard-step">
<div class="onboard-step-icon"><i class="fa-solid fa-download"></i></div>
<span>حمّل النتائج</span>
</div>
</div>
</div>
</div>
</div>
<!-- Batch Processing Overlay -->
<div
id="batch-overlay"
class="absolute inset-0 hidden items-center justify-center flex-col gap-4 z-50 rounded-2xl"
style="background: rgba(0, 0, 0, 0.75)"
>
<div
class="w-10 h-10 border-2 border-t-transparent rounded-full animate-spin"
style="border-color: var(--accent); border-top-color: transparent"
></div>
<p
id="batch-counter"
class="text-2xl font-mono font-bold"
style="color: var(--accent)"
dir="ltr"
>
0/0
</p>
<p
id="batch-eta"
class="text-sm font-medium hidden"
style="color: var(--text-muted)"
></p>
<p class="text-sm" style="color: var(--text-muted)">
جاري المعالجة...
</p>
<button
id="batch-cancel-btn"
onclick="cancelBatch()"
class="mt-1 px-5 py-2 font-bold rounded text-sm transition-all flex items-center gap-2"
style="background: var(--danger); color: white"
>
<i class="fa-solid fa-stop"></i> إلغاء
</button>
</div>
</div>
</div>
<!-- ── COLUMN 3: OPTIONS (LEFT SIDE) ── -->
<div class="lg:col-span-3 flex flex-col gap-6 order-3">
<div
class="studio-card rounded-2xl p-5 sticky top-6"
style="border: 1px solid var(--border-card)"
>
<label
class="block text-[10px] font-bold text-theme-muted uppercase mb-4 tracking-widest text-right"
>الخيارات والإعدادات</label
>
<!-- Processing Steps -->
<div class="space-y-3 mb-6" dir="rtl">
<!-- Phase 2.3: Tooltips added via title attribute + ? icon -->
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
title="ترميم الوجه باستخدام CodeFormer — يحسن جودة الصورة"
>
<input
type="checkbox"
id="step-restore"
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-cyan-500 transition-colors"
>ترميم الوجه (AI)</span
>
<i
class="fa-regular fa-circle-question text-[10px] text-slate-600 group-hover:text-cyan-400 transition-colors"
></i>
</label>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
title="إزالة العيوب مع الحفاظ على ملمس البشرة الطبيعي"
>
<input
type="checkbox"
id="step-retouch"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors"
>تجميل البشرة</span
>
<i
class="fa-regular fa-circle-question text-[10px] text-slate-600 group-hover:text-yellow-400 transition-colors"
></i>
</label>
<div class="flex flex-col gap-1 p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
<label
class="flex items-center gap-3 cursor-pointer group"
title="تحسين الألوان باستخدام شبكة MLP أو منحنيات LUT"
>
<input
type="checkbox"
id="step-color"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
onchange="toggleColorMethodContainer()"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors"
>تحسين الألوان</span
>
<i
class="fa-regular fa-circle-question text-[10px] text-slate-600 group-hover:text-yellow-400 transition-colors"
></i>
</label>
<div id="color-method-container" class="mt-1 flex items-center gap-2 pr-7">
<label for="color-method" class="text-xs text-theme-muted">الطريقة:</label>
<select
id="color-method"
class="flex-1 bg-slate-800 border border-slate-700 text-slate-300 text-xs rounded p-1 focus:ring-1 focus:ring-yellow-500 outline-none"
>
<option value="mlp">MLP (شبكة عصبية)</option>
<option value="lut">LUT (منحنيات الألوان)</option>
</select>
</div>
</div>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
title="إزالة الخلفية باستخدام BiRefNet (RMBG-2.0)"
>
<input
type="checkbox"
id="step-rmbg"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors"
>إزالة الخلفية</span
>
<i
class="fa-regular fa-circle-question text-[10px] text-slate-600 group-hover:text-yellow-400 transition-colors"
></i>
</label>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
title="كشف الوجه وتوسيطه تلقائياً بنسبة 5:7 للبطاقة"
>
<input
type="checkbox"
id="step-crop"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-yellow-500 transition-colors"
>قص تلقائي</span
>
<i
class="fa-regular fa-circle-question text-[10px] text-slate-600 group-hover:text-yellow-400 transition-colors"
></i>
</label>
<div
class="border-t my-3"
style="border-color: var(--border-card)"
></div>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
>
<input
type="checkbox"
id="add-studio"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors"
>اسم الاستوديو</span
>
</label>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
>
<input
type="checkbox"
id="add-logo"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors"
>شعار الاستوديو</span
>
</label>
<label
class="flex items-center gap-3 cursor-pointer group p-2 rounded-lg hover:bg-slate-500/5 transition-colors"
>
<input
type="checkbox"
id="add-date"
checked
class="w-4 h-4 rounded"
style="accent-color: var(--accent)"
/>
<span
class="text-sm font-medium text-theme-secondary group-hover:text-emerald-500 transition-colors"
>إضافة التاريخ</span
>
</label>
<div
class="border-t my-3"
style="border-color: var(--border-card)"
></div>
<!-- Frame Selection -->
<div class="p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-theme-secondary"
>الإطار الزخرفي</span
>
<div class="flex gap-1">
<button
onclick="deleteFrame()"
class="text-xs bg-rose-900/30 border border-rose-500/50 hover:bg-rose-500 text-rose-300 hover:text-white w-6 h-6 flex items-center justify-center rounded transition-all"
title="حذف الإطار المحدد"
>
<i class="fa-solid fa-xmark"></i>
</button>
<button
onclick="document.getElementById('frame-upload').click()"
class="text-xs bg-slate-700 hover:bg-slate-600 text-white px-2 py-1 rounded transition-colors"
>
<i class="fa-solid fa-plus"></i> رفع
</button>
</div>
<input
type="file"
id="frame-upload"
class="hidden"
accept="image/*"
onchange="uploadFrame(this.files[0])"
/>
</div>
<select
id="frame-select"
class="w-full bg-slate-800 border border-slate-700 text-slate-300 text-xs rounded p-2 focus:ring-2 focus:ring-pink-500 outline-none"
>
<option value="">(بدون إطار)</option>
</select>
<!-- Phase 2.1: Larger frame preview + thumbnail grid -->
<div
id="frame-preview-box"
class="mt-2 w-full h-28 bg-slate-800/50 rounded border border-slate-700/50 flex items-center justify-center overflow-hidden hidden"
>
<img
id="frame-preview-img"
src=""
class="h-full object-contain opacity-80"
/>
</div>
<!-- Thumbnail grid for frame selection (Phase 2.1) -->
<div
id="frame-thumbnails"
class="mt-2 flex flex-wrap gap-2 justify-end"
></div>
</div>
<!-- Frame Color Picker -->
<div class="p-2 rounded-lg hover:bg-slate-500/5 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-theme-secondary"
>لون الإطار</span
>
<div class="flex items-center gap-2">
<button
onclick="saveCurrentColor()"
class="text-xs bg-slate-700 hover:bg-slate-600 text-white px-2 py-1 rounded transition-colors"
title="حفظ اللون الحالي"
>
<i class="fa-solid fa-floppy-disk"></i>
</button>
<input
type="color"
id="frame-color"
value="#3c0000"
class="w-8 h-8 rounded cursor-pointer bg-transparent border-0 p-0"
/>
</div>
</div>
<div
id="saved-colors-container"
class="flex flex-wrap gap-2 min-h-[24px]"
>
<!-- Saved colors will appear here -->
</div>
<p class="text-[9px] text-theme-muted mt-1 text-center">
انقر لاختيار · × لحذف
</p>
</div>
</div>
<!-- Settings Panel (#5) - Always visible in side column? Or collapsible? Let's keep collapsible but cleaner -->
<div class="mb-6">
<button
onclick="toggleSettings()"
class="w-full justify-between flex items-center gap-2 text-xs text-theme-muted hover:text-yellow-500 transition-colors font-bold p-2 bg-slate-500/5 rounded-lg"
>
<div class="flex items-center gap-2">
<i class="fa-solid fa-sliders text-xs"></i>
<span>إعدادات متقدمة</span>
</div>
<i
class="fa-solid fa-chevron-down text-[10px] transition-transform"
id="settings-chevron"
></i>
</button>
<div id="settings-panel" class="settings-panel mt-2">
<div
class="p-3 rounded-lg border space-y-4 text-right"
style="
background: var(--bg-card-alt);
border-color: var(--border-card);
"
>
<!-- Restoration Fidelity -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="text-[10px] font-bold text-theme-muted"
>قوة الترميم (Fidelity)</label
>
<span
id="fidelity-val"
class="text-[10px] font-mono text-cyan-500"
>0.5</span
>
</div>
<input
type="range"
id="setting-fidelity"
min="0"
max="1"
step="0.1"
value="0.5"
class="w-full accent-cyan-500 h-1.5"
oninput="
document.getElementById('fidelity-val').textContent =
this.value
"
/>
<p class="text-[8px] text-theme-muted mt-1">
الأقل = ترميم أقوى، الأعلى = حفاظ على ملامح الأصل
</p>
</div>
<!-- Retouch Sensitivity -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="text-[10px] font-bold text-theme-muted"
>حساسية التجميل</label
>
<span
id="sensitivity-val"
class="text-[10px] font-mono text-yellow-500"
>3.0</span
>
</div>
<input
type="range"
id="setting-sensitivity"
min="1"
max="5"
step="0.5"
value="3.0"
class="w-full accent-yellow-500 h-1.5"
oninput="
document.getElementById('sensitivity-val').textContent =
this.value
"
/>
</div>
<!-- Tone Smoothing -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="text-[10px] font-bold text-theme-muted"
>نعومة البشرة</label
>
<span
id="smoothing-val"
class="text-[10px] font-mono text-yellow-500"
>0.6</span
>
</div>
<input
type="range"
id="setting-smoothing"
min="0"
max="1"
step="0.1"
value="0.6"
class="w-full accent-yellow-500 h-1.5"
oninput="
document.getElementById('smoothing-val').textContent =
parseFloat(this.value).toFixed(1)
"
/>
</div>
<!-- ID Font Size -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="text-[10px] font-bold text-theme-muted"
>حجم خط الرقم</label
>
<span
id="fontsize-val"
class="text-[10px] font-mono text-yellow-500"
>63</span
>
</div>
<input
type="range"
id="setting-fontsize"
min="30"
max="120"
step="1"
value="63"
class="w-full accent-yellow-500 h-1.5"
oninput="
document.getElementById('fontsize-val').textContent =
this.value
"
/>
</div>
<!-- Name Font Size -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="text-[10px] font-bold text-theme-muted"
>حجم خط الاسم</label
>
<span
id="namefontsize-val"
class="text-[10px] font-mono text-yellow-500"
>43</span
>
</div>
<input
type="range"
id="setting-namefontsize"
min="20"
max="80"
step="1"
value="43"
class="w-full accent-yellow-500 h-1.5"
oninput="
document.getElementById('namefontsize-val').textContent =
this.value
"
/>
</div>
<button
onclick="saveSettings()"
class="w-full py-2 font-bold text-xs rounded transition-all flex items-center justify-center gap-2"
style="background: var(--accent); color: #1a1a1a"
>
<i class="fa-solid fa-floppy-disk"></i> حفظ الإعدادات
</button>
<!-- Phase 3.5: Settings Live Preview -->
<div id="settings-live-preview">
<span class="text-[9px] text-theme-muted">معاينة مباشرة</span>
</div>
<!-- Backup Controls -->
<div class="flex gap-2 pt-2 border-t border-slate-600/30">
<button
onclick="exportSettings()"
class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-bold text-[10px] transition-all flex items-center justify-center gap-2"
>
<i class="fa-solid fa-file-export"></i> تصدير Backup
</button>
<button
onclick="document.getElementById('import-file').click()"
class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-bold text-[10px] transition-all flex items-center justify-center gap-2"
>
<i class="fa-solid fa-file-import"></i> استيراد
</button>
<input
type="file"
id="import-file"
class="hidden"
accept=".zip"
onchange="importSettings(this.files[0])"
/>
</div>
</div>
</div>
</div>
<!-- Actions List -->
<div class="flex flex-col gap-2">
<button
id="process-all-btn"
disabled
class="w-full disabled:opacity-50 disabled:cursor-not-allowed font-bold py-3.5 rounded-xl transition-all flex items-center justify-center gap-2 text-sm"
style="background: var(--accent); color: #1a1a1a"
>
<i class="fa-solid fa-play"></i> بدء معالجة الكل
</button>
<div class="grid grid-cols-2 gap-2 mt-2">
<!-- Re-process Single (#7) -->
<button
id="reprocess-btn"
disabled
onclick="reprocessCurrent()"
title="إعادة معالجة هذه الصورة"
aria-label="إعادة معالجة"
class="disabled:opacity-50 disabled:cursor-not-allowed font-bold py-2 rounded transition-all flex items-center justify-center gap-2 text-xs"
style="background: var(--accent); color: #1a1a1a; opacity: 0.85"
>
<i class="fa-solid fa-rotate"></i> إعادة
</button>
<button
id="edit-crop-btn"
disabled
aria-label="قص الصورة"
title="قص"
class="disabled:opacity-40 disabled:cursor-not-allowed font-bold py-2 rounded transition-all flex items-center justify-center gap-2 text-xs"
style="
background: var(--bg-input);
color: var(--text-secondary);
"
>
<i class="fa-solid fa-crop-simple"></i> قص
</button>
</div>
<div class="grid grid-cols-2 gap-2 mt-1">
<button
id="save-btn"
disabled
title="تحميل الصورة"
class="disabled:opacity-40 disabled:cursor-not-allowed font-bold py-2 rounded transition-all flex items-center justify-center gap-2 text-xs"
style="background: var(--accent); color: #1a1a1a"
>
<i class="fa-solid fa-download"></i> تحميل
</button>
<button
id="download-all-btn"
disabled
title="تحميل الكل ك ZIP"
class="disabled:opacity-40 disabled:cursor-not-allowed font-bold py-2 rounded transition-all flex items-center justify-center gap-2 text-xs"
style="background: var(--info); color: white"
>
<i class="fa-solid fa-file-zipper"></i> تحميل الكل
</button>
</div>
<button
id="clear-all-btn"
title="حذف الكل"
class="mt-3 w-full font-bold py-2 rounded transition-all text-xs"
style="
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
"
>
<i class="fa-solid fa-trash-can ml-1"></i> حذف جميع الصور
</button>
</div>
</div>
</div>
</main>
<!-- UX7: Mobile Sticky Action Bar -->
<div class="mobile-sticky-bar" id="mobile-sticky-bar">
<button
id="mobile-process-btn"
disabled
class="flex-1 disabled:opacity-50 disabled:cursor-not-allowed font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 text-sm"
style="background: var(--accent); color: #1a1a1a"
>
<i class="fa-solid fa-play"></i> بدء المعالجة
</button>
<button
id="mobile-download-btn"
disabled
class="disabled:opacity-40 disabled:cursor-not-allowed font-bold py-3 px-4 rounded-xl transition-all flex items-center justify-center gap-2 text-xs"
style="background: var(--info); color: white"
>
<i class="fa-solid fa-file-zipper"></i>
</button>
</div>
<script>
// ══════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════
let imageData = [];
let currentIndex = -1;
let cropper = null;
let isAIReady = false;
let showingOriginal = false; // Before/After state
let batchCancelled = false; // For Phase 1.2: batch cancel flag
let lastAction = null; // For Phase 3.6: undo system
let undoTimer = null; // For Phase 3.6: undo toast auto-dismiss
let livePreviewDebounce = null; // Phase 3.5: settings live preview debounce
// Per-image countdown timers
let processingStartTime = null; // timestamp when current image started
let countdownTarget = null; // seconds — estimated total for current image
let countdownInterval = null; // interval handle for countdown updates
let avgProcessingTime = 100000; // ms — starts at ~100s default
let currentController = null; // AbortController for in-flight request
// ══════════════════════════════════════════════════
// UI ELEMENTS
// ══════════════════════════════════════════════════
const fileInput = document.getElementById("file-input");
const imageList = document.getElementById("image-list");
const aiStatus = document.getElementById("ai-status");
const processAllBtn = document.getElementById("process-all-btn");
const downloadAllBtn = document.getElementById("download-all-btn");
const clearAllBtn = document.getElementById("clear-all-btn");
const editBtn = document.getElementById("edit-crop-btn");
const saveBtn = document.getElementById("save-btn");
const reprocessBtn = document.getElementById("reprocess-btn");
const mainPreview = document.getElementById("main-preview");
const previewPlaceholder = document.getElementById("preview-placeholder");
const previewContainer = document.getElementById("preview-container");
const cropperContainer = document.getElementById("cropper-container");
const cropperImg = document.getElementById("cropper-img");
const zoomHint = document.getElementById("zoom-hint");
// ══════════════════════════════════════════════════
// THEME TOGGLE (#11)
// ══════════════════════════════════════════════════
function initTheme() {
const saved = localStorage.getItem("studio-theme") || "dark";
document.documentElement.setAttribute("data-theme", saved);
updateThemeIcon(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("studio-theme", next);
updateThemeIcon(next);
}
function updateThemeIcon(theme) {
const icon = document.getElementById("theme-icon");
if (theme === "dark") {
icon.className = "fa-solid fa-sun text-yellow-400 text-sm";
} else {
icon.className = "fa-solid fa-moon text-slate-600 text-sm";
}
}
initTheme();
// ══════════════════════════════════════════════════
// TOAST NOTIFICATIONS
// ══════════════════════════════════════════════════
function showToast(message, type = "info", duration = 4000) {
const container = document.getElementById("toast-container");
const toast = document.createElement("div");
const icons = {
success: "fa-circle-check",
error: "fa-circle-exclamation",
info: "fa-circle-info",
warning: "fa-triangle-exclamation",
};
toast.className = `toast toast-${type}`;
toast.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = "toastOut 0.3s ease-in forwards";
setTimeout(() => toast.remove(), 300);
}, duration);
}
// ══════════════════════════════════════════════════
// SETTINGS PANEL (#5)
// ══════════════════════════════════════════════════
function toggleSettings() {
const panel = document.getElementById("settings-panel");
const chevron = document.getElementById("settings-chevron");
panel.classList.toggle("open");
chevron.style.transform = panel.classList.contains("open")
? "rotate(180deg)"
: "";
}
async function loadSettingsFromServer() {
try {
const res = await fetch("/settings");
const data = await res.json();
if (data.restoration && data.restoration.fidelity !== undefined) {
document.getElementById("setting-fidelity").value =
data.restoration.fidelity;
document.getElementById("fidelity-val").textContent = parseFloat(
data.restoration.fidelity,
).toFixed(1);
}
if (data.retouch) {
if (data.retouch.sensitivity !== undefined) {
document.getElementById("setting-sensitivity").value =
data.retouch.sensitivity;
document.getElementById("sensitivity-val").textContent =
data.retouch.sensitivity;
}
if (data.retouch.tone_smoothing !== undefined) {
document.getElementById("setting-smoothing").value =
data.retouch.tone_smoothing;
document.getElementById("smoothing-val").textContent = parseFloat(
data.retouch.tone_smoothing,
).toFixed(1);
}
}
if (data.overlays) {
if (data.overlays.id_font_size !== undefined) {
document.getElementById("setting-fontsize").value =
data.overlays.id_font_size;
document.getElementById("fontsize-val").textContent =
data.overlays.id_font_size;
}
if (data.overlays.name_font_size !== undefined) {
document.getElementById("setting-namefontsize").value =
data.overlays.name_font_size;
document.getElementById("namefontsize-val").textContent =
data.overlays.name_font_size;
}
}
} catch (e) {
console.warn("Could not load settings", e);
}
}
async function saveSettings() {
const payload = {
restoration: {
fidelity: parseFloat(
document.getElementById("setting-fidelity").value,
),
},
retouch: {
sensitivity: parseFloat(
document.getElementById("setting-sensitivity").value,
),
tone_smoothing: parseFloat(
document.getElementById("setting-smoothing").value,
),
},
overlays: {
id_font_size: parseInt(
document.getElementById("setting-fontsize").value,
),
name_font_size: parseInt(
document.getElementById("setting-namefontsize").value,
),
},
};
try {
const res = await fetch("/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.status === "success") {
showToast("تم حفظ الإعدادات بنجاح", "success");
} else {
showToast("فشل حفظ الإعدادات", "error");
}
} catch (e) {
showToast("خطأ في الاتصال بالسيرفر", "error");
}
}
async function exportSettings() {
// Gather client-side data
const clientData = {
theme: localStorage.getItem("studio-theme"),
savedColors: JSON.parse(
localStorage.getItem("studio-saved-colors") || "[]",
),
};
try {
showToast("جاري تحضير ملف النسخة الاحتياطية...", "info");
const res = await fetch("/backup/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(clientData),
});
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `studio_backup_${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
showToast("تم تنزيل النسخة الاحتياطية", "success");
} else {
showToast("فشل التصدير", "error");
}
} catch (e) {
showToast("خطأ في الاتصال", "error");
}
}
async function importSettings(file) {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
if (
!confirm(
"سيتم استبدال جميع الإعدادات الحالية والإطارات بالنسخة المستوردة. هل أنت متأكد؟",
)
) {
document.getElementById("import-file").value = ""; // Reset
return;
}
try {
showToast("جاري استعادة النسخة الاحتياطية...", "info");
const res = await fetch("/backup/import", {
method: "POST",
body: fd,
});
if (res.ok) {
const data = await res.json();
// Restore client data
if (data.client_data) {
if (data.client_data.theme)
localStorage.setItem("studio-theme", data.client_data.theme);
if (data.client_data.savedColors)
localStorage.setItem(
"studio-saved-colors",
JSON.stringify(data.client_data.savedColors),
);
}
showToast("تم الاستعادة بنجاح! سيتم تحديث الصفحة...", "success");
setTimeout(() => window.location.reload(), 1500);
} else {
const err = await res.json();
showToast(err.error || "فشل الاستيراد", "error");
}
} catch (e) {
showToast("خطأ في الاتصال", "error");
}
document.getElementById("import-file").value = ""; // Reset
}
// Phase 3.5: Debounced live preview for settings sliders
function attachLivePreview() {
const sliders = [
"setting-fidelity",
"setting-sensitivity",
"setting-smoothing",
"setting-fontsize",
"setting-namefontsize",
];
sliders.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener("input", () => {
clearTimeout(livePreviewDebounce);
livePreviewDebounce = setTimeout(() => {
updateSettingsLivePreview();
}, 300);
});
}
});
}
function updateSettingsLivePreview() {
const preview = document.getElementById("settings-live-preview");
const currentData = imageData[currentIndex];
if (!currentData || !currentData.result_url) {
preview.innerHTML =
'<span class="text-[9px] text-theme-muted">اختر صورة لمعاينتها</span>';
return;
}
preview.innerHTML = `<img src="${currentData.result_url}?v=${currentData.version}" alt="معاينة" style="opacity:0.85;">`;
}
function toggleColorMethodContainer() {
const stepColor = document.getElementById("step-color");
const container = document.getElementById("color-method-container");
if (stepColor && container) {
container.style.display = stepColor.checked ? "flex" : "none";
}
}
toggleColorMethodContainer();
loadSettingsFromServer();
setTimeout(attachLivePreview, 500);
// ══════════════════════════════════════════════════
// MOBILE DRAWER (#10)
// ══════════════════════════════════════════════════
function toggleDrawer(open) {
document
.getElementById("drawer-overlay")
.classList.toggle("active", open);
document
.getElementById("mobile-drawer")
.classList.toggle("active", open);
}
const frameSelect = document.getElementById("frame-select");
const framePreviewBox = document.getElementById("frame-preview-box");
const framePreviewImg = document.getElementById("frame-preview-img");
// Frame Logic
async function loadFrames() {
try {
const res = await fetch("/frames");
const data = await res.json();
const current = frameSelect.value;
frameSelect.innerHTML = '<option value="">(بدون إطار)</option>';
data.frames.forEach((f) => {
const opt = document.createElement("option");
opt.value = f.filename;
opt.textContent = f.filename;
frameSelect.appendChild(opt);
});
frameSelect.value = current;
renderFrameThumbnails(data.frames, current);
updateFramePreview();
} catch (e) {
console.error("Failed to load frames", e);
}
}
// Phase 2.1: Render thumbnail grid for frame selection
function renderFrameThumbnails(frames, selectedFilename) {
const container = document.getElementById("frame-thumbnails");
if (!frames || frames.length === 0) {
container.innerHTML = "";
return;
}
container.innerHTML = frames
.map(
(f) => `
<div onclick="selectFrame('${f.filename}')" class="frame-thumb ${selectedFilename === f.filename ? "frame-thumb-selected" : ""}" title="${f.filename}">
<img src="/assets/${f.filename}" alt="${f.filename}" loading="lazy">
</div>
`,
)
.join("");
}
function selectFrame(filename) {
frameSelect.value = filename;
updateFramePreview();
renderFrameThumbnails(
Array.from(frameSelect.options)
.slice(1)
.map((o) => ({ filename: o.value })),
filename,
);
}
frameSelect.onchange = () => {
const val = frameSelect.value;
renderFrameThumbnails(
Array.from(frameSelect.options)
.slice(1)
.map((o) => ({ filename: o.value })),
val,
);
updateFramePreview();
};
function updateFramePreview() {
const val = frameSelect.value;
if (val) {
framePreviewBox.classList.remove("hidden");
framePreviewImg.src = `/assets/${val}`;
} else {
framePreviewBox.classList.add("hidden");
}
}
async function uploadFrame(file) {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
try {
showToast("جاري رفع الإطار...", "info");
const res = await fetch("/frames", { method: "POST", body: fd });
if (res.ok) {
showToast("تم رفع الإطار بنجاح", "success");
await loadFrames();
const data = await res.json();
if (data.frame) {
frameSelect.value = data.frame.filename; // Select new frame
updateFramePreview();
}
} else {
showToast("فشل الرفع", "error");
}
} catch (e) {
showToast("خطأ في الاتصال", "error");
}
}
async function deleteFrame() {
const val = frameSelect.value;
if (!val) return showToast("لم يتم تحديد إطار للحذف", "warning");
if (!confirm(`هل أنت متأكد من حذف الإطار "${val}"؟`)) return;
try {
const res = await fetch(`/frames/${val}`, { method: "DELETE" });
if (res.ok) {
showToast("تم الحذف بنجاح", "success");
await loadFrames();
// Clear selection if it was deleted (loadFrames keeps current if possible, but if deleted it falls back to empty)
if (frameSelect.value === val) frameSelect.value = "";
updateFramePreview();
} else {
const err = await res.json();
showToast(err.error || "فشل الحذف", "error");
}
} catch (e) {
showToast("خطأ في الاتصال", "error");
}
}
// ══════════════════════════════════════════════════
// SAVED COLORS LOGIC
// ══════════════════════════════════════════════════
function loadSavedColors() {
const container = document.getElementById("saved-colors-container");
const saved = JSON.parse(
localStorage.getItem("studio-saved-colors") || "[]",
);
container.innerHTML = "";
// Add Default Maroon
const defaultColor = "#3c0000";
const defDiv = document.createElement("div");
defDiv.className =
"w-6 h-6 rounded-full cursor-pointer border border-slate-500 hover:scale-110 transition-transform bg-[#3c0000] relative group";
defDiv.onclick = () => {
document.getElementById("frame-color").value = defaultColor;
};
defDiv.title = "Default Maroon";
container.appendChild(defDiv);
saved.forEach((color) => {
// Phase 2.4: visible × delete button on hover
const div = document.createElement("div");
div.className =
"w-6 h-6 rounded-full cursor-pointer border border-slate-500 hover:scale-110 transition-transform relative color-swatch";
div.style.backgroundColor = color;
div.onclick = () => {
document.getElementById("frame-color").value = color;
};
// Visible delete button on hover
const delBtn = document.createElement("button");
delBtn.className = "color-delete-btn";
delBtn.innerHTML = '<i class="fa-solid fa-xmark text-[6px]"></i>';
delBtn.onclick = (e) => {
e.stopPropagation();
deleteColor(color);
};
delBtn.title = "حذف اللون";
div.appendChild(delBtn);
container.appendChild(div);
});
}
function saveCurrentColor() {
const color = document.getElementById("frame-color").value;
let saved = JSON.parse(
localStorage.getItem("studio-saved-colors") || "[]",
);
if (!saved.includes(color) && color !== "#3c0000") {
saved.push(color);
localStorage.setItem("studio-saved-colors", JSON.stringify(saved));
loadSavedColors();
showToast("تم حفظ اللون", "success");
} else if (color === "#3c0000") {
showToast("هذا هو اللون الافتراضي", "info");
} else {
showToast("اللون محفوظ مسبقاً", "warning");
}
}
function deleteColor(colorToDelete) {
let saved = JSON.parse(
localStorage.getItem("studio-saved-colors") || "[]",
);
saved = saved.filter((c) => c !== colorToDelete);
localStorage.setItem("studio-saved-colors", JSON.stringify(saved));
loadSavedColors();
}
loadSavedColors();
loadFrames(); // Initial load
// ══════════════════════════════════════════════════
// AI STATUS CHECK
// ══════════════════════════════════════════════════
async function checkStatus() {
try {
const res = await fetch("/status");
const data = await res.json();
if (data.ai_ready) {
isAIReady = true;
// Phase 2.2: Detailed AI status with model breakdown
const models = data.models || [];
const modelBadges = models
.map((m) => {
if (m.ready) {
return `<span class="inline-flex items-center gap-1" style="color: var(--success)" title="${m.name} جاهز">
<span class="w-1.5 h-1.5 rounded-full" style="background: var(--success)"></span>${m.name}
</span>`;
} else {
return `<span class="inline-flex items-center gap-1" style="color: var(--danger)" title="${m.name}${m.error || "خطأ"}">
<span class="w-1.5 h-1.5 rounded-full" style="background: var(--danger)"></span>${m.name}
</span>`;
}
})
.join('<span class="text-slate-600 mx-1">|</span>');
aiStatus.innerHTML = `
<div class="w-3 h-3 rounded-full" style="background: var(--success)"></div>
<span class="hidden sm:inline">AI جاهز</span>
<span class="sm:hidden">✓</span>
${modelBadges ? `<span class="hidden md:inline text-[10px] mx-1" style="color: var(--text-muted)">|</span><span class="hidden md:inline text-[10px]" style="color: var(--text-muted)">${modelBadges}</span>` : ""}
`;
aiStatus.className =
"flex items-center gap-2 px-4 py-2 rounded-full text-sm flex-wrap";
aiStatus.style.background = "rgba(39,174,96,0.15)";
aiStatus.style.border = "1px solid rgba(39,174,96,0.3)";
aiStatus.style.color = "#55d493";
} else {
isAIReady = false;
aiStatus.innerHTML = `
<div class="w-3 h-3 rounded-full animate-pulse" style="background: var(--accent)"></div>
<span class="hidden sm:inline">جاري التحميل...</span>
<span class="sm:hidden">⏳</span>
`;
aiStatus.className =
"flex items-center gap-2 px-4 py-2 rounded-full text-sm flex-wrap";
aiStatus.style.background = "var(--accent-dim)";
aiStatus.style.border = "1px solid rgba(212,168,67,0.3)";
aiStatus.style.color = "var(--accent)";
}
} catch (e) {
isAIReady = false;
aiStatus.innerHTML = `
<div class="w-3 h-3 rounded-full" style="background: var(--danger)"></div>
<span class="hidden sm:inline" style="color: var(--danger)">خطأ في الاتصال</span>
<span class="sm:hidden" style="color: var(--danger)">✗</span>
`;
aiStatus.className =
"flex items-center gap-2 px-4 py-2 rounded-full text-sm flex-wrap";
aiStatus.style.background = "rgba(192,57,43,0.15)";
aiStatus.style.border = "1px solid rgba(192,57,43,0.3)";
aiStatus.style.color = "var(--danger)";
}
}
setInterval(checkStatus, 2000);
// ══════════════════════════════════════════════════
// UPLOAD & QUEUE
// ══════════════════════════════════════════════════
const dropZone = document.getElementById("queue-card");
if (dropZone) {
["dragenter", "dragover"].forEach((name) => {
dropZone.addEventListener(
name,
(e) => {
e.preventDefault();
dropZone.classList.add("drop-glow");
},
false,
);
});
["dragleave", "drop"].forEach((name) => {
dropZone.addEventListener(
name,
(e) => {
e.preventDefault();
dropZone.classList.remove("drop-glow");
},
false,
);
});
dropZone.addEventListener(
"drop",
(e) => {
handleFiles(e.dataTransfer.files);
},
false,
);
}
fileInput.onchange = (e) => handleFiles(e.target.files);
function uploadFileWithProgress(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
const wrappers = [
document.getElementById("upload-progress-wrapper"),
document.getElementById("upload-progress-wrapper-mobile"),
];
const fills = [
document.getElementById("upload-progress-fill"),
document.getElementById("upload-progress-fill-mobile"),
];
const texts = [
document.getElementById("upload-progress-text"),
document.getElementById("upload-progress-text-mobile"),
];
wrappers.forEach((w) => {
if (w) w.classList.remove("hidden");
});
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
fills.forEach((f) => {
if (f) f.style.width = pct + "%";
});
texts.forEach((t) => {
if (t) t.textContent = `جاري الرفع... ${pct}%`;
});
}
};
xhr.onload = () => {
wrappers.forEach((w) => {
if (w) w.classList.add("hidden");
});
fills.forEach((f) => {
if (f) f.style.width = "0%";
});
if (xhr.status >= 200 && xhr.status < 300)
resolve(JSON.parse(xhr.responseText));
else reject(new Error(`Upload failed: ${xhr.status}`));
};
xhr.onerror = () => {
wrappers.forEach((w) => {
if (w) w.classList.add("hidden");
});
reject(new Error("Network error"));
};
xhr.open("POST", "/upload");
xhr.send(formData);
});
}
async function handleFiles(files) {
for (let file of files) {
try {
const data = await uploadFileWithProgress(file);
imageData.push({
...data,
name: "",
id_num: "",
result_url: null,
preview_url: null,
version: 1,
custom_crop: null,
steps: null,
status: "waiting",
error_msg: null,
});
renderQueue();
if (currentIndex === -1) selectImage(imageData.length - 1);
} catch (e) {
// Phase 1.3: Add failed upload to queue with retry option
const tempId =
"failed_" +
Date.now() +
"_" +
Math.random().toString(36).substr(2, 9);
imageData.push({
id: tempId,
filename: file.name,
thumb_url: null,
width: 0,
height: 0,
name: "",
id_num: "",
result_url: null,
preview_url: null,
version: 1,
custom_crop: null,
steps: null,
status: "upload-failed",
error_msg: e.message || "فشل الرفع",
_file: file, // store file reference for retry
});
renderQueue();
showToast(
`فشل رفع ${file.name} — اضغط 🔄 لإعادة المحاولة`,
"error",
6000,
);
}
}
fileInput.value = "";
}
// Phase 1.1 + 1.3: Retry failed upload or re-process failed image
async function retryImage(idx) {
const data = imageData[idx];
if (!data) return;
if (data.status === "upload-failed" && data._file) {
// Retry upload (Phase 1.3)
const file = data._file;
data.status = "processing";
data.error_msg = null;
renderQueue();
try {
const uploaded = await uploadFileWithProgress(file);
// Merge server response into existing entry
imageData[idx] = {
...uploaded,
name: data.name,
id_num: data.id_num,
result_url: null,
preview_url: null,
version: 1,
custom_crop: null,
steps: null,
status: "waiting",
error_msg: null,
};
showToast(`تم رفع ${file.name} بنجاح`, "success");
} catch (e) {
imageData[idx].status = "upload-failed";
imageData[idx].error_msg = e.message || "فشل الرفع مجدداً";
showToast(`فشل رفع ${file.name} مجدداً`, "error", 4000);
}
} else if (data.status === "error") {
// Retry processing (Phase 1.1)
data.result_url = null;
data.status = "waiting";
renderQueue();
await runPipelineForIndex(idx, !!data.custom_crop);
selectImage(idx);
return;
}
renderQueue();
}
function getQualityBadge(width, height) {
const megapixels = (width * height) / 1000000;
const minDim = Math.min(width, height);
if (minDim >= 1500 || megapixels >= 3)
return '<span class="badge-quality badge-hd">HD</span>';
if (minDim >= 800 || megapixels >= 1)
return '<span class="badge-quality badge-mid">OK</span>';
return '<span class="badge-quality badge-lowres" title="جودة منخفضة">⚠ LOW</span>';
}
// ══════════════════════════════════════════════════
// QUEUE RENDER - WITH DELETE BUTTON
// ══════════════════════════════════════════════════
function renderQueue() {
const count = imageData.length;
document.getElementById("queue-count").innerText = `${count} صور`;
document.getElementById("nav-counter").innerText =
count > 0 ? `${currentIndex + 1}/${count}` : "0/0";
const mobileCount = document.getElementById("mobile-queue-count");
if (mobileCount) mobileCount.innerText = count;
if (count === 0) {
const emptyHTML = `
<div class="text-center py-12">
<div class="float-anim inline-block mb-4" style="opacity: 0.35">
<i class="fa-solid fa-layer-group text-3xl" style="color: var(--text-muted)"></i>
</div>
<p class="text-theme-muted text-sm font-medium">القائمة فارغة</p>
</div>`;
imageList.innerHTML = emptyHTML;
const mobileList = document.getElementById("mobile-image-list");
if (mobileList)
mobileList.innerHTML = `<div class="text-center py-10 text-theme-muted italic text-sm">القائمة فارغة</div>`;
processAllBtn.disabled = true;
downloadAllBtn.disabled = true;
editBtn.disabled = true;
saveBtn.disabled = true;
reprocessBtn.disabled = true;
return;
}
const html = imageData
.map((img, idx) => {
let statusIcon = "";
if (img.status === "waiting")
statusIcon = '<i class="fa-regular fa-clock text-slate-500"></i>';
else if (img.status === "processing")
statusIcon =
'<i class="fa-solid fa-gear processing-spin text-yellow-500"></i>';
else if (img.status === "done")
statusIcon =
'<i class="fa-solid fa-circle-check text-emerald-500"></i>';
else if (img.status === "error")
statusIcon =
'<i class="fa-solid fa-circle-exclamation text-rose-500"></i>';
else if (img.status === "upload-failed")
statusIcon =
'<i class="fa-solid fa-triangle-exclamation text-orange-500"></i>';
// Error tooltip (Phase 1.1)
const errorTooltip =
img.status === "error" && img.error_msg
? `<div class="slide-error-msg">${img.error_msg}</div>`
: "";
// Retry button (Phase 1.1)
const retryBtn =
img.status === "error" || img.status === "upload-failed"
? `<button onclick="event.stopPropagation(); retryImage(${idx})" class="slide-retry" title="إعادة المحاولة"><i class="fa-solid fa-rotate-right"></i></button>`
: "";
// Queue slide extra class for error state
const slideClass =
img.status === "error" || img.status === "upload-failed"
? "has-error"
: "";
// Per-image countdown display
const isProcessing = img.status === "processing";
const countdownEl = `<span class="slide-countdown" id="countdown-${idx}" ${isProcessing ? 'style="display: block;"' : ""}></span>`;
// UX6: Status class for bottom-border coloring
const statusClass = img.status ? `status-${img.status}` : '';
return `
<div onclick="selectImage(${idx})" class="queue-slide ${currentIndex === idx ? "active" : ""} ${slideClass} ${statusClass}">
${
img.thumb_url
? `<img src="${img.preview_url ? img.preview_url + "?v=" + img.version : img.thumb_url}" alt="">`
: `<div class="w-[72px] h-[72px] rounded-lg bg-slate-800 flex items-center justify-center border border-slate-700">
<i class="fa-solid fa-triangle-exclamation text-orange-500 text-xl"></i>
</div>`
}
<div class="slide-status">${statusIcon}</div>
${errorTooltip}
${retryBtn}
<button onclick="deleteImage(event, ${idx})" class="slide-delete" title="حذف"><i class="fa-solid fa-xmark"></i></button>
<div class="slide-name">${img.filename}</div>
${countdownEl}
</div>
`;
})
.join("");
imageList.innerHTML = html;
const mobileList = document.getElementById("mobile-image-list");
if (mobileList) mobileList.innerHTML = html;
// Auto-scroll to the active slide
const activeSlide = imageList.querySelector(".queue-slide.active");
if (activeSlide)
activeSlide.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
processAllBtn.disabled = count === 0;
const processedCount = imageData.filter((d) => d.result_url).length;
downloadAllBtn.disabled = processedCount === 0;
// UX2: Pulse attention on process button when unprocessed images exist
const unprocessedCount = imageData.filter((d) => !d.result_url && d.status !== 'error' && d.status !== 'upload-failed').length;
processAllBtn.classList.toggle('pulse-attention', unprocessedCount > 0 && count > 0);
// UX7: Sync mobile sticky bar
const mobileStickyBar = document.getElementById('mobile-sticky-bar');
const mobileProcessBtn = document.getElementById('mobile-process-btn');
const mobileDownloadBtn = document.getElementById('mobile-download-btn');
if (mobileStickyBar) {
mobileStickyBar.classList.toggle('has-images', count > 0);
}
if (mobileProcessBtn) {
mobileProcessBtn.disabled = count === 0 || unprocessedCount === 0;
mobileProcessBtn.onclick = processAllBtn.onclick;
}
if (mobileDownloadBtn) {
mobileDownloadBtn.disabled = processedCount === 0;
mobileDownloadBtn.onclick = downloadAllBtn.onclick;
}
// UX4: Update processing overlay for current image
updatePreviewProcessingOverlay();
// Ensure any currently processing image has its countdown text immediately
// injected after DOM rebuild to avoid flickering or empty states
imageData.forEach((img, idx) => {
if (img.status === "processing") updateCountdowns(idx);
});
}
// ══════════════════════════════════════════════════
// SELECTION & NAVIGATION
// ══════════════════════════════════════════════════
function navigate(direction) {
if (imageData.length === 0) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < imageData.length)
selectImage(nextIndex);
}
function selectImage(idx) {
saveCurrentFields();
currentIndex = idx;
showingOriginal = false;
const data = imageData[idx];
document.getElementById("student-name").value = data.name;
document.getElementById("student-id").value = data.id_num;
document.getElementById("current-filename").innerText = data.filename;
zoomHint.classList.toggle(
"hidden",
!data.result_url && !data.thumb_url,
);
reprocessBtn.disabled = !data.result_url;
const previewSkeleton = document.getElementById("preview-skeleton");
previewPlaceholder.classList.add("hidden");
if (data.result_url) {
// Processed image: Show in main preview
const afterUrl = data.preview_url
? data.preview_url + "?v=" + data.version
: data.result_url + "?v=" + data.version;
const tempImg = new Image();
tempImg.onload = () => {
mainPreview.src = tempImg.src;
mainPreview.classList.remove("hidden");
mainPreview.classList.remove("opacity-0");
mainPreview.classList.add("opacity-100");
previewSkeleton.classList.add("hidden");
};
tempImg.onerror = () => {
console.error("Failed to load processed preview:", afterUrl);
previewSkeleton.classList.add("hidden");
};
tempImg.src = afterUrl;
} else {
// Unprocessed image: Show original thumb preview
if (!data.thumb_url) {
previewSkeleton.classList.remove("hidden");
mainPreview.classList.add("hidden");
} else {
previewSkeleton.classList.add("hidden");
const tempImg = new Image();
tempImg.onload = () => {
mainPreview.src = tempImg.src;
mainPreview.classList.remove("hidden");
mainPreview.classList.remove("opacity-0");
mainPreview.classList.add("opacity-100");
previewSkeleton.classList.add("hidden");
};
tempImg.onerror = () => {
console.error("Failed to load preview:", data.thumb_url);
previewSkeleton.classList.add("hidden");
};
tempImg.src = data.thumb_url;
}
}
editBtn.disabled = false;
saveBtn.disabled = !data.result_url;
// UX4: Show/hide processing overlay for this image
updatePreviewProcessingOverlay();
renderQueue();
toggleDrawer(false);
}
function saveCurrentFields() {
if (currentIndex === -1) return;
imageData[currentIndex].name =
document.getElementById("student-name").value;
imageData[currentIndex].id_num =
document.getElementById("student-id").value;
}
// ══════════════════════════════════════════════════
// UX4: PROCESSING OVERLAY ON PREVIEW
// ══════════════════════════════════════════════════
function updatePreviewProcessingOverlay() {
const overlay = document.getElementById('preview-processing-overlay');
const filenameEl = document.getElementById('preview-processing-filename');
if (!overlay) return;
const currentData = currentIndex >= 0 ? imageData[currentIndex] : null;
if (currentData && currentData.status === 'processing') {
overlay.classList.add('active');
if (filenameEl) filenameEl.textContent = currentData.filename;
} else {
overlay.classList.remove('active');
}
}
// ══════════════════════════════════════════════════
// UX1: PREVIEW DROP ZONE (GUIDED EMPTY STATE)
// ══════════════════════════════════════════════════
(function setupPreviewDropZone() {
const previewDropZone = document.getElementById('preview-drop-zone');
if (!previewDropZone) return;
['dragenter', 'dragover'].forEach(name => {
previewDropZone.addEventListener(name, (e) => {
e.preventDefault();
previewDropZone.classList.add('drop-glow');
}, false);
});
['dragleave', 'drop'].forEach(name => {
previewDropZone.addEventListener(name, (e) => {
e.preventDefault();
previewDropZone.classList.remove('drop-glow');
}, false);
});
previewDropZone.addEventListener('drop', (e) => {
handleFiles(e.dataTransfer.files);
}, false);
})();
// ══════════════════════════════════════════════════
// PHASE 3.4: DIRECT IMAGE JUMP
// ══════════════════════════════════════════════════
function openJumpPopover(e) {
if (imageData.length === 0) return;
e.stopPropagation();
const popover = document.getElementById("jump-popover");
const input = document.getElementById("jump-input");
document.getElementById("jump-total").textContent = imageData.length;
input.max = imageData.length;
input.value = currentIndex + 1;
popover.classList.add("active");
setTimeout(() => input.focus(), 50);
}
function closeJumpPopover() {
document.getElementById("jump-popover").classList.remove("active");
}
function doJump() {
const val = parseInt(document.getElementById("jump-input").value);
if (isNaN(val) || val < 1 || val > imageData.length) {
showToast("رقم غير صحيح", "warning");
return;
}
closeJumpPopover();
selectImage(val - 1);
}
// ══════════════════════════════════════════════════
// PHASE 3.6: UNDO SYSTEM
// ══════════════════════════════════════════════════
function saveUndoState(actionLabel) {
// Snapshot current state
lastAction = {
label: actionLabel,
snapshot: JSON.parse(JSON.stringify(imageData)),
currentIndex: currentIndex,
};
showUndoToast(actionLabel);
}
function showUndoToast(actionLabel) {
// Remove any existing undo toast
const existing = document.querySelector(".undo-toast");
if (existing) existing.remove();
if (undoTimer) clearTimeout(undoTimer);
const container = document.getElementById("toast-container");
const toast = document.createElement("div");
toast.className = "undo-toast";
toast.innerHTML = `
<i class="fa-solid fa-rotate-left text-yellow-400"></i>
<span>${actionLabel}</span>
<button class="undo-btn" onclick="performUndo()">تراجع</button>
`;
container.appendChild(toast);
undoTimer = setTimeout(() => {
toast.style.animation = "toastOut 0.3s ease-in forwards";
setTimeout(() => toast.remove(), 300);
lastAction = null;
}, 5000);
}
function performUndo() {
if (!lastAction) return;
imageData = lastAction.snapshot;
currentIndex = Math.min(lastAction.currentIndex, imageData.length - 1);
if (imageData.length === 0) currentIndex = -1;
renderQueue();
if (currentIndex !== -1) selectImage(currentIndex);
document.querySelector(".undo-toast")?.remove();
if (undoTimer) clearTimeout(undoTimer);
lastAction = null;
showToast("تم التراجع", "info");
}
// ══════════════════════════════════════════════════
// ZOOM
// ══════════════════════════════════════════════════
function openZoom() {
const src = mainPreview.src;
if (!src) return;
const modal = document.getElementById("zoom-modal");
const zoomImg = document.getElementById("zoom-img");
zoomImg.src = src;
zoomImg.style.transform = "scale(1)";
modal.classList.add("active");
modal.onwheel = (e) => {
e.preventDefault();
const current =
parseFloat(
zoomImg.style.transform.replace("scale(", "").replace(")", ""),
) || 1;
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const next = Math.max(0.5, Math.min(5, current + delta));
zoomImg.style.transform = `scale(${next})`;
};
}
function closeZoom() {
document.getElementById("zoom-modal").classList.remove("active");
}
// ══════════════════════════════════════════════════
// PROCESSING
// ══════════════════════════════════════════════════
function cancelBatch() {
batchCancelled = true;
// Abort the in-flight fetch request immediately
if (currentController) {
currentController.abort();
currentController = null;
// Show cancelling state on overlay
const counter = document.getElementById("batch-counter");
counter.textContent = counter.textContent + " ✕";
document.getElementById("batch-cancel-btn").disabled = true;
document.getElementById("batch-cancel-btn").innerHTML =
'<i class="fa-solid fa-hourglass-half animate-spin"></i> إلغاء...';
}
showToast("تم إلغاء المعالجة", "warning");
}
processAllBtn.onclick = async () => {
if (!isAIReady) return showToast("انتظر تحميل المحرك...", "warning");
saveCurrentFields();
processAllBtn.disabled = true;
fileInput.disabled = true;
batchCancelled = false;
const toProcess = imageData.filter((d) => !d.result_url);
const total = toProcess.length;
if (total === 0) {
processAllBtn.disabled = false;
fileInput.disabled = false;
return;
}
// Show batch overlay with counter
const overlay = document.getElementById("batch-overlay");
const counter = document.getElementById("batch-counter");
overlay.classList.replace("hidden", "flex");
let done = 0;
counter.textContent = `0/${total}`;
for (let i = 0; i < imageData.length; i++) {
if (batchCancelled) break; // Phase 1.2: honour cancel
if (imageData[i].result_url) continue;
await runPipelineForIndex(i);
done++;
counter.textContent = `${done}/${total}`;
}
// Hide overlay
overlay.classList.replace("flex", "hidden");
document.getElementById("batch-eta").classList.add("hidden");
processAllBtn.disabled = false;
fileInput.disabled = false;
// Refresh preview to show result of currently selected image
if (currentIndex !== -1) selectImage(currentIndex);
const processed =
done - imageData.filter((d) => d.status === "error").length;
showToast(
batchCancelled
? `تم إلغاء المعالجة (${done} من ${total})`
: `تمت معالجة ${processed} صورة`,
"success",
);
};
async function runPipelineForIndex(idx, isCustom = false) {
const data = imageData[idx];
data.status = "processing";
data.error_msg = null;
// Stop any previous countdown first, then renderQueue creates fresh DOM
stopCountdown(idx);
renderQueue();
// Now the countdown element exists in the DOM — start the timer
startCountdown(idx);
const formData = new FormData();
formData.append("name", data.name);
formData.append("id_number", data.id_num);
formData.append(
"do_restore",
document.getElementById("step-restore").checked,
);
formData.append(
"fidelity",
document.getElementById("setting-fidelity").value,
);
formData.append(
"do_rmbg",
document.getElementById("step-rmbg").checked,
);
formData.append(
"do_color",
document.getElementById("step-color").checked,
);
formData.append(
"color_method",
document.getElementById("color-method").value,
);
formData.append(
"do_retouch",
document.getElementById("step-retouch").checked,
);
formData.append(
"do_crop",
document.getElementById("step-crop").checked,
);
formData.append(
"add_studio_name",
document.getElementById("add-studio").checked,
);
formData.append(
"add_logo",
document.getElementById("add-logo").checked,
);
formData.append(
"add_date",
document.getElementById("add-date").checked,
);
const frameVal = document.getElementById("frame-select").value;
if (frameVal) formData.append("frame_name", frameVal);
// New Frame Color
const frameColor = document.getElementById("frame-color");
if (frameColor) formData.append("frame_color", frameColor.value);
if (isCustom && data.custom_crop) {
formData.append("x1", data.custom_crop.x1);
formData.append("y1", data.custom_crop.y1);
formData.append("x2", data.custom_crop.x2);
formData.append("y2", data.custom_crop.y2);
}
// Attach abort signal to this request
currentController = new AbortController();
const signal = currentController.signal;
try {
const res = await fetch(`/process/${data.id}`, {
method: "POST",
body: formData,
signal: signal,
});
const result = await res.json();
if (result.error) throw new Error(result.error);
imageData[idx].result_url = result.result_url;
imageData[idx].preview_url = result.preview_url;
imageData[idx].version++;
imageData[idx].status = "done";
imageData[idx].error_msg = null;
imageData[idx].steps = {
restore: document.getElementById("step-restore").checked,
rmbg: document.getElementById("step-rmbg").checked,
color: document.getElementById("step-color").checked,
retouch: document.getElementById("step-retouch").checked,
crop:
document.getElementById("step-crop").checked ||
!!data.custom_crop,
};
// Record processing time for ETA
if (processingStartTime) {
avgProcessingTime = Date.now() - processingStartTime;
}
} catch (e) {
// Check if this was cancelled mid-request
if (e.name === "AbortError" || e.message === "Cancelled") {
imageData[idx].status = "waiting";
imageData[idx].error_msg = null;
imageData[idx].result_url = null;
} else {
console.error("Processing", e);
imageData[idx].status = "error";
imageData[idx].error_msg = e.message || "خطأ غير معروف";
showToast(
`خطأ في ${data.filename}: ${imageData[idx].error_msg}`,
"error",
6000,
);
}
} finally {
// Stop countdown before next renderQueue rebuilds DOM
stopCountdown(idx);
currentController = null;
}
renderQueue();
}
function startCountdown(idx) {
processingStartTime = Date.now();
// Use the rolling average as the estimated total; convert ms → seconds
countdownTarget = Math.max(10, Math.round(avgProcessingTime / 1000));
// Show initial value immediately (renderQueue hasn't run yet)
const el = document.getElementById(`countdown-${idx}`);
if (el) {
el.style.display = "block";
el.textContent = formatCountdown(countdownTarget);
}
// Update countdown every second
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
updateCountdowns(idx);
updateBatchETA();
}, 1000);
}
function stopCountdown(idx) {
const el = document.getElementById(`countdown-${idx}`);
if (el) {
const elapsed = processingStartTime
? Math.round((Date.now() - processingStartTime) / 1000)
: 0;
el.textContent = formatTime(elapsed) + " ✓";
el.style.display = "block";
setTimeout(() => {
const freshEl = document.getElementById(`countdown-${idx}`);
if (freshEl) freshEl.style.display = "none";
}, 2000);
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
processingStartTime = null;
countdownTarget = null;
updateBatchETA(); // Immediately reset/recalculate batch ETA
}
function updateCountdowns(idx) {
if (!processingStartTime || !countdownTarget) return;
const elapsed = Math.round((Date.now() - processingStartTime) / 1000);
const remaining = Math.max(0, countdownTarget - elapsed);
// Always re-query the element — renderQueue recreates DOM each time
const el = document.getElementById(`countdown-${idx}`);
if (el) el.textContent = formatCountdown(remaining);
}
// Formats countdown time (M:SS)
function formatCountdown(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
// Formats elapsed time (Ss or M:SS)
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function updateBatchETA() {
const etaEl = document.getElementById("batch-eta");
if (!etaEl) return;
const remaining = imageData.filter(
(d) =>
!d.result_url &&
d.status !== "error" &&
d.status !== "upload-failed" &&
d.status !== "processing",
).length;
// To get a smooth ticking overall ETA:
// Base ETA for untouched images + ETA for current image
let totalMs = avgProcessingTime * remaining;
if (processingStartTime && countdownTarget) {
const currentElapsed = Date.now() - processingStartTime;
const currentRemaining = Math.max(
0,
countdownTarget * 1000 - currentElapsed,
);
totalMs += currentRemaining;
} else if (imageData.some((d) => d.status === "processing")) {
totalMs += avgProcessingTime;
}
if (totalMs <= 0 && remaining === 0) {
etaEl.classList.add("hidden");
return;
}
const etaSec = Math.round(totalMs / 1000);
etaEl.classList.remove("hidden");
etaEl.textContent = `⏱ متبقي ~${formatTime(etaSec)}`;
}
async function reprocessCurrent() {
if (currentIndex === -1 || !isAIReady) return;
saveCurrentFields();
const data = imageData[currentIndex];
data.result_url = null;
data.status = "waiting";
data.steps = null;
renderQueue();
await runPipelineForIndex(currentIndex, !!data.custom_crop);
selectImage(currentIndex);
showToast("تمت إعادة المعالجة", "success");
}
// ══════════════════════════════════════════════════
// CROP & DOWNLOAD
// ══════════════════════════════════════════════════
editBtn.onclick = () => {
const data = imageData[currentIndex];
document.getElementById("cropper-loader").classList.remove("hidden");
cropperImg.src = data.orig_url || data.thumb_url;
previewContainer.classList.add("hidden");
zoomHint.classList.add("hidden");
cropperContainer.classList.remove("hidden");
if (cropper) cropper.destroy();
cropper = new Cropper(cropperImg, {
aspectRatio: 5 / 7,
viewMode: 1,
autoCropArea: 0.8,
ready() {
document.getElementById("cropper-loader").classList.add("hidden");
}
});
};
function cancelCrop() {
cropperContainer.classList.add("hidden");
previewContainer.classList.remove("hidden");
zoomHint.classList.remove("hidden");
}
async function applyCrop() {
const cropData = cropper.getData(true);
const imgData = imageData[currentIndex];
const displayImg = cropper.getImageData();
const scaleX = imgData.width / displayImg.naturalWidth;
const scaleY = imgData.height / displayImg.naturalHeight;
imgData.custom_crop = {
x1: Math.max(0, Math.round(cropData.x * scaleX)),
y1: Math.max(0, Math.round(cropData.y * scaleY)),
x2: Math.min(
imgData.width,
Math.round((cropData.x + cropData.width) * scaleX),
),
y2: Math.min(
imgData.height,
Math.round((cropData.y + cropData.height) * scaleY),
),
};
cancelCrop();
imgData.result_url = null;
imgData.status = "waiting";
await runPipelineForIndex(currentIndex, true);
selectImage(currentIndex);
}
downloadAllBtn.onclick = async () => {
const processed = imageData.filter((d) => d.result_url);
if (processed.length === 0) return;
const overlay = document.getElementById("batch-overlay");
const counter = document.getElementById("batch-counter");
overlay.classList.replace("hidden", "flex");
counter.textContent = "ZIP...";
const zip = new JSZip();
const folder = zip.folder("studio_layouts");
for (let data of processed) {
const res = await fetch(data.result_url);
const blob = await res.blob();
folder.file(`${PathStem(data.filename)}_layout.jpg`, blob);
}
const content = await zip.generateAsync({ type: "blob" });
const link = document.createElement("a");
link.href = URL.createObjectURL(content);
// Phase 3.3: unique zip filename with date + count
const dateStr = new Date().toISOString().slice(0, 10);
const zipName = `Studio_Batch_${dateStr}_${processed.length}imgs.zip`;
link.download = zipName;
link.click();
overlay.classList.replace("flex", "hidden");
showToast(`تم تحميل ${processed.length} صورة`, "success");
};
clearAllBtn.onclick = async () => {
// UX3: No confirm() — use undo toast instead
saveUndoState("حذف الكل");
try {
const res = await fetch("/clear-all", { method: "POST" });
imageData = [];
currentIndex = -1;
fileInput.value = "";
mainPreview.classList.add("hidden");
previewPlaceholder.classList.remove("hidden");
zoomHint.classList.add("hidden");
document.getElementById("student-name").value = "";
document.getElementById("student-id").value = "";
document.getElementById("current-filename").innerText = "";
renderQueue();
showToast("تم الحذف", "success");
} catch (e) {}
};
function deleteImage(e, idx) {
e.stopPropagation();
// UX3: No confirm() — use undo toast for instant + reversible delete
saveUndoState("حذف صورة");
imageData.splice(idx, 1);
if (imageData.length === 0) {
currentIndex = -1;
mainPreview.classList.add("hidden");
previewPlaceholder.classList.remove("hidden");
zoomHint.classList.add("hidden");
document.getElementById("student-name").value = "";
} else {
if (currentIndex === idx) {
currentIndex = Math.max(0, currentIndex - 1);
selectImage(currentIndex);
} else if (currentIndex > idx) {
currentIndex--;
}
}
renderQueue();
}
// Global Delete function for header button (keep for keyboard/compatibility)
function deleteSelected() {
if (currentIndex !== -1)
deleteImage({ stopPropagation: () => {} }, currentIndex);
}
saveBtn.onclick = () => {
const data = imageData[currentIndex];
if (!data || !data.result_url) return;
// Phase 3.3: unique filename with timestamp suffix to avoid conflicts
const baseName = PathStem(data.filename) || "photo";
const timestamp = Date.now();
const uniqueName = `${baseName}_${timestamp}.jpg`;
const link = document.createElement("a");
link.href = data.result_url;
link.download = uniqueName;
link.click();
};
function PathStem(filename) {
return filename.substring(0, filename.lastIndexOf(".")) || filename;
}
window.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
return;
// If cropper is active, intercept events for cropping controls
const isCropperActive = cropperContainer && !cropperContainer.classList.contains("hidden");
if (isCropperActive && cropper) {
if (e.key === "Escape") {
cancelCrop();
e.preventDefault();
} else if (e.key === "Enter") {
applyCrop();
e.preventDefault();
} else if (e.key === "+" || e.key === "=") {
cropper.zoom(0.1);
e.preventDefault();
} else if (e.key === "-") {
cropper.zoom(-0.1);
e.preventDefault();
} else if (e.key === "r" || e.key === "R") {
cropper.rotate(90);
e.preventDefault();
} else if (e.key === "ArrowLeft") {
cropper.move(-10, 0);
e.preventDefault();
} else if (e.key === "ArrowRight") {
cropper.move(10, 0);
e.preventDefault();
} else if (e.key === "ArrowUp") {
cropper.move(0, -10);
e.preventDefault();
} else if (e.key === "ArrowDown") {
cropper.move(0, 10);
e.preventDefault();
}
return;
}
if (e.key === "Escape") {
closeZoom();
document.getElementById("shortcuts-modal").classList.remove("active");
toggleDrawer(false);
closeJumpPopover();
return;
}
// Phase 3.6: Ctrl+Z = Undo
if (e.key === "z" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
performUndo();
return;
}
// Phase 3.4: G = Jump to image
if (e.key === "g" || e.key === "G") {
openJumpPopover({ stopPropagation: () => {} });
return;
}
if (e.key === "ArrowRight") navigate(1);
else if (e.key === "ArrowLeft") navigate(-1);
else if (e.key === "Delete") deleteSelected();
else if (e.key === "Enter") {
saveCurrentFields();
if (currentIndex < imageData.length - 1) navigate(1);
} else if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveCurrentFields();
runPipelineForIndex(currentIndex);
}
});
</script>
</body>
</html>