| <!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" |
| /> |
| |
| <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> |
| |
| <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"); |
| |
| |
| :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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| #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 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); |
| } |
| |
| |
| @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-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-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 { |
| 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 { |
| 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-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); |
| } |
| |
| [dir="rtl"] .mobile-drawer { |
| left: 0; |
| right: auto; |
| transform: translateX(-100%); |
| } |
| .mobile-drawer.active { |
| transform: translateX(0) !important; |
| } |
| |
| |
| |
| .empty-state-icon { |
| font-size: 32px; |
| color: var(--text-muted); |
| opacity: 0.4; |
| display: block; |
| margin-bottom: 10px; |
| } |
| |
| #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); |
| } |
| |
| #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; |
| } |
| |
| .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-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; |
| } |
| |
| |
| #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; |
| } |
| |
| |
| .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; |
| |
| } |
| .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; |
| } |
| |
| .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; |
| } |
| |
| .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; |
| } |
| |
| .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; |
| } |
| |
| @keyframes slideInRight { |
| from { |
| opacity: 0; |
| transform: translateX(20px); |
| } |
| to { |
| opacity: 1; |
| transform: translateX(0); |
| } |
| } |
| .queue-slide-new { |
| animation: slideInRight 0.25s ease-out; |
| } |
| |
| @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; |
| } |
| |
| .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); |
| } |
| |
| .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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| #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); |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| |
| .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; |
| } |
| |
| |
| .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 { |
| 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; |
| } |
| |
| |
| .queue-slide .slide-delete { |
| opacity: 1 !important; |
| } |
| |
| |
| #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; |
| } |
| |
| #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; |
| } |
| |
| #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; |
| } |
| |
| #cropper-container .cropper-actions { |
| padding-top: 8px !important; |
| gap: 8px !important; |
| } |
| #cropper-container .cropper-actions button { |
| padding: 8px 14px !important; |
| font-size: 13px !important; |
| } |
| } |
| |
| |
| @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"> |
| |
| <div id="toast-container"></div> |
|
|
| |
| <div id="zoom-modal" onclick="closeZoom()"> |
| <img id="zoom-img" src="" alt="Zoomed preview" /> |
| </div> |
|
|
| |
| <div id="cropper-container" class="hidden"> |
| |
| <div class="cropper-modal-box"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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" |
| >← →</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 & 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> |
| |
| <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 & 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">← → ↑ ↓</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> |
|
|
| |
| <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 |
| 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> |
|
|
| |
| <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> |
|
|
| |
| <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" |
| > |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="lg:col-span-6 flex flex-col-reverse lg:flex-col gap-6 order-2"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <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" |
| > |
|
|
| |
| <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> |
|
|
|
|
|
|
| |
| <div |
| id="preview-container" |
| class="w-full h-full flex items-center justify-center relative" |
| > |
| |
| <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()" |
| /> |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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 |
| > |
|
|
| |
| <div class="space-y-3 mb-6" dir="rtl"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| <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> |
| |
| <div |
| id="frame-thumbnails" |
| class="mt-2 flex flex-wrap gap-2 justify-end" |
| ></div> |
| </div> |
|
|
| |
| <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]" |
| > |
| |
| </div> |
| <p class="text-[9px] text-theme-muted mt-1 text-center"> |
| انقر لاختيار · × لحذف |
| </p> |
| </div> |
| </div> |
|
|
| |
| <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); |
| " |
| > |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <div id="settings-live-preview"> |
| <span class="text-[9px] text-theme-muted">معاينة مباشرة</span> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| |
| |
| let imageData = []; |
| let currentIndex = -1; |
| let cropper = null; |
| let isAIReady = false; |
| let showingOriginal = false; |
| let batchCancelled = false; |
| let lastAction = null; |
| let undoTimer = null; |
| let livePreviewDebounce = null; |
| |
| let processingStartTime = null; |
| let countdownTarget = null; |
| let countdownInterval = null; |
| let avgProcessingTime = 100000; |
| let currentController = null; |
| |
| |
| |
| |
| 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"); |
| |
| |
| |
| |
| 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(); |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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() { |
| |
| 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 = ""; |
| return; |
| } |
| |
| try { |
| showToast("جاري استعادة النسخة الاحتياطية...", "info"); |
| const res = await fetch("/backup/import", { |
| method: "POST", |
| body: fd, |
| }); |
| if (res.ok) { |
| const data = await res.json(); |
| |
| |
| 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 = ""; |
| } |
| |
| |
| 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); |
| |
| |
| |
| |
| 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"); |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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; |
| 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(); |
| |
| if (frameSelect.value === val) frameSelect.value = ""; |
| updateFramePreview(); |
| } else { |
| const err = await res.json(); |
| showToast(err.error || "فشل الحذف", "error"); |
| } |
| } catch (e) { |
| showToast("خطأ في الاتصال", "error"); |
| } |
| } |
| |
| |
| |
| |
| function loadSavedColors() { |
| const container = document.getElementById("saved-colors-container"); |
| const saved = JSON.parse( |
| localStorage.getItem("studio-saved-colors") || "[]", |
| ); |
| container.innerHTML = ""; |
| |
| |
| 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) => { |
| |
| 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; |
| }; |
| |
| 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(); |
| |
| |
| |
| |
| |
| async function checkStatus() { |
| try { |
| const res = await fetch("/status"); |
| const data = await res.json(); |
| if (data.ai_ready) { |
| isAIReady = true; |
| |
| 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); |
| |
| |
| |
| |
| 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) { |
| |
| 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, |
| }); |
| renderQueue(); |
| showToast( |
| `فشل رفع ${file.name} — اضغط 🔄 لإعادة المحاولة`, |
| "error", |
| 6000, |
| ); |
| } |
| } |
| fileInput.value = ""; |
| } |
| |
| |
| async function retryImage(idx) { |
| const data = imageData[idx]; |
| if (!data) return; |
| |
| if (data.status === "upload-failed" && data._file) { |
| |
| const file = data._file; |
| data.status = "processing"; |
| data.error_msg = null; |
| renderQueue(); |
| try { |
| const uploaded = await uploadFileWithProgress(file); |
| |
| 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") { |
| |
| 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>'; |
| } |
| |
| |
| |
| |
| 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>'; |
| |
| |
| const errorTooltip = |
| img.status === "error" && img.error_msg |
| ? `<div class="slide-error-msg">${img.error_msg}</div>` |
| : ""; |
| |
| 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>` |
| : ""; |
| |
| const slideClass = |
| img.status === "error" || img.status === "upload-failed" |
| ? "has-error" |
| : ""; |
| |
| const isProcessing = img.status === "processing"; |
| const countdownEl = `<span class="slide-countdown" id="countdown-${idx}" ${isProcessing ? 'style="display: block;"' : ""}></span>`; |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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); |
| |
| |
| 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; |
| } |
| |
| |
| updatePreviewProcessingOverlay(); |
| |
| |
| |
| imageData.forEach((img, idx) => { |
| if (img.status === "processing") updateCountdowns(idx); |
| }); |
| } |
| |
| |
| |
| |
| 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) { |
| |
| 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 { |
| |
| 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; |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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'); |
| } |
| } |
| |
| |
| |
| |
| (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); |
| })(); |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| function saveUndoState(actionLabel) { |
| |
| lastAction = { |
| label: actionLabel, |
| snapshot: JSON.parse(JSON.stringify(imageData)), |
| currentIndex: currentIndex, |
| }; |
| showUndoToast(actionLabel); |
| } |
| |
| function showUndoToast(actionLabel) { |
| |
| 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"); |
| } |
| |
| |
| |
| |
| 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"); |
| } |
| |
| |
| |
| |
| function cancelBatch() { |
| batchCancelled = true; |
| |
| if (currentController) { |
| currentController.abort(); |
| currentController = null; |
| |
| 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; |
| } |
| |
| |
| 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; |
| if (imageData[i].result_url) continue; |
| await runPipelineForIndex(i); |
| done++; |
| counter.textContent = `${done}/${total}`; |
| } |
| |
| |
| overlay.classList.replace("flex", "hidden"); |
| document.getElementById("batch-eta").classList.add("hidden"); |
| processAllBtn.disabled = false; |
| fileInput.disabled = false; |
| |
| |
| 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; |
| |
| stopCountdown(idx); |
| renderQueue(); |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| 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, |
| }; |
| |
| if (processingStartTime) { |
| avgProcessingTime = Date.now() - processingStartTime; |
| } |
| } catch (e) { |
| |
| 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 { |
| |
| stopCountdown(idx); |
| currentController = null; |
| } |
| renderQueue(); |
| } |
| |
| function startCountdown(idx) { |
| processingStartTime = Date.now(); |
| |
| countdownTarget = Math.max(10, Math.round(avgProcessingTime / 1000)); |
| |
| const el = document.getElementById(`countdown-${idx}`); |
| if (el) { |
| el.style.display = "block"; |
| el.textContent = formatCountdown(countdownTarget); |
| } |
| |
| 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(); |
| } |
| |
| function updateCountdowns(idx) { |
| if (!processingStartTime || !countdownTarget) return; |
| const elapsed = Math.round((Date.now() - processingStartTime) / 1000); |
| const remaining = Math.max(0, countdownTarget - elapsed); |
| |
| const el = document.getElementById(`countdown-${idx}`); |
| if (el) el.textContent = formatCountdown(remaining); |
| } |
| |
| |
| function formatCountdown(seconds) { |
| const m = Math.floor(seconds / 60); |
| const s = seconds % 60; |
| return `${m}:${s.toString().padStart(2, "0")}`; |
| } |
| |
| |
| 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; |
| |
| |
| |
| 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"); |
| } |
| |
| |
| |
| |
| 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); |
| |
| 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 () => { |
| |
| 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(); |
| |
| 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(); |
| } |
| |
| |
| function deleteSelected() { |
| if (currentIndex !== -1) |
| deleteImage({ stopPropagation: () => {} }, currentIndex); |
| } |
| |
| saveBtn.onclick = () => { |
| const data = imageData[currentIndex]; |
| if (!data || !data.result_url) return; |
| |
| 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; |
| |
| |
| 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; |
| } |
| |
| if (e.key === "z" && (e.ctrlKey || e.metaKey)) { |
| e.preventDefault(); |
| performUndo(); |
| return; |
| } |
| |
| 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> |
|
|