@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,109 @@
1
+ import { extractPalette, type ColorSwatch } from './logic';
2
+ import type { ChromaticLensUI } from './index';
3
+
4
+ export function initChromaticLens() {
5
+ const root = document.getElementById('chromatic-lens-root');
6
+ if (!root) return;
7
+
8
+ const labels = JSON.parse(root.dataset.ui || '{}') as ChromaticLensUI;
9
+ const canvas = document.createElement('canvas');
10
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
11
+
12
+ const fileInput = root.querySelector('#file-input') as HTMLInputElement;
13
+ const dropArea = root.querySelector('#drop-area') as HTMLElement;
14
+ const workspace = root.querySelector('#workspace-area') as HTMLElement;
15
+ const emptyState = root.querySelector('#empty-state') as HTMLElement;
16
+ const loader = root.querySelector('#loader-overlay') as HTMLElement;
17
+ const previewImg = root.querySelector('#preview-image') as HTMLImageElement;
18
+ const paletteGrid = root.querySelector('#swatches-grid') as HTMLElement;
19
+ const colorCountSelect = root.querySelector('#color-count') as HTMLSelectElement;
20
+
21
+ let currentImageData: Uint8ClampedArray | null = null;
22
+ let isProcessing = false;
23
+
24
+ const processPalette = () => {
25
+ if (!currentImageData || isProcessing) return;
26
+ isProcessing = true;
27
+ loader.classList.remove('hidden');
28
+ paletteGrid.classList.add('hidden');
29
+
30
+ const colorCount = parseInt(colorCountSelect.value);
31
+
32
+ requestAnimationFrame(() => {
33
+ setTimeout(() => {
34
+ const palette = extractPalette(currentImageData!, colorCount);
35
+ renderPalette(palette);
36
+ loader.classList.add('hidden');
37
+ paletteGrid.classList.remove('hidden');
38
+ isProcessing = false;
39
+ }, 50);
40
+ });
41
+ };
42
+
43
+ const renderPalette = (palette: ColorSwatch[]) => {
44
+ paletteGrid.innerHTML = "";
45
+ palette.forEach((swatch) => {
46
+ const swatchEl = document.createElement('div');
47
+ swatchEl.className = "swatch";
48
+ swatchEl.innerHTML = `
49
+ <div class="swatch-color" style="background-color: ${swatch.hex}"></div>
50
+ <div class="swatch-info">
51
+ <span class="swatch-hex">${swatch.hex}</span>
52
+ <div class="swatch-action">
53
+ <span class="action-text">${labels.copyLabel}</span>
54
+ </div>
55
+ </div>
56
+ `;
57
+
58
+ swatchEl.onclick = () => {
59
+ navigator.clipboard.writeText(swatch.hex);
60
+ const actionText = swatchEl.querySelector('.action-text')!;
61
+ const originalText = actionText.textContent;
62
+ actionText.textContent = labels.copiedLabel;
63
+ swatchEl.classList.add('copied');
64
+
65
+ setTimeout(() => {
66
+ actionText.textContent = originalText;
67
+ swatchEl.classList.remove('copied');
68
+ }, 2000);
69
+ };
70
+
71
+ paletteGrid.appendChild(swatchEl);
72
+ });
73
+ };
74
+
75
+ const handleFile = (file: File) => {
76
+ const reader = new FileReader();
77
+ reader.onload = (e) => {
78
+ const img = new Image();
79
+ img.onload = () => {
80
+ canvas.width = img.naturalWidth;
81
+ canvas.height = img.naturalHeight;
82
+ ctx.drawImage(img, 0, 0);
83
+ currentImageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
84
+
85
+ previewImg.src = img.src;
86
+ emptyState.classList.add('hidden');
87
+ workspace.classList.remove('hidden');
88
+ processPalette();
89
+ };
90
+ img.src = e.target?.result as string;
91
+ };
92
+ reader.readAsDataURL(file);
93
+ };
94
+
95
+ dropArea.onclick = () => fileInput.click();
96
+ fileInput.onchange = () => {
97
+ if (fileInput.files?.[0]) handleFile(fileInput.files[0]);
98
+ };
99
+
100
+ colorCountSelect.onchange = processPalette;
101
+
102
+ root.ondragover = (e) => { e.preventDefault(); dropArea.classList.add('drop-area-active'); };
103
+ root.ondragleave = () => { dropArea.classList.remove('drop-area-active'); };
104
+ root.ondrop = (e: DragEvent) => {
105
+ e.preventDefault();
106
+ dropArea.classList.remove('drop-area-active');
107
+ if (e.dataTransfer?.files[0]) handleFile(e.dataTransfer.files[0]);
108
+ };
109
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { collageMaker } 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 collageMaker.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,302 @@
1
+ ---
2
+ import type { CollageMakerUI } from './index';
3
+ import './style.css';
4
+
5
+ interface Props {
6
+ ui: CollageMakerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const dropSub = (ui.dropSub ?? '').replace('{link}', `<span class="cm-drop-link">${ui.dropLink ?? ''}</span>`);
11
+ ---
12
+
13
+ <div class="cm-root" id="collage-maker-root" data-ui={JSON.stringify(ui)}>
14
+ <div class="cm-card">
15
+
16
+ <div class="cm-top-row">
17
+ <div class="cm-left-col">
18
+
19
+ <div id="cm-drop-zone" class="cm-drop-zone">
20
+ <input type="file" id="cm-file-input" accept="image/*" multiple />
21
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
22
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
23
+ <circle cx="8.5" cy="8.5" r="1.5"/>
24
+ <path d="M21 15l-5-5L5 21"/>
25
+ </svg>
26
+ <p class="cm-drop-title">{ui.dropTitle}</p>
27
+ <p class="cm-drop-sub" set:html={dropSub} />
28
+ </div>
29
+
30
+ <div id="cm-images-section" style="display:none">
31
+ <div class="cm-section-header">
32
+ <span class="cm-section-label">{ui.imgsLoaded}</span>
33
+ <span id="cm-counter" class="cm-badge">0/9</span>
34
+ </div>
35
+ <div id="cm-thumbs" class="cm-thumbs"></div>
36
+ </div>
37
+
38
+ </div>
39
+
40
+ <div class="cm-preview-col">
41
+ <div class="cm-preview-placeholder" id="cm-placeholder">
42
+ <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" opacity="0.25">
43
+ <rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/>
44
+ <rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/>
45
+ </svg>
46
+ <p>{ui.previewTitle}</p>
47
+ </div>
48
+ <canvas id="cm-canvas" class="cm-canvas" style="display:none"></canvas>
49
+ <div id="cm-preview-footer" style="display:none">
50
+ <span id="cm-canvas-dims" class="cm-dims-badge"></span>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="cm-section-divider" id="cm-layout-section" style="display:none">
56
+ <span class="cm-section-label">{ui.layoutLabel}</span>
57
+ <div id="cm-layouts" class="cm-layouts"></div>
58
+ </div>
59
+
60
+ <div class="cm-section-divider cm-settings-row" id="cm-settings-section" style="display:none">
61
+ <span class="cm-section-label">{ui.settingsLabel}</span>
62
+ <div class="cm-settings-inline">
63
+ <div class="cm-setting">
64
+ <label class="cm-setting-label" for="cm-border-width">{ui.borderLabel} <span id="cm-bw-val">0</span>px</label>
65
+ <input type="range" id="cm-border-width" min="0" max="30" value="0" class="cm-slider" />
66
+ </div>
67
+ <div class="cm-setting">
68
+ <label class="cm-setting-label">{ui.borderColorLabel}</label>
69
+ <div class="cm-color-row">
70
+ <input type="color" id="cm-border-color" value="#ffffff" class="cm-color-swatch" />
71
+ <span id="cm-bc-val" class="cm-color-code">#FFFFFF</span>
72
+ </div>
73
+ </div>
74
+ <div class="cm-setting">
75
+ <label class="cm-setting-label">{ui.bgColorLabel}</label>
76
+ <div class="cm-color-row">
77
+ <input type="color" id="cm-bg-color" value="#1a1a1a" class="cm-color-swatch" />
78
+ <span id="cm-bg-val" class="cm-color-code">#1A1A1A</span>
79
+ </div>
80
+ </div>
81
+ <button id="cm-download-btn" class="cm-download-btn" disabled>
82
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M12 4v12M8 12l4 4 4-4"/></svg>
83
+ {ui.downloadBtn}
84
+ </button>
85
+ </div>
86
+ </div>
87
+
88
+ </div>
89
+ </div>
90
+
91
+ <script>
92
+ interface ImgEntry { src: string; w: number; h: number; name: string; }
93
+ type Pos = [number, number, number, number];
94
+
95
+ interface Layout {
96
+ id: string;
97
+ label: string;
98
+ images: number;
99
+ svgPath: string;
100
+ build: (gap: number) => { W: number; H: number; pos: Pos[] };
101
+ }
102
+
103
+ const LAYOUTS: Layout[] = [
104
+ {
105
+ id: '2x1', label: '2 cols', images: 2,
106
+ svgPath: 'M1,1h10v10h-10z M13,1h10v10h-10z',
107
+ build: g => { const W=800,H=400,cw=(W-g*3)/2,ch=H-g*2; return {W,H,pos:[[g,g,cw,ch],[g*2+cw,g,cw,ch]]}; }
108
+ },
109
+ {
110
+ id: '1x2', label: '2 filas', images: 2,
111
+ svgPath: 'M1,1h22v10h-22z M1,13h22v10h-22z',
112
+ build: g => { const W=400,H=800,cw=W-g*2,ch=(H-g*3)/2; return {W,H,pos:[[g,g,cw,ch],[g,g*2+ch,cw,ch]]}; }
113
+ },
114
+ {
115
+ id: '3-split', label: '3 cols', images: 3,
116
+ svgPath: 'M1,1h6v22h-6z M9,1h6v22h-6z M17,1h6v22h-6z',
117
+ build: g => { const W=900,H=300,cw=(W-g*4)/3,ch=H-g*2; return {W,H,pos:[[g,g,cw,ch],[g*2+cw,g,cw,ch],[g*3+cw*2,g,cw,ch]]}; }
118
+ },
119
+ {
120
+ id: '2+2', label: '2×2', images: 4,
121
+ svgPath: 'M1,1h10v10h-10z M13,1h10v10h-10z M1,13h10v10h-10z M13,13h10v10h-10z',
122
+ build: g => { const W=800,H=800,cs=(W-g*3)/2; return {W,H,pos:[[g,g,cs,cs],[g*2+cs,g,cs,cs],[g,g*2+cs,cs,cs],[g*2+cs,g*2+cs,cs,cs]]}; }
123
+ },
124
+ {
125
+ id: 'big-left', label: 'Grande izq', images: 4,
126
+ svgPath: 'M1,1h13v22h-13z M16,1h7v6h-7z M16,9h7v6h-7z M16,17h7v6h-7z',
127
+ build: g => { const W=800,H=600,bs=400-g,ss=(W-bs-g*4)/3; return {W,H,pos:[[g,g,bs,bs],[bs+g*2,g,ss,ss],[bs+g*2,g*2+ss,ss,ss],[bs+g*2,g*3+ss*2,ss,ss]]}; }
128
+ },
129
+ {
130
+ id: 'big-right', label: 'Grande der', images: 4,
131
+ svgPath: 'M1,1h7v6h-7z M1,9h7v6h-7z M1,17h7v6h-7z M10,1h13v22h-13z',
132
+ build: g => { const W=800,H=600,bs=400-g,ss=(W-bs-g*4)/3; return {W,H,pos:[[g,g,ss,ss],[g,g*2+ss,ss,ss],[g,g*3+ss*2,ss,ss],[ss+g*2,g,bs,bs]]}; }
133
+ },
134
+ {
135
+ id: '5-grid', label: '5 asim.', images: 5,
136
+ svgPath: 'M1,1h10v22h-10z M13,1h10v10h-10z M13,13h10v10h-10z',
137
+ build: g => { const W=900,H=750,c1=(W-g*3)/2,c2=(W-g*4)/3,r2=(H-g*3)/2; return {W,H,pos:[[g,g,c1,H-g*2],[c1+g*2,g,c2,r2],[c1+g*2+c2+g,g,c2,r2],[c1+g*2,g*2+r2,c2,r2],[c1+g*2+c2+g,g*2+r2,c2,r2]]}; }
138
+ },
139
+ {
140
+ id: '2x3', label: '2×3', images: 6,
141
+ svgPath: 'M1,1h10v6h-10z M13,1h10v6h-10z M1,9h10v6h-10z M13,9h10v6h-10z M1,17h10v6h-10z M13,17h10v6h-10z',
142
+ build: g => { const W=800,H=1200,cw=(W-g*3)/2,ch=(H-g*4)/3; const pos:Pos[]=[]; for(let r=0;r<3;r++)for(let c=0;c<2;c++)pos.push([g*(c+1)+cw*c,g*(r+1)+ch*r,cw,ch]); return {W,H,pos}; }
143
+ },
144
+ {
145
+ id: '2x4', label: '2×4', images: 8,
146
+ svgPath: 'M1,1h10v4h-10z M13,1h10v4h-10z M1,7h10v4h-10z M13,7h10v4h-10z M1,13h10v4h-10z M13,13h10v4h-10z M1,19h10v4h-10z M13,19h10v4h-10z',
147
+ build: g => { const W=800,H=1600,cw=(W-g*3)/2,ch=(H-g*5)/4; const pos:Pos[]=[]; for(let r=0;r<4;r++)for(let c=0;c<2;c++)pos.push([g*(c+1)+cw*c,g*(r+1)+ch*r,cw,ch]); return {W,H,pos}; }
148
+ },
149
+ {
150
+ id: '3x3', label: '3×3', images: 9,
151
+ svgPath: 'M1,1h6v6h-6z M9,1h6v6h-6z M17,1h6v6h-6z M1,9h6v6h-6z M9,9h6v6h-6z M17,9h6v6h-6z M1,17h6v6h-6z M9,17h6v6h-6z M17,17h6v6h-6z',
152
+ build: g => { const W=900,H=900,cs=(W-g*4)/3; const pos:Pos[]=[]; for(let r=0;r<3;r++)for(let c=0;c<3;c++)pos.push([g*(c+1)+cs*c,g*(r+1)+cs*r,cs,cs]); return {W,H,pos}; }
153
+ },
154
+ ];
155
+
156
+ const root = document.getElementById('collage-maker-root') as HTMLElement;
157
+ const labels = JSON.parse(root.dataset.ui ?? '{}') as Record<string,string>;
158
+ const dropZone = document.getElementById('cm-drop-zone') as HTMLElement;
159
+ const fileInput = document.getElementById('cm-file-input') as HTMLInputElement;
160
+ const imagesSection = document.getElementById('cm-images-section') as HTMLElement;
161
+ const thumbsEl = document.getElementById('cm-thumbs') as HTMLElement;
162
+ const counterEl = document.getElementById('cm-counter') as HTMLElement;
163
+ const layoutSection = document.getElementById('cm-layout-section') as HTMLElement;
164
+ const layoutsEl = document.getElementById('cm-layouts') as HTMLElement;
165
+ const settingsSection = document.getElementById('cm-settings-section') as HTMLElement;
166
+ const downloadBtn = document.getElementById('cm-download-btn') as HTMLButtonElement;
167
+ const canvas = document.getElementById('cm-canvas') as HTMLCanvasElement;
168
+ const placeholder = document.getElementById('cm-placeholder') as HTMLElement;
169
+ const previewFooter = document.getElementById('cm-preview-footer') as HTMLElement;
170
+ const canvasDims = document.getElementById('cm-canvas-dims') as HTMLElement;
171
+ const bwInput = document.getElementById('cm-border-width') as HTMLInputElement;
172
+ const bwVal = document.getElementById('cm-bw-val') as HTMLElement;
173
+ const bcInput = document.getElementById('cm-border-color') as HTMLInputElement;
174
+ const bcVal = document.getElementById('cm-bc-val') as HTMLElement;
175
+ const bgInput = document.getElementById('cm-bg-color') as HTMLInputElement;
176
+ const bgVal = document.getElementById('cm-bg-val') as HTMLElement;
177
+
178
+ let images: ImgEntry[] = [];
179
+ let selectedLayout: Layout | null = null;
180
+ let collageReady = false;
181
+
182
+ function buildLayoutPicker(): void {
183
+ layoutsEl.innerHTML = '';
184
+ LAYOUTS.forEach(layout => {
185
+ const enabled = images.length >= layout.images;
186
+ const needsText = (labels.needsImgs ?? 'Necesitas {n} imágenes').replace('{n}', String(layout.images));
187
+ const btn = document.createElement('button');
188
+ btn.className = ['cm-layout-btn', selectedLayout?.id === layout.id ? 'cm-layout-btn-active' : '', !enabled ? 'cm-layout-btn-disabled' : ''].filter(Boolean).join(' ');
189
+ btn.disabled = !enabled;
190
+ btn.title = enabled ? layout.label : needsText;
191
+ btn.innerHTML = `<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor" aria-hidden="true"><path d="${layout.svgPath}"/></svg><span>${layout.label}</span>${!enabled ? `<span class="cm-layout-need">${layout.images}</span>` : ''}`;
192
+ if (enabled) {
193
+ btn.addEventListener('click', () => { selectedLayout = layout; buildLayoutPicker(); renderCanvas(); });
194
+ }
195
+ layoutsEl.appendChild(btn);
196
+ });
197
+ if (!selectedLayout || images.length < selectedLayout.images) {
198
+ selectedLayout = LAYOUTS.find(l => images.length >= l.images) ?? null;
199
+ if (selectedLayout) buildLayoutPicker();
200
+ }
201
+ }
202
+
203
+ function renderThumbs(): void {
204
+ thumbsEl.innerHTML = '';
205
+ images.forEach((img, idx) => {
206
+ const div = document.createElement('div');
207
+ div.className = 'cm-thumb';
208
+ div.innerHTML = `<img src="${img.src}" alt="" /><button class="cm-thumb-del" data-idx="${idx}"><svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg></button><span class="cm-thumb-num">${idx+1}</span>`;
209
+ thumbsEl.appendChild(div);
210
+ });
211
+ thumbsEl.querySelectorAll('.cm-thumb-del').forEach(btn => {
212
+ btn.addEventListener('click', e => {
213
+ e.stopPropagation();
214
+ const idx = parseInt((btn as HTMLElement).dataset.idx ?? '0');
215
+ images.splice(idx, 1);
216
+ afterImagesChange();
217
+ });
218
+ });
219
+ }
220
+
221
+ function afterImagesChange(): void {
222
+ counterEl.textContent = `${images.length}/9`;
223
+ imagesSection.style.display = images.length > 0 ? '' : 'none';
224
+ layoutSection.style.display = images.length >= 2 ? '' : 'none';
225
+ settingsSection.style.display = images.length >= 2 ? '' : 'none';
226
+ renderThumbs();
227
+ if (images.length >= 2) {
228
+ buildLayoutPicker();
229
+ renderCanvas();
230
+ } else {
231
+ canvas.style.display = 'none';
232
+ placeholder.style.display = '';
233
+ previewFooter.style.display = 'none';
234
+ collageReady = false;
235
+ downloadBtn.disabled = true;
236
+ }
237
+ }
238
+
239
+ async function renderCanvas(): Promise<void> {
240
+ if (!selectedLayout || images.length < 2) return;
241
+ const gap = parseInt(bwInput.value);
242
+ const { W, H, pos } = selectedLayout.build(gap);
243
+ canvas.width = W;
244
+ canvas.height = H;
245
+ const ctx = canvas.getContext('2d')!;
246
+ ctx.fillStyle = bgInput.value;
247
+ ctx.fillRect(0, 0, W, H);
248
+ for (let i = 0; i < Math.min(images.length, pos.length); i++) {
249
+ const p = pos[i]!;
250
+ await drawImage(ctx, images[i]!.src, p[0], p[1], p[2], p[3]);
251
+ if (gap > 0) { ctx.strokeStyle = bcInput.value; ctx.lineWidth = gap; ctx.strokeRect(p[0]-gap/2, p[1]-gap/2, p[2]+gap, p[3]+gap); }
252
+ }
253
+ canvas.style.display = '';
254
+ placeholder.style.display = 'none';
255
+ previewFooter.style.display = '';
256
+ canvasDims.textContent = `${W} × ${H} px`;
257
+ collageReady = true;
258
+ downloadBtn.disabled = false;
259
+ }
260
+
261
+ function drawImage(ctx: CanvasRenderingContext2D, src: string, rect: { x: number; y: number; w: number; h: number }): Promise<void> {
262
+ return new Promise(resolve => {
263
+ const img = new Image();
264
+ const { x, y, w, h } = rect;
265
+ img.onload = () => {
266
+ const ratio = Math.max(w / img.width, h / img.height);
267
+ const sw = w / ratio, sh = h / ratio;
268
+ ctx.drawImage(img, (img.width-sw)/2, (img.height-sh)/2, sw, sh, x, y, w, h);
269
+ resolve();
270
+ };
271
+ img.src = src;
272
+ });
273
+ }
274
+
275
+ async function loadFiles(files: FileList | File[]): Promise<void> {
276
+ const arr = Array.from(files).filter(f => f.type.startsWith('image/')).slice(0, 9 - images.length);
277
+ const loaded = await Promise.all(arr.map(f => new Promise<ImgEntry>((resolve, reject) => {
278
+ const r = new FileReader();
279
+ r.onload = e => { const img = new Image(); img.onload = () => resolve({ src: e.target!.result as string, w: img.width, h: img.height, name: f.name }); img.onerror = reject; img.src = e.target!.result as string; };
280
+ r.readAsDataURL(f);
281
+ })));
282
+ images = [...images, ...loaded];
283
+ afterImagesChange();
284
+ }
285
+
286
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('cm-drop-zone-over'); });
287
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('cm-drop-zone-over'));
288
+ dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('cm-drop-zone-over'); if (e.dataTransfer?.files.length) loadFiles(e.dataTransfer.files); });
289
+ fileInput.addEventListener('change', () => { if (fileInput.files?.length) loadFiles(fileInput.files); fileInput.value = ''; });
290
+
291
+ bwInput.addEventListener('input', () => { bwVal.textContent = bwInput.value; renderCanvas(); });
292
+ bcInput.addEventListener('input', () => { bcVal.textContent = bcInput.value.toUpperCase(); renderCanvas(); });
293
+ bgInput.addEventListener('input', () => { bgVal.textContent = bgInput.value.toUpperCase(); renderCanvas(); });
294
+
295
+ downloadBtn.addEventListener('click', () => {
296
+ if (!collageReady) return;
297
+ const a = document.createElement('a');
298
+ a.href = canvas.toDataURL('image/webp', 0.92);
299
+ a.download = 'collage.webp';
300
+ a.click();
301
+ });
302
+ </script>
@@ -0,0 +1,233 @@
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { CollageMakerUI, CollageMakerLocaleContent } from '../index';
3
+
4
+ const slug = 'free-online-photo-collage-maker-professional-compositions';
5
+ const title = 'Online Collage Maker - Design professional compositions';
6
+ const description = 'Create photo collages for free in seconds. Choose from multiple layouts, adjust borders, and download in high quality without watermarks.';
7
+
8
+ const ui: CollageMakerUI = {
9
+ dropTitle: "Drag images here",
10
+ dropSub: "or {link} - min. 2, max. 9",
11
+ dropLink: "select files",
12
+ imgsLoaded: "Loaded images",
13
+ layoutLabel: "Layout",
14
+ settingsLabel: "Settings",
15
+ borderLabel: "Border",
16
+ borderColorLabel: "Border color",
17
+ bgColorLabel: "Background",
18
+ downloadBtn: "Download",
19
+ previewTitle: "Preview",
20
+ needsImgs: "You need {n} images",
21
+ errorMin: "You need at least 2 images",
22
+ errorMax: "Maximum 9 images allowed",
23
+ errorLoad: "Error loading images",
24
+ faqTitle: "Frequently Asked Questions",
25
+ bibliographyTitle: "References"
26
+ };
27
+
28
+ const faq: CollageMakerLocaleContent['faq'] = [
29
+ {
30
+ question: "How can I change the order of the photos?",
31
+ answer: "The photos are placed in the collage following the order in which they appear in the loaded images list. You can remove one and upload it again to adjust its position.",
32
+ },
33
+ {
34
+ question: "What output format does the collage have?",
35
+ answer: "The collage is downloaded in high-resolution WebP format, ideal for sharing on social networks without losing sharpness.",
36
+ },
37
+ ];
38
+
39
+ const howTo: CollageMakerLocaleContent['howTo'] = [
40
+ {
41
+ name: "Upload your photos",
42
+ text: "Select between 2 and 9 images from your file explorer.",
43
+ },
44
+ {
45
+ name: "Choose a layout",
46
+ text: "Select the grid that best suits the number of photos you have uploaded.",
47
+ },
48
+ {
49
+ name: "Customize the style",
50
+ text: "Adjust the border thickness and color to give it a professional finish.",
51
+ },
52
+ {
53
+ name: "Download and share",
54
+ text: "Press the create button and instantly download your final composition.",
55
+ },
56
+ ];
57
+
58
+ const bibliography: CollageMakerLocaleContent['bibliography'] = [
59
+ {
60
+ name: "Photographic Composition: The Art of Collage",
61
+ url: "https://en.wikipedia.org/wiki/Collage",
62
+ },
63
+ ];
64
+
65
+ const seo: CollageMakerLocaleContent['seo'] = [
66
+ {
67
+ type: 'summary',
68
+ title: 'Professional Online Collage Maker',
69
+ items: [
70
+ 'Multiple layouts and predefined grids',
71
+ 'Customization of borders and background colors',
72
+ '1200px high resolution ready for social networks',
73
+ 'Instant processing with no usage limits'
74
+ ]
75
+ },
76
+ { type: 'title', text: 'Collage Design: Compositions That Tell Stories', level: 2 },
77
+ { type: 'paragraph', html: 'A collage is more than a mix of photos; it\'s a visual narrative that communicates emotion and context. Our tool allows you to create professional geometric compositions for Instagram, Facebook, Pinterest, or personal projects without the need for Photoshop or expensive software.' },
78
+
79
+ { type: 'stats', items: [
80
+ { value: '2-9', label: 'Images per Collage', icon: 'mdi:image-multiple' },
81
+ { value: '1200px', label: 'HD Resolution', icon: 'mdi:video-high-definition' },
82
+ { value: 'Instant', label: 'Generation', icon: 'mdi:lightning-bolt' }
83
+ ], columns: 3 },
84
+
85
+ { type: 'title', text: 'Visual Composition: Design Principles', level: 3 },
86
+ { type: 'paragraph', html: 'Composition is the art of organizing visual elements in a way that guides the viewer\'s eye and communicates intent. A good collage balances:' },
87
+ { type: 'list', items: [
88
+ '<strong>Balance:</strong> Visual distribution of weight (light vs dark images, large vs small).',
89
+ '<strong>Visual Flow:</strong> The eye should traverse the composition naturally, without dead spots.',
90
+ '<strong>Contrast:</strong> Variations in color, size, and shape that catch the attention.',
91
+ '<strong>Unity:</strong> Thematic coherence - images must "speak together" about the same story.'
92
+ ], icon: 'mdi:check' },
93
+
94
+ { type: 'card', title: 'Smart and Adaptive Designs', html: 'Our system automatically calculates the optimal space distribution based on the number of photos. 2 images = symmetric grid; 5 images = balanced asymmetric distribution. Each grid is designed to maximize visual impact.' },
95
+
96
+ { type: 'comparative', items: [
97
+ {
98
+ title: 'For Social Networks',
99
+ description: 'Instagram, TikTok, Facebook - square formats',
100
+ icon: 'mdi:share-all',
101
+ points: [
102
+ '1200px is perfect for Instagram feed',
103
+ 'Square format avoids cropping when posting',
104
+ 'Customizable borders for branding'
105
+ ],
106
+ highlight: true
107
+ },
108
+ {
109
+ title: 'For Portfolios',
110
+ description: 'Visual showcases for photographers and designers',
111
+ icon: 'mdi:briefcase-outline',
112
+ points: [
113
+ 'Show multiple angles of a project',
114
+ 'Cohesive visual narrative',
115
+ 'Professional digital book printing'
116
+ ]
117
+ },
118
+ {
119
+ title: 'For Personal Gifts',
120
+ description: 'Prints, frames, and digital albums',
121
+ icon: 'mdi:gift-outline',
122
+ points: [
123
+ 'Event memories (weddings, trips)',
124
+ 'High resolution ready for printing',
125
+ 'Instant design without manual effort'
126
+ ]
127
+ }
128
+ ], columns: 3 },
129
+
130
+ { type: 'title', text: 'Customization: Borders and Colors', level: 3 },
131
+ { type: 'table', headers: ['Element', 'Visual Effect', 'Recommendation'], rows: [
132
+ ['White Border', 'Clean, minimalist, modern', 'Social networks, digital portfolios'],
133
+ ['Black Border', 'Dramatic, professional, artistic', 'Art photography, fashion, luxury'],
134
+ ['Neutral Border (gray)', 'Versatile, academic, corporate', 'Business, education, neutrality'],
135
+ ['No Border', 'Fusion, continuity, immersive', 'Uniform background, flowing photos']
136
+ ] },
137
+
138
+ { type: 'proscons', items: [
139
+ {
140
+ pro: 'Professional predefined designs - no composition knowledge needed',
141
+ con: 'Options limited to pre-established grids'
142
+ },
143
+ {
144
+ pro: '1200px resolution ready for social networks without rescaling',
145
+ con: 'Not as customizable as Photoshop'
146
+ },
147
+ {
148
+ pro: '100% local processing - privacy, speed, no limits',
149
+ con: 'Requires a modern browser'
150
+ },
151
+ {
152
+ pro: 'Totally free, no watermarks, no advertising',
153
+ con: 'No individual editing options (cropping, rotation)'
154
+ }
155
+ ], proTitle: 'Advantages', conTitle: 'Limitations' },
156
+
157
+ { type: 'diagnostic', variant: 'success', title: 'Ready for Social Networks', icon: 'mdi:check-circle-outline', badge: 'Optimized', html: '1200x1200px is the ideal resolution for Instagram. It supports any aspect ratio, but the square output maximizes feed impact, eliminates automatic cropping, and looks just as good on desktop, tablet, and mobile.' },
158
+
159
+ { type: 'glossary', items: [
160
+ {
161
+ term: 'Visual Composition',
162
+ definition: 'Art of organizing elements (color, shape, space) to guide the viewer\'s eye and communicate emotional intent.'
163
+ },
164
+ {
165
+ term: 'Rule of Thirds',
166
+ definition: 'Composition principle: divides the image into 9 equal areas (3x3). Position subjects on intersection lines for maximum impact.'
167
+ },
168
+ {
169
+ term: 'Symmetrical Grid',
170
+ definition: 'Equal space divisions. Reassuring, orderly. Ideal for pairs of photos or even numbers.'
171
+ },
172
+ {
173
+ term: 'Asymmetrical Grid',
174
+ definition: 'Unequal divisions. Dynamic, interesting, visual. Ideal for 5+ photos with variety of sizes.'
175
+ },
176
+ {
177
+ term: 'Visual Balance',
178
+ definition: 'Perceptual equilibrium of visual weight. Light + dark image = balance; large + small = balance.'
179
+ }
180
+ ] },
181
+
182
+ { type: 'message', title: 'Instant Visual Narrative', ariaLabel: 'Information about composition and collages', html: 'Every collage tells a story. Our automatic designer ensures your visual stories are balanced, professional, and ready for the world. Without waiting for a graphic designer to do the work.' },
183
+
184
+ { type: 'title', text: 'Create, Share, Inspire', level: 3 },
185
+ { type: 'paragraph', html: 'A well-made collage is the difference between an forgettable post and one your followers remember and share. Use smart composition for your visual stories to impact.' }
186
+ ];
187
+
188
+ const faqSchema: WithContext<FAQPage> = {
189
+ '@context': 'https://schema.org',
190
+ '@type': 'FAQPage',
191
+ mainEntity: faq.map((item) => ({
192
+ '@type': 'Question',
193
+ name: item.question,
194
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
195
+ })),
196
+ };
197
+
198
+ const howToSchema: WithContext<HowTo> = {
199
+ '@context': 'https://schema.org',
200
+ '@type': 'HowTo',
201
+ name: title,
202
+ description,
203
+ step: howTo.map((step) => ({
204
+ '@type': 'HowToStep',
205
+ name: step.name,
206
+ text: step.text,
207
+ })),
208
+ };
209
+
210
+ const appSchema: WithContext<SoftwareApplication> = {
211
+ '@context': 'https://schema.org',
212
+ '@type': 'SoftwareApplication',
213
+ name: title,
214
+ description,
215
+ applicationCategory: 'UtilitiesApplication',
216
+ operatingSystem: 'Web',
217
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
218
+ inLanguage: 'en',
219
+ };
220
+
221
+ export const content: CollageMakerLocaleContent = {
222
+ slug,
223
+ title,
224
+ description,
225
+ ui,
226
+ seo,
227
+ faqTitle: "Frequently Asked Questions",
228
+ faq,
229
+ bibliographyTitle: "References",
230
+ bibliography,
231
+ howTo,
232
+ schemas: [faqSchema as any, howToSchema as any, appSchema],
233
+ };