@jjlmoya/utils-audiovisual 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +198 -0
  3. package/src/category/i18n/es.ts +198 -0
  4. package/src/category/i18n/fr.ts +198 -0
  5. package/src/category/index.ts +17 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +4 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +32 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/seo_length.test.ts +22 -0
  21. package/src/tests/tool_validation.test.ts +17 -0
  22. package/src/tool/chromaticLens/bibliography.astro +17 -0
  23. package/src/tool/chromaticLens/component.astro +178 -0
  24. package/src/tool/chromaticLens/i18n/en.ts +246 -0
  25. package/src/tool/chromaticLens/i18n/es.ts +244 -0
  26. package/src/tool/chromaticLens/i18n/fr.ts +244 -0
  27. package/src/tool/chromaticLens/index.ts +43 -0
  28. package/src/tool/chromaticLens/logic.ts +87 -0
  29. package/src/tool/chromaticLens/seo.astro +15 -0
  30. package/src/tool/chromaticLens/style.css +308 -0
  31. package/src/tool/chromaticLens/ui.ts +109 -0
  32. package/src/tool/collageMaker/bibliography.astro +17 -0
  33. package/src/tool/collageMaker/component.astro +302 -0
  34. package/src/tool/collageMaker/i18n/en.ts +233 -0
  35. package/src/tool/collageMaker/i18n/es.ts +231 -0
  36. package/src/tool/collageMaker/i18n/fr.ts +231 -0
  37. package/src/tool/collageMaker/index.ts +51 -0
  38. package/src/tool/collageMaker/logic.ts +134 -0
  39. package/src/tool/collageMaker/seo.astro +15 -0
  40. package/src/tool/collageMaker/style.css +386 -0
  41. package/src/tool/exifCleaner/bibliography.astro +18 -0
  42. package/src/tool/exifCleaner/component.astro +162 -0
  43. package/src/tool/exifCleaner/i18n/en.ts +277 -0
  44. package/src/tool/exifCleaner/i18n/es.ts +277 -0
  45. package/src/tool/exifCleaner/i18n/fr.ts +277 -0
  46. package/src/tool/exifCleaner/index.ts +57 -0
  47. package/src/tool/exifCleaner/logic.ts +135 -0
  48. package/src/tool/exifCleaner/seo.astro +18 -0
  49. package/src/tool/exifCleaner/style.css +289 -0
  50. package/src/tool/exifCleaner/ui.ts +117 -0
  51. package/src/tool/imageCompressor/bibliography.astro +17 -0
  52. package/src/tool/imageCompressor/component.astro +262 -0
  53. package/src/tool/imageCompressor/i18n/en.ts +232 -0
  54. package/src/tool/imageCompressor/i18n/es.ts +230 -0
  55. package/src/tool/imageCompressor/i18n/fr.ts +230 -0
  56. package/src/tool/imageCompressor/index.ts +50 -0
  57. package/src/tool/imageCompressor/logic.ts +79 -0
  58. package/src/tool/imageCompressor/seo.astro +15 -0
  59. package/src/tool/imageCompressor/style.css +503 -0
  60. package/src/tool/printQualityCalculator/bibliography.astro +18 -0
  61. package/src/tool/printQualityCalculator/component.astro +318 -0
  62. package/src/tool/printQualityCalculator/i18n/en.ts +247 -0
  63. package/src/tool/printQualityCalculator/i18n/es.ts +245 -0
  64. package/src/tool/printQualityCalculator/i18n/fr.ts +245 -0
  65. package/src/tool/printQualityCalculator/index.ts +56 -0
  66. package/src/tool/printQualityCalculator/logic.ts +53 -0
  67. package/src/tool/printQualityCalculator/seo.astro +18 -0
  68. package/src/tool/printQualityCalculator/style.css +491 -0
  69. package/src/tool/printQualityCalculator/ui.ts +122 -0
  70. package/src/tool/privacyBlur/bibliography.astro +17 -0
  71. package/src/tool/privacyBlur/component.astro +230 -0
  72. package/src/tool/privacyBlur/i18n/en.ts +238 -0
  73. package/src/tool/privacyBlur/i18n/es.ts +236 -0
  74. package/src/tool/privacyBlur/i18n/fr.ts +236 -0
  75. package/src/tool/privacyBlur/index.ts +49 -0
  76. package/src/tool/privacyBlur/logic.ts +249 -0
  77. package/src/tool/privacyBlur/seo.astro +15 -0
  78. package/src/tool/privacyBlur/style.css +332 -0
  79. package/src/tool/privacyBlur/ui.ts +124 -0
  80. package/src/tool/subtitleSync/bibliography.astro +17 -0
  81. package/src/tool/subtitleSync/component.astro +187 -0
  82. package/src/tool/subtitleSync/i18n/en.ts +241 -0
  83. package/src/tool/subtitleSync/i18n/es.ts +241 -0
  84. package/src/tool/subtitleSync/i18n/fr.ts +241 -0
  85. package/src/tool/subtitleSync/index.ts +49 -0
  86. package/src/tool/subtitleSync/logic.ts +91 -0
  87. package/src/tool/subtitleSync/seo.astro +15 -0
  88. package/src/tool/subtitleSync/style.css +325 -0
  89. package/src/tool/subtitleSync/ui.ts +152 -0
  90. package/src/tool/timelapseCalculator/bibliography.astro +15 -0
  91. package/src/tool/timelapseCalculator/component.astro +148 -0
  92. package/src/tool/timelapseCalculator/i18n/en.ts +169 -0
  93. package/src/tool/timelapseCalculator/i18n/es.ts +169 -0
  94. package/src/tool/timelapseCalculator/i18n/fr.ts +169 -0
  95. package/src/tool/timelapseCalculator/index.ts +52 -0
  96. package/src/tool/timelapseCalculator/logic.ts +46 -0
  97. package/src/tool/timelapseCalculator/seo.astro +18 -0
  98. package/src/tool/timelapseCalculator/style.css +285 -0
  99. package/src/tool/tvDistance/bibliography.astro +17 -0
  100. package/src/tool/tvDistance/component.astro +178 -0
  101. package/src/tool/tvDistance/i18n/en.ts +223 -0
  102. package/src/tool/tvDistance/i18n/es.ts +223 -0
  103. package/src/tool/tvDistance/i18n/fr.ts +223 -0
  104. package/src/tool/tvDistance/index.ts +49 -0
  105. package/src/tool/tvDistance/logic.ts +47 -0
  106. package/src/tool/tvDistance/seo.astro +15 -0
  107. package/src/tool/tvDistance/style.css +435 -0
  108. package/src/tool/tvDistance/ui.ts +66 -0
  109. package/src/tool/videoFrameExtractor/bibliography.astro +17 -0
  110. package/src/tool/videoFrameExtractor/component.astro +285 -0
  111. package/src/tool/videoFrameExtractor/i18n/en.ts +235 -0
  112. package/src/tool/videoFrameExtractor/i18n/es.ts +235 -0
  113. package/src/tool/videoFrameExtractor/i18n/fr.ts +235 -0
  114. package/src/tool/videoFrameExtractor/index.ts +53 -0
  115. package/src/tool/videoFrameExtractor/logic.ts +49 -0
  116. package/src/tool/videoFrameExtractor/seo.astro +15 -0
  117. package/src/tool/videoFrameExtractor/style.css +426 -0
  118. package/src/tool/videoFrameExtractor/ui.ts +179 -0
  119. package/src/tools.ts +25 -0
  120. package/src/types.ts +72 -0
@@ -0,0 +1,289 @@
1
+ .ec-root {
2
+ --ec-bg: #fff;
3
+ --ec-bg-elevated: #f8fafc;
4
+ --ec-border: #e2e8f0;
5
+ --ec-text: #0f172a;
6
+ --ec-text-muted: #64748b;
7
+ --ec-accent: #6366f1;
8
+ --ec-accent-alpha: rgba(99, 102, 241, 0.08);
9
+ --ec-accent-alpha-hover: rgba(99, 102, 241, 0.02);
10
+ --ec-shadow: rgba(0, 0, 0, 0.15);
11
+
12
+ padding: 2.5rem 1.5rem;
13
+ max-width: 1000px;
14
+ margin: 0 auto;
15
+ }
16
+
17
+ .theme-dark .ec-root {
18
+ --ec-bg: #18181b;
19
+ --ec-bg-elevated: #27272a;
20
+ --ec-border: #3f3f46;
21
+ --ec-text: #f4f4f5;
22
+ --ec-text-muted: #71717a;
23
+ --ec-accent: #818cf8;
24
+ --ec-accent-alpha: rgba(129, 140, 248, 0.12);
25
+ --ec-accent-alpha-hover: rgba(129, 140, 248, 0.02);
26
+ --ec-shadow: rgba(0, 0, 0, 0.3);
27
+ }
28
+
29
+ .ec-card {
30
+ background: var(--ec-bg);
31
+ border: 1px solid var(--ec-border);
32
+ border-radius: 3rem;
33
+ padding: 1.5rem;
34
+ box-shadow: 0 45px 120px -30px var(--ec-shadow);
35
+ position: relative;
36
+ overflow: hidden;
37
+ }
38
+
39
+ .ec-drop {
40
+ padding: 4rem 2rem;
41
+ border: 3px dashed var(--ec-border);
42
+ border-radius: 2.5rem;
43
+ display: flex;
44
+ flex-direction: column;
45
+ align-items: center;
46
+ text-align: center;
47
+ cursor: pointer;
48
+ gap: 1.5rem;
49
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
50
+ }
51
+
52
+ .ec-drop:hover,
53
+ .ec-drop-active {
54
+ border-color: var(--ec-accent);
55
+ background: var(--ec-accent-alpha-hover);
56
+ }
57
+
58
+ .ec-drop-icon {
59
+ width: 6rem;
60
+ height: 6rem;
61
+ background: var(--ec-accent-alpha);
62
+ border-radius: 2rem;
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ color: var(--ec-accent);
67
+ }
68
+
69
+ .ec-drop-icon svg {
70
+ width: 3rem;
71
+ height: 3rem;
72
+ }
73
+
74
+ .ec-title {
75
+ font-size: 2.5rem;
76
+ font-weight: 950;
77
+ color: var(--ec-text);
78
+ margin: 0;
79
+ }
80
+
81
+ .ec-subtitle {
82
+ font-size: 1.15rem;
83
+ color: var(--ec-text-muted);
84
+ max-width: 500px;
85
+ margin: 0;
86
+ font-weight: 700;
87
+ }
88
+
89
+ .ec-badges {
90
+ display: flex;
91
+ gap: 1rem;
92
+ flex-wrap: wrap;
93
+ justify-content: center;
94
+ }
95
+
96
+ .ec-badge {
97
+ padding: 0.6rem 1.25rem;
98
+ background: var(--ec-bg-elevated);
99
+ border-radius: 2rem;
100
+ font-size: 0.8rem;
101
+ font-weight: 800;
102
+ color: var(--ec-text-muted);
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 0.5rem;
106
+ }
107
+
108
+ .ec-badge svg {
109
+ width: 1rem;
110
+ height: 1rem;
111
+ flex-shrink: 0;
112
+ }
113
+
114
+ .ec-processing {
115
+ padding: 5rem;
116
+ display: flex;
117
+ flex-direction: column;
118
+ align-items: center;
119
+ justify-content: center;
120
+ gap: 1.5rem;
121
+ }
122
+
123
+ .ec-spinner {
124
+ width: 4rem;
125
+ height: 4rem;
126
+ border: 4px solid var(--ec-accent-alpha);
127
+ border-top-color: var(--ec-accent);
128
+ border-radius: 50%;
129
+ animation: ec-spin 0.8s linear infinite;
130
+ }
131
+
132
+ .ec-processing-text {
133
+ font-weight: 800;
134
+ color: var(--ec-text);
135
+ margin: 0;
136
+ }
137
+
138
+ .ec-result {
139
+ padding: 2.5rem;
140
+ display: flex;
141
+ flex-direction: column;
142
+ animation: ec-slide-up 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
143
+ }
144
+
145
+ .ec-result-layout {
146
+ display: grid;
147
+ grid-template-columns: 1fr 1fr;
148
+ gap: 3rem;
149
+ }
150
+
151
+ @media (max-width: 800px) {
152
+ .ec-result-layout {
153
+ grid-template-columns: 1fr;
154
+ }
155
+ }
156
+
157
+ .ec-preview-col {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 1.5rem;
161
+ }
162
+
163
+ .ec-preview-img {
164
+ width: 100%;
165
+ border-radius: 1.5rem;
166
+ box-shadow: 0 20px 50px var(--ec-shadow);
167
+ display: block;
168
+ }
169
+
170
+ .ec-metadata {
171
+ background: var(--ec-bg-elevated);
172
+ border: 1px solid var(--ec-border);
173
+ border-radius: 1.25rem;
174
+ padding: 1.5rem;
175
+ min-height: 150px;
176
+ display: flex;
177
+ flex-direction: column;
178
+ justify-content: center;
179
+ }
180
+
181
+ .ec-no-metadata {
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ height: 100%;
186
+ color: var(--ec-text-muted);
187
+ text-align: center;
188
+ }
189
+
190
+ .ec-metadata-title {
191
+ color: var(--ec-accent);
192
+ font-weight: 700;
193
+ margin-bottom: 1rem;
194
+ font-size: 0.9rem;
195
+ }
196
+
197
+ .ec-metadata-list {
198
+ list-style: none;
199
+ padding: 0;
200
+ margin: 0;
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: 0.75rem;
204
+ }
205
+
206
+ .ec-metadata-list li {
207
+ display: flex;
208
+ justify-content: space-between;
209
+ gap: 1rem;
210
+ font-size: 0.85rem;
211
+ font-weight: 600;
212
+ color: var(--ec-text);
213
+ }
214
+
215
+ .ec-metadata-list li span:first-child {
216
+ font-weight: 700;
217
+ }
218
+
219
+ .ec-metadata-list li span:last-child {
220
+ color: var(--ec-text-muted);
221
+ }
222
+
223
+ .ec-actions-col {
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 1.5rem;
227
+ }
228
+
229
+ .ec-btn {
230
+ padding: 1.25rem;
231
+ border-radius: 1.5rem;
232
+ font-weight: 950;
233
+ font-size: 1.1rem;
234
+ border: none;
235
+ cursor: pointer;
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ gap: 0.75rem;
240
+ transition: all 0.2s;
241
+ }
242
+
243
+ .ec-btn svg {
244
+ width: 1.25rem;
245
+ height: 1.25rem;
246
+ }
247
+
248
+ .ec-btn-primary {
249
+ background: var(--ec-accent);
250
+ color: #fff;
251
+ box-shadow: 0 15px 35px -10px var(--ec-accent);
252
+ }
253
+
254
+ .ec-btn-primary:hover {
255
+ transform: translateY(-2px);
256
+ box-shadow: 0 20px 45px -10px var(--ec-accent);
257
+ }
258
+
259
+ .ec-btn-secondary {
260
+ background: var(--ec-bg-elevated);
261
+ border: 1px solid var(--ec-border);
262
+ color: var(--ec-text);
263
+ }
264
+
265
+ .ec-btn-secondary:hover {
266
+ border-color: var(--ec-accent);
267
+ color: var(--ec-accent);
268
+ }
269
+
270
+ .ec-hidden {
271
+ display: none;
272
+ }
273
+
274
+ @keyframes ec-spin {
275
+ to {
276
+ transform: rotate(360deg);
277
+ }
278
+ }
279
+
280
+ @keyframes ec-slide-up {
281
+ from {
282
+ opacity: 0;
283
+ transform: translateY(30px);
284
+ }
285
+ to {
286
+ opacity: 1;
287
+ transform: translateY(0);
288
+ }
289
+ }
@@ -0,0 +1,117 @@
1
+ import { extractExif, createCleanImage } from './logic';
2
+ import type { ExifCleanerUI } from './index';
3
+
4
+ export interface ExifEls {
5
+ initial: HTMLElement;
6
+ processing: HTMLElement;
7
+ result: HTMLElement;
8
+ preview: HTMLImageElement;
9
+ download: HTMLElement;
10
+ reset: HTMLElement;
11
+ list: HTMLElement;
12
+ input: HTMLInputElement;
13
+ }
14
+
15
+ function renderLog(tags: Record<string, string | number | boolean | undefined>, list: HTMLElement, riskTitle: string) {
16
+ if (!list) return;
17
+ const keys = Object.keys(tags);
18
+ if (keys.length === 0) {
19
+ list.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #94a3b8"><p>No se encontraron metadatos.</p></div>`;
20
+ return;
21
+ }
22
+
23
+ let html = `<div style="color: var(--accent); font-weight: 700; margin-bottom: 0.5rem">${riskTitle}</div><ul style="list-style: none; padding: 0; margin-bottom: 1rem">`;
24
+ if (tags.GPSLocation) html += `<li style="color: var(--primary-base); font-weight: 700; display: flex; justify-content: space-between"><span>GPS:</span> <span style="font-size: 0.6rem; text-align: right">${tags.GPSLocation}</span></li>`;
25
+ if (tags.Model) html += `<li style="color: var(--accent); display: flex; justify-content: space-between"><span>CAMARA:</span> <span>${tags.Model}</span></li>`;
26
+ if (tags.DateTimeOriginal) html += `<li style="color: var(--cyan); display: flex; justify-content: space-between"><span>FECHA:</span> <span>${tags.DateTimeOriginal}</span></li>`;
27
+ list.innerHTML = html + "</ul>";
28
+ }
29
+
30
+ function handleDownload(file: File, blob: Blob | null) {
31
+ if (blob) {
32
+ const url = URL.createObjectURL(blob);
33
+ const a = document.createElement("a");
34
+ a.href = url;
35
+ a.download = `CLEAN_${file.name.split(".")[0]}.webp`;
36
+ a.click();
37
+ setTimeout(() => URL.revokeObjectURL(url), 100);
38
+ }
39
+ }
40
+
41
+ async function processAndClean(file: File, els: ExifEls, labels: ExifCleanerUI) {
42
+ els.processing.classList.remove("hidden");
43
+ els.initial.classList.add("hidden");
44
+
45
+ const reader = new FileReader();
46
+ reader.onload = async (e) => {
47
+ const metadata = await extractExif(file);
48
+ const img = new Image();
49
+ img.onload = async () => {
50
+ if (els.preview) els.preview.src = img.src;
51
+ renderLog(metadata, els.list, labels.privacyRiskTitle);
52
+ const blob = await createCleanImage(img);
53
+
54
+ els.download.onclick = () => handleDownload(file, blob);
55
+
56
+ els.processing.classList.add("hidden");
57
+ els.result.classList.remove("hidden");
58
+ setTimeout(() => els.result.classList.remove("opacity-0"), 50);
59
+ };
60
+ img.src = e.target?.result as string;
61
+ };
62
+ reader.readAsDataURL(file);
63
+ }
64
+
65
+ function setupEvents(root: HTMLElement, els: ExifEls, labels: ExifCleanerUI) {
66
+ root.onclick = (e) => {
67
+ const isButton = (e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('label');
68
+ if (!isButton && els.result.classList.contains('hidden')) {
69
+ els.input.click();
70
+ }
71
+ };
72
+
73
+ root.ondragover = (e) => {
74
+ e.preventDefault();
75
+ root.classList.add("drop-zone-active");
76
+ };
77
+
78
+ root.ondragleave = () => root.classList.remove("drop-zone-active");
79
+ root.ondrop = (e: DragEvent) => {
80
+ e.preventDefault();
81
+ root.classList.remove("drop-zone-active");
82
+ if (e.dataTransfer?.files[0]) processAndClean(e.dataTransfer.files[0], els, labels);
83
+ };
84
+
85
+ els.input.onchange = () => {
86
+ if (els.input.files?.[0]) processAndClean(els.input.files[0], els, labels);
87
+ };
88
+
89
+ els.reset.onclick = () => {
90
+ els.result.classList.add("opacity-0");
91
+ setTimeout(() => {
92
+ els.result.classList.add("hidden");
93
+ els.initial.classList.remove("hidden");
94
+ els.input.value = "";
95
+ }, 300);
96
+ };
97
+ }
98
+
99
+ export const initExifCleaner = () => {
100
+ const root = document.getElementById('exif-cleaner-root');
101
+ if (!root) return;
102
+
103
+ const labels = JSON.parse(root.dataset.ui || '{}') as ExifCleanerUI;
104
+
105
+ const els: ExifEls = {
106
+ initial: root.querySelector("#initial-state")!,
107
+ processing: root.querySelector("#processing-state")!,
108
+ result: root.querySelector("#result-state")!,
109
+ preview: root.querySelector("#result-preview") as HTMLImageElement,
110
+ download: root.querySelector("#download-btn")!,
111
+ reset: root.querySelector("#reset-button")!,
112
+ list: root.querySelector("#metadata-list")!,
113
+ input: root.querySelector("#file-upload") as HTMLInputElement
114
+ };
115
+
116
+ setupEvents(root, els, labels);
117
+ };
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { imageCompressor } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await imageCompressor.i18n[locale]?.();
12
+ if (!content || !content.bibliography || content.bibliography.length === 0) return null;
13
+
14
+ const { bibliography, bibliographyTitle = 'Bibliografía' } = content;
15
+ ---
16
+
17
+ <SharedBibliography title={bibliographyTitle} links={bibliography} />
@@ -0,0 +1,262 @@
1
+ ---
2
+ import './style.css';
3
+ ---
4
+
5
+ <div class="ic-dashboard" id="image-compressor-root">
6
+
7
+ <div class="ic-global-settings">
8
+ <div class="ic-toggle-group">
9
+ <label class="ic-webp-switch">
10
+ <input type="checkbox" id="global-webp-toggle" checked />
11
+ <span class="ic-webp-slider"></span>
12
+ </label>
13
+ <span class="ic-toggle-label">Convertir a WebP</span>
14
+ </div>
15
+ <div class="ic-settings-group">
16
+ <label for="global-quality">Compresión: <span id="global-q-val">80</span>%</label>
17
+ <input type="range" id="global-quality" min="10" max="100" value="80" class="ic-mini-slider" />
18
+ </div>
19
+ </div>
20
+
21
+ <div id="drop-zone" class="ic-drop-zone">
22
+ <label for="image-input" class="ic-file-label">
23
+ <span class="ic-upload-icon">
24
+ <svg viewBox="0 0 24 24" fill="none" width="48" height="48" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
25
+ <path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M16 8l-4-4-4 4M12 4v12" />
26
+ </svg>
27
+ </span>
28
+ <span class="ic-upload-text">Suelta tus imágenes aquí</span>
29
+ <span class="ic-upload-subtext">Soporta múltiples archivos a la vez</span>
30
+ <span class="ic-upload-btn">Explorar archivos</span>
31
+ </label>
32
+ <input type="file" id="image-input" accept="image/jpeg,image/png,image/webp" multiple />
33
+ </div>
34
+
35
+ <div id="file-list-container" class="ic-file-list-container" style="display:none">
36
+ <div class="ic-list-header">
37
+ <h3>Archivos Procesados</h3>
38
+ <span id="total-savings" class="ic-total-savings"></span>
39
+ </div>
40
+ <ul id="file-list" class="ic-file-list"></ul>
41
+ <div class="ic-global-actions">
42
+ <button id="download-all" class="ic-primary-btn">Descargar Todas</button>
43
+ </div>
44
+ </div>
45
+
46
+ </div>
47
+
48
+ <template id="file-item-template">
49
+ <li class="ic-file-item">
50
+ <div class="ic-preview-col">
51
+ <div class="ic-preview-wrapper">
52
+ <img class="ic-preview-img" alt="Preview" />
53
+ </div>
54
+ <div class="ic-file-info">
55
+ <span class="ic-filename"></span>
56
+ <span class="ic-orig-size"></span>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="ic-arrow-col">
61
+ <div class="ic-arrow-circle">
62
+ <svg viewBox="0 0 24 24" fill="none" width="20" height="20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
63
+ <path d="M14 5l7 7-7 7M3 12h18" />
64
+ </svg>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="ic-result-col">
69
+ <div class="ic-savings-pill">
70
+ <span class="ic-new-size"></span>
71
+ <span class="ic-savings-pct"></span>
72
+ </div>
73
+ <div class="ic-item-actions">
74
+ <button class="ic-icon-btn ic-edit-btn" title="Ajustar esta imagen">
75
+ <svg viewBox="0 0 24 24" fill="none" width="18" height="18" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
76
+ </button>
77
+ <a class="ic-icon-btn ic-download-btn" title="Descargar" download>
78
+ <svg viewBox="0 0 24 24" fill="none" width="18" height="18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 4v12M8 12l4 4 4-4"/></svg>
79
+ </a>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="ic-inline-editor" style="display:none">
84
+ <div class="ic-editor-controls">
85
+ <div class="ic-editor-group">
86
+ <label>Calidad: <span class="ic-local-q-val">80</span>%</label>
87
+ <input type="range" class="ic-local-quality" min="10" max="100" value="80" />
88
+ </div>
89
+ <div class="ic-editor-group">
90
+ <label>Ancho máx. (px):</label>
91
+ <input type="number" class="ic-local-width" placeholder="Original" />
92
+ </div>
93
+ </div>
94
+ <button class="ic-editor-close">Cerrar</button>
95
+ </div>
96
+ </li>
97
+ </template>
98
+
99
+ <script>
100
+ import { compressImage, formatBytes, generateId, type CompressorSettings } from './logic';
101
+
102
+ interface QueueItem {
103
+ id: string;
104
+ file: File;
105
+ originalSize: number;
106
+ settings: CompressorSettings;
107
+ dataUrl: string;
108
+ newSize: number;
109
+ ext: string;
110
+ }
111
+
112
+ const queue = new Map<string, QueueItem>();
113
+ let totalOriginalSize = 0;
114
+ let totalCompressedSize = 0;
115
+
116
+ const dropZone = document.getElementById('drop-zone') as HTMLElement;
117
+ const fileInput = document.getElementById('image-input') as HTMLInputElement;
118
+ const container = document.getElementById('file-list-container') as HTMLElement;
119
+ const fileList = document.getElementById('file-list') as HTMLUListElement;
120
+ const totalSavingsTxt= document.getElementById('total-savings') as HTMLElement;
121
+ const downloadAllBtn = document.getElementById('download-all') as HTMLButtonElement;
122
+ const template = document.getElementById('file-item-template') as HTMLTemplateElement;
123
+ const globalQuality = document.getElementById('global-quality') as HTMLInputElement;
124
+ const globalQVal = document.getElementById('global-q-val') as HTMLElement;
125
+ const globalWebp = document.getElementById('global-webp-toggle') as HTMLInputElement;
126
+
127
+ globalQuality?.addEventListener('input', () => {
128
+ globalQVal.textContent = globalQuality.value;
129
+ });
130
+
131
+ async function handleFiles(files: FileList | File[]) {
132
+ if (!files.length) return;
133
+ container.style.display = '';
134
+
135
+ for (let i = 0; i < files.length; i++) {
136
+ const file = files[i];
137
+ if (!file || !file.type.startsWith('image/')) continue;
138
+
139
+ const id = generateId();
140
+ const settings: CompressorSettings = {
141
+ quality: parseInt(globalQuality.value),
142
+ width: null,
143
+ convertToWebp: globalWebp?.checked ?? true,
144
+ };
145
+ const item: QueueItem = { id, file, originalSize: file.size, settings, dataUrl: '', newSize: 0, ext: 'webp' };
146
+ queue.set(id, item);
147
+ totalOriginalSize += file.size;
148
+
149
+ appendItem(item);
150
+ await processItem(item);
151
+ updateTotals();
152
+ }
153
+ }
154
+
155
+ function appendItem(item: QueueItem) {
156
+ const clone = template.content.cloneNode(true) as DocumentFragment;
157
+ const li = clone.querySelector('li') as HTMLLIElement;
158
+ li.dataset.id = item.id;
159
+
160
+ (li.querySelector('.ic-filename') as HTMLElement).textContent = item.file.name;
161
+ (li.querySelector('.ic-orig-size') as HTMLElement).textContent = formatBytes(item.originalSize);
162
+
163
+ const localQ = li.querySelector('.ic-local-quality') as HTMLInputElement;
164
+ const localQVal = li.querySelector('.ic-local-q-val') as HTMLElement;
165
+ const localW = li.querySelector('.ic-local-width') as HTMLInputElement;
166
+ const editor = li.querySelector('.ic-inline-editor') as HTMLElement;
167
+ const editBtn = li.querySelector('.ic-edit-btn') as HTMLButtonElement;
168
+ const closeBtn = li.querySelector('.ic-editor-close') as HTMLButtonElement;
169
+
170
+ localQ.value = item.settings.quality.toString();
171
+ localQVal.textContent = item.settings.quality.toString();
172
+
173
+ editBtn.addEventListener('click', () => {
174
+ editor.style.display = editor.style.display === 'none' ? '' : 'none';
175
+ });
176
+ closeBtn.addEventListener('click', () => { editor.style.display = 'none'; });
177
+
178
+ localQ.addEventListener('input', () => { localQVal.textContent = localQ.value; });
179
+ localQ.addEventListener('change', () => {
180
+ item.settings.quality = parseInt(localQ.value);
181
+ processItem(item).then(() => updateTotals());
182
+ });
183
+ localW.addEventListener('change', () => {
184
+ item.settings.width = localW.value ? parseInt(localW.value) : null;
185
+ processItem(item).then(() => updateTotals());
186
+ });
187
+
188
+ fileList.appendChild(li);
189
+ }
190
+
191
+ async function processItem(item: QueueItem): Promise<void> {
192
+ const li = fileList.querySelector(`li[data-id="${item.id}"]`) as HTMLLIElement;
193
+ const result = await compressImage(item.file, item.settings);
194
+
195
+ item.dataUrl = result.dataUrl;
196
+ item.newSize = result.newSize;
197
+
198
+ const format = item.settings.convertToWebp ? 'image/webp' : item.file.type;
199
+ item.ext = format.split('/')[1] ?? 'jpeg';
200
+
201
+ if (!li) return;
202
+
203
+ const previewImg = li.querySelector('.ic-preview-img') as HTMLImageElement;
204
+ const newSizeTxt = li.querySelector('.ic-new-size') as HTMLElement;
205
+ const savingsPct = li.querySelector('.ic-savings-pct') as HTMLElement;
206
+ const pill = li.querySelector('.ic-savings-pill') as HTMLElement;
207
+ const downloadBtn = li.querySelector('.ic-download-btn') as HTMLAnchorElement;
208
+
209
+ previewImg.src = result.dataUrl;
210
+
211
+ newSizeTxt.textContent = formatBytes(result.newSize);
212
+ const savings = ((item.originalSize - result.newSize) / item.originalSize) * 100;
213
+
214
+ if (savings < 0) {
215
+ savingsPct.textContent = `+${Math.abs(savings).toFixed(1)}%`;
216
+ pill.classList.add('ic-savings-pill-negative');
217
+ } else {
218
+ savingsPct.textContent = `-${savings.toFixed(1)}%`;
219
+ pill.classList.remove('ic-savings-pill-negative');
220
+ }
221
+
222
+ const baseName = item.file.name.replace(/\.[^/.]+$/, '');
223
+ downloadBtn.href = result.dataUrl;
224
+ downloadBtn.download = `${baseName}.${item.ext}`;
225
+ }
226
+
227
+ function updateTotals() {
228
+ totalCompressedSize = Array.from(queue.values()).reduce((sum, i) => sum + i.newSize, 0);
229
+ const saved = totalOriginalSize - totalCompressedSize;
230
+ if (saved > 0) {
231
+ totalSavingsTxt.textContent = `Ahorro total: ${formatBytes(saved)}`;
232
+ totalSavingsTxt.style.color = '#10b981';
233
+ } else {
234
+ totalSavingsTxt.textContent = 'Sin ahorro neto';
235
+ totalSavingsTxt.style.color = '#ef4444';
236
+ }
237
+ }
238
+
239
+ dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('ic-dragover'); });
240
+ dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('ic-dragover'); });
241
+ dropZone.addEventListener('drop', (e) => {
242
+ e.preventDefault();
243
+ dropZone.classList.remove('ic-dragover');
244
+ if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
245
+ });
246
+ fileInput.addEventListener('change', () => {
247
+ if (fileInput.files?.length) handleFiles(fileInput.files);
248
+ });
249
+
250
+ downloadAllBtn.addEventListener('click', () => {
251
+ queue.forEach((item) => {
252
+ if (!item.dataUrl) return;
253
+ const a = document.createElement('a');
254
+ a.href = item.dataUrl;
255
+ const baseName = item.file.name.replace(/\.[^/.]+$/, '');
256
+ a.download = `${baseName}.${item.ext}`;
257
+ document.body.appendChild(a);
258
+ a.click();
259
+ document.body.removeChild(a);
260
+ });
261
+ });
262
+ </script>