@astro-live-cms/core 0.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 (163) hide show
  1. package/dist/index.d.ts +15 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +122 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/runtime/auth/session.d.ts +14 -0
  6. package/dist/runtime/auth/session.d.ts.map +1 -0
  7. package/dist/runtime/auth/session.js +77 -0
  8. package/dist/runtime/auth/session.js.map +1 -0
  9. package/dist/runtime/bind.d.ts +14 -0
  10. package/dist/runtime/bind.d.ts.map +1 -0
  11. package/dist/runtime/bind.js +11 -0
  12. package/dist/runtime/bind.js.map +1 -0
  13. package/dist/runtime/config.d.ts +6 -0
  14. package/dist/runtime/config.d.ts.map +1 -0
  15. package/dist/runtime/config.js +18 -0
  16. package/dist/runtime/config.js.map +1 -0
  17. package/dist/runtime/content.d.ts +30 -0
  18. package/dist/runtime/content.d.ts.map +1 -0
  19. package/dist/runtime/content.js +48 -0
  20. package/dist/runtime/content.js.map +1 -0
  21. package/dist/runtime/index.d.ts +10 -0
  22. package/dist/runtime/index.d.ts.map +1 -0
  23. package/dist/runtime/index.js +12 -0
  24. package/dist/runtime/index.js.map +1 -0
  25. package/dist/runtime/markers.d.ts +17 -0
  26. package/dist/runtime/markers.d.ts.map +1 -0
  27. package/dist/runtime/markers.js +17 -0
  28. package/dist/runtime/markers.js.map +1 -0
  29. package/dist/runtime/middleware.d.ts +12 -0
  30. package/dist/runtime/middleware.d.ts.map +1 -0
  31. package/dist/runtime/middleware.js +37 -0
  32. package/dist/runtime/middleware.js.map +1 -0
  33. package/dist/runtime/mutations/contracts.d.ts +57 -0
  34. package/dist/runtime/mutations/contracts.d.ts.map +1 -0
  35. package/dist/runtime/mutations/contracts.js +242 -0
  36. package/dist/runtime/mutations/contracts.js.map +1 -0
  37. package/dist/runtime/mutations/engine.d.ts +23 -0
  38. package/dist/runtime/mutations/engine.d.ts.map +1 -0
  39. package/dist/runtime/mutations/engine.js +161 -0
  40. package/dist/runtime/mutations/engine.js.map +1 -0
  41. package/dist/runtime/routes/_helpers.d.ts +6 -0
  42. package/dist/runtime/routes/_helpers.d.ts.map +1 -0
  43. package/dist/runtime/routes/_helpers.js +23 -0
  44. package/dist/runtime/routes/_helpers.js.map +1 -0
  45. package/dist/runtime/routes/admin.d.ts +3 -0
  46. package/dist/runtime/routes/admin.d.ts.map +1 -0
  47. package/dist/runtime/routes/admin.js +110 -0
  48. package/dist/runtime/routes/admin.js.map +1 -0
  49. package/dist/runtime/routes/auth-login.d.ts +4 -0
  50. package/dist/runtime/routes/auth-login.d.ts.map +1 -0
  51. package/dist/runtime/routes/auth-login.js +66 -0
  52. package/dist/runtime/routes/auth-login.js.map +1 -0
  53. package/dist/runtime/routes/auth-logout.d.ts +4 -0
  54. package/dist/runtime/routes/auth-logout.d.ts.map +1 -0
  55. package/dist/runtime/routes/auth-logout.js +51 -0
  56. package/dist/runtime/routes/auth-logout.js.map +1 -0
  57. package/dist/runtime/routes/editor-assets.d.ts +3 -0
  58. package/dist/runtime/routes/editor-assets.d.ts.map +1 -0
  59. package/dist/runtime/routes/editor-assets.js +47 -0
  60. package/dist/runtime/routes/editor-assets.js.map +1 -0
  61. package/dist/runtime/routes/entries.d.ts +3 -0
  62. package/dist/runtime/routes/entries.d.ts.map +1 -0
  63. package/dist/runtime/routes/entries.js +89 -0
  64. package/dist/runtime/routes/entries.js.map +1 -0
  65. package/dist/runtime/routes/history.d.ts +4 -0
  66. package/dist/runtime/routes/history.d.ts.map +1 -0
  67. package/dist/runtime/routes/history.js +56 -0
  68. package/dist/runtime/routes/history.js.map +1 -0
  69. package/dist/runtime/routes/mutate.d.ts +4 -0
  70. package/dist/runtime/routes/mutate.d.ts.map +1 -0
  71. package/dist/runtime/routes/mutate.js +35 -0
  72. package/dist/runtime/routes/mutate.js.map +1 -0
  73. package/dist/runtime/routes/schema.d.ts +3 -0
  74. package/dist/runtime/routes/schema.d.ts.map +1 -0
  75. package/dist/runtime/routes/schema.js +27 -0
  76. package/dist/runtime/routes/schema.js.map +1 -0
  77. package/dist/runtime/routes/studio-home.d.ts +3 -0
  78. package/dist/runtime/routes/studio-home.d.ts.map +1 -0
  79. package/dist/runtime/routes/studio-home.js +174 -0
  80. package/dist/runtime/routes/studio-home.js.map +1 -0
  81. package/dist/runtime/routes/theme-bootstrap.d.ts +4 -0
  82. package/dist/runtime/routes/theme-bootstrap.d.ts.map +1 -0
  83. package/dist/runtime/routes/theme-bootstrap.js +65 -0
  84. package/dist/runtime/routes/theme-bootstrap.js.map +1 -0
  85. package/dist/runtime/routes/theme-factory.d.ts +3 -0
  86. package/dist/runtime/routes/theme-factory.d.ts.map +1 -0
  87. package/dist/runtime/routes/theme-factory.js +142 -0
  88. package/dist/runtime/routes/theme-factory.js.map +1 -0
  89. package/dist/runtime/routes/upload.d.ts +3 -0
  90. package/dist/runtime/routes/upload.d.ts.map +1 -0
  91. package/dist/runtime/routes/upload.js +29 -0
  92. package/dist/runtime/routes/upload.js.map +1 -0
  93. package/dist/runtime/schema/infer-json-schema.d.ts +12 -0
  94. package/dist/runtime/schema/infer-json-schema.d.ts.map +1 -0
  95. package/dist/runtime/schema/infer-json-schema.js +75 -0
  96. package/dist/runtime/schema/infer-json-schema.js.map +1 -0
  97. package/dist/runtime/storage/filesystem-adapter.d.ts +29 -0
  98. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -0
  99. package/dist/runtime/storage/filesystem-adapter.js +182 -0
  100. package/dist/runtime/storage/filesystem-adapter.js.map +1 -0
  101. package/dist/runtime/storage/filesystem-upload-handler.d.ts +11 -0
  102. package/dist/runtime/storage/filesystem-upload-handler.d.ts.map +1 -0
  103. package/dist/runtime/storage/filesystem-upload-handler.js +37 -0
  104. package/dist/runtime/storage/filesystem-upload-handler.js.map +1 -0
  105. package/dist/runtime/storage/in-memory-adapter.d.ts +24 -0
  106. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -0
  107. package/dist/runtime/storage/in-memory-adapter.js +78 -0
  108. package/dist/runtime/storage/in-memory-adapter.js.map +1 -0
  109. package/dist/runtime/themes/preset-catalog.d.ts +9 -0
  110. package/dist/runtime/themes/preset-catalog.d.ts.map +1 -0
  111. package/dist/runtime/themes/preset-catalog.js +47 -0
  112. package/dist/runtime/themes/preset-catalog.js.map +1 -0
  113. package/dist/runtime/utils.d.ts +6 -0
  114. package/dist/runtime/utils.d.ts.map +1 -0
  115. package/dist/runtime/utils.js +29 -0
  116. package/dist/runtime/utils.js.map +1 -0
  117. package/dist/types.d.ts +28 -0
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/dist/vite/virtual-config-plugin.d.ts +7 -0
  122. package/dist/vite/virtual-config-plugin.d.ts.map +1 -0
  123. package/dist/vite/virtual-config-plugin.js +25 -0
  124. package/dist/vite/virtual-config-plugin.js.map +1 -0
  125. package/package.json +35 -0
  126. package/static/cms/editor/config.js +6 -0
  127. package/static/cms/editor/guards.js +68 -0
  128. package/static/cms/editor/helpers.js +16 -0
  129. package/static/cms/editor/image-edit.js +148 -0
  130. package/static/cms/editor/image-utils.js +84 -0
  131. package/static/cms/editor/image.css +133 -0
  132. package/static/cms/editor/link-ui.css +143 -0
  133. package/static/cms/editor/linkify.js +55 -0
  134. package/static/cms/editor/panel.css +91 -0
  135. package/static/cms/editor/panel.js +64 -0
  136. package/static/cms/editor/save-queue.js +167 -0
  137. package/static/cms/editor/section-controls/activation.js +10 -0
  138. package/static/cms/editor/section-controls/api.js +88 -0
  139. package/static/cms/editor/section-controls/constants.js +24 -0
  140. package/static/cms/editor/section-controls/index.js +622 -0
  141. package/static/cms/editor/section-controls/model.js +76 -0
  142. package/static/cms/editor/section-controls/mutations.js +112 -0
  143. package/static/cms/editor/section-controls/page-context.js +34 -0
  144. package/static/cms/editor/section-controls/pickers.js +196 -0
  145. package/static/cms/editor/section-controls/reorder.js +92 -0
  146. package/static/cms/editor/section-controls/selection.js +54 -0
  147. package/static/cms/editor/section-controls/spacing-drag.js +83 -0
  148. package/static/cms/editor/section-controls/ui-elements.js +54 -0
  149. package/static/cms/editor/section-controls/utils.js +35 -0
  150. package/static/cms/editor/section-controls.css +349 -0
  151. package/static/cms/editor/section-controls.js +1 -0
  152. package/static/cms/editor/security.js +23 -0
  153. package/static/cms/editor/sync.js +64 -0
  154. package/static/cms/editor/text-edit.js +129 -0
  155. package/static/cms/editor/text-link-interactions.js +191 -0
  156. package/static/cms/editor/toast.css +50 -0
  157. package/static/cms/editor/toast.js +29 -0
  158. package/static/cms/editor/tokens-and-text.css +94 -0
  159. package/static/cms/editor/toolbar.css +261 -0
  160. package/static/cms/editor/toolbar.js +110 -0
  161. package/static/cms/editor.css +10 -0
  162. package/static/cms/editor.js +101 -0
  163. package/static/cms/studio.css +312 -0
@@ -0,0 +1,68 @@
1
+ export function mountEditorGuards(state) {
2
+ document.body.classList.add('cms-edit-mode');
3
+
4
+ window.addEventListener('beforeunload', (e) => {
5
+ if (state.dirtyEl) {
6
+ e.preventDefault();
7
+ e.returnValue = '';
8
+ }
9
+ });
10
+
11
+ // Smart navigation: only block clicks on CMS-editable elements.
12
+ document.addEventListener(
13
+ 'click',
14
+ (e) => {
15
+ const target = e.target;
16
+ if (!(target instanceof Element)) return;
17
+
18
+ // Allow clicks inside the CMS toolbar
19
+ if (target.closest('.cms-toolbar')) return;
20
+
21
+ // Allow clicks on image overlay buttons
22
+ if (target.closest('.cms-img-overlay')) return;
23
+
24
+ // Allow clicks inside the link popover
25
+ if (target.closest('.cms-link-popover')) return;
26
+
27
+ const editable = target.closest('[data-cms]');
28
+ const link = target.closest('a');
29
+ if (link) {
30
+ // Link contains or is a CMS editable — block nav, focus editable
31
+ const cmsInLink = editable || link.querySelector('[data-cms]');
32
+ if (cmsInLink instanceof HTMLElement) {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ cmsInLink.focus();
36
+ return;
37
+ }
38
+ // Non-CMS link — let it navigate normally
39
+ return;
40
+ }
41
+
42
+ // For buttons: only intercept if they contain CMS content
43
+ const btn = target.closest('button');
44
+ if (btn && !btn.closest('.cms-toolbar') && !btn.closest('.cms-img-wrapper')) {
45
+ const cmsInBtn = editable || btn.querySelector('[data-cms]');
46
+ if (cmsInBtn instanceof HTMLElement) {
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ cmsInBtn.focus();
50
+ }
51
+ }
52
+ },
53
+ true,
54
+ );
55
+
56
+ // Block form submissions (except CMS forms)
57
+ document.addEventListener(
58
+ 'submit',
59
+ (e) => {
60
+ const target = e.target;
61
+ if (!(target instanceof Element)) return;
62
+ if (!target.closest('.cms-toolbar')) {
63
+ e.preventDefault();
64
+ }
65
+ },
66
+ true,
67
+ );
68
+ }
@@ -0,0 +1,16 @@
1
+ export function parseCmsAttr(attr) {
2
+ const dot1 = attr.indexOf('.');
3
+ const dot2 = attr.indexOf('.', dot1 + 1);
4
+ if (dot1 === -1 || dot2 === -1) return null;
5
+ return {
6
+ collection: attr.slice(0, dot1),
7
+ id: attr.slice(dot1 + 1, dot2),
8
+ field: attr.slice(dot2 + 1),
9
+ };
10
+ }
11
+
12
+ export function flash(el, success) {
13
+ const cls = success ? 'cms-saved' : 'cms-error';
14
+ el.classList.add(cls);
15
+ setTimeout(() => el.classList.remove(cls), 2000);
16
+ }
@@ -0,0 +1,148 @@
1
+ import { API_BASE } from './config.js';
2
+ import { uploadHeaders } from './security.js';
3
+
4
+ export function mountImageEditors({
5
+ parseCmsAttr,
6
+ flash,
7
+ compressImage,
8
+ updateEmblaCarousel,
9
+ saveField,
10
+ setStatus,
11
+ showToast,
12
+ onUnauthorized,
13
+ }) {
14
+ const syncBoundImages = (marker, src) => {
15
+ document.querySelectorAll('[data-cms-img]').forEach((node) => {
16
+ if (!(node instanceof HTMLImageElement)) return;
17
+ if (node.getAttribute('data-cms-img') !== marker) return;
18
+ node.src = src;
19
+ updateEmblaCarousel(node, src);
20
+ });
21
+ };
22
+
23
+ document.querySelectorAll('[data-cms-img]').forEach((img) => {
24
+ if (!(img instanceof HTMLImageElement)) return;
25
+ if (img.dataset.cmsImgMounted === 'true') return;
26
+ img.dataset.cmsImgMounted = 'true';
27
+
28
+ const existingWrapper = img.parentElement;
29
+ if (existingWrapper?.classList.contains('cms-img-wrapper')) {
30
+ existingWrapper.parentNode?.insertBefore(img, existingWrapper);
31
+ existingWrapper.remove();
32
+ }
33
+
34
+ const wrapper = document.createElement('div');
35
+ wrapper.className = 'cms-img-wrapper';
36
+ wrapper.style.position = 'relative';
37
+ wrapper.style.display = 'block';
38
+ wrapper.style.width = '100%';
39
+ wrapper.style.height = '100%';
40
+
41
+ img.parentNode?.insertBefore(wrapper, img);
42
+ wrapper.appendChild(img);
43
+
44
+ // Corner badge — persistent edit indicator
45
+ const badge = document.createElement('div');
46
+ badge.className = 'cms-img-badge';
47
+ badge.innerHTML =
48
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
49
+ wrapper.appendChild(badge);
50
+
51
+ // Full overlay with idle + uploading states
52
+ const overlay = document.createElement('button');
53
+ overlay.className = 'cms-img-overlay';
54
+ overlay.type = 'button';
55
+ overlay.innerHTML = `
56
+ <span class="cms-img-overlay-content cms-img-state-idle">
57
+ <svg class="cms-img-overlay-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
58
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/>
59
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"/>
60
+ </svg>
61
+ <span class="cms-img-overlay-text">Replace Image</span>
62
+ </span>
63
+ <span class="cms-img-overlay-content cms-img-state-uploading">
64
+ <span class="cms-img-spinner"></span>
65
+ <span class="cms-img-overlay-text">Uploading...</span>
66
+ </span>
67
+ `;
68
+ wrapper.appendChild(overlay);
69
+
70
+ const input = document.createElement('input');
71
+ input.type = 'file';
72
+ input.accept = 'image/jpeg,image/png,image/webp,image/avif';
73
+ input.style.display = 'none';
74
+ wrapper.appendChild(input);
75
+
76
+ overlay.addEventListener('click', (e) => {
77
+ e.stopPropagation(); // prevent slide click (lightbox)
78
+ input.click();
79
+ });
80
+
81
+ input.addEventListener('change', async () => {
82
+ const file = input.files?.[0];
83
+ if (!file) return;
84
+
85
+ const marker = img.getAttribute('data-cms-img') || '';
86
+ const parsed = parseCmsAttr(marker);
87
+ if (!parsed) return;
88
+
89
+ // Optimistic preview
90
+ const previewUrl = URL.createObjectURL(file);
91
+ const originalSrc = img.src;
92
+ img.src = previewUrl;
93
+
94
+ overlay.classList.add('uploading');
95
+ badge.classList.add('cms-img-badge-hidden');
96
+ setStatus('saving', 'Uploading...');
97
+
98
+ try {
99
+ const compressed = await compressImage(file);
100
+ const formData = new FormData();
101
+ formData.append('file', compressed);
102
+ const headers = await uploadHeaders();
103
+
104
+ const uploadRes = await fetch(`${API_BASE}/upload`, {
105
+ method: 'POST',
106
+ headers,
107
+ body: formData,
108
+ });
109
+ if (uploadRes.status === 401) {
110
+ onUnauthorized();
111
+ return;
112
+ }
113
+ if (!uploadRes.ok) throw new Error('Upload failed');
114
+ const { url } = await uploadRes.json();
115
+
116
+ const result = await saveField(parsed.collection, parsed.id, parsed.field, url);
117
+ if (result.ok) {
118
+ syncBoundImages(marker, url);
119
+ showToast('Image updated', 'success');
120
+ } else {
121
+ const rollbackSrc =
122
+ result.reason === 'conflict' && typeof result.latestValue === 'string'
123
+ ? result.latestValue
124
+ : originalSrc;
125
+ syncBoundImages(marker, rollbackSrc);
126
+ if (result.reason === 'conflict') {
127
+ showToast('Image changed elsewhere. Loaded latest image.', 'error');
128
+ } else if (result.reason !== 'unauthorized') {
129
+ showToast('Failed to save image. Changes reverted.', 'error');
130
+ }
131
+ }
132
+
133
+ flash(img, result.ok);
134
+ } catch {
135
+ img.src = originalSrc;
136
+ flash(img, false);
137
+ setStatus('error', 'Upload failed');
138
+ showToast('Upload failed', 'error');
139
+ } finally {
140
+ URL.revokeObjectURL(previewUrl);
141
+ overlay.classList.remove('uploading');
142
+ badge.classList.remove('cms-img-badge-hidden');
143
+ }
144
+
145
+ input.value = '';
146
+ });
147
+ });
148
+ }
@@ -0,0 +1,84 @@
1
+ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
2
+ if (file.size < 200000 && file.type === 'image/webp') return file;
3
+
4
+ const bitmap = await createImageBitmap(file);
5
+ let width = bitmap.width;
6
+ let height = bitmap.height;
7
+
8
+ const needsResize = width > maxWidth;
9
+
10
+ // PNGs: preserve lossless quality — only process if oversized
11
+ if (file.type === 'image/png' && !needsResize) {
12
+ bitmap.close();
13
+ return file;
14
+ }
15
+
16
+ if (needsResize) {
17
+ height = Math.round((height * maxWidth) / width);
18
+ width = maxWidth;
19
+ }
20
+
21
+ const canvas = document.createElement('canvas');
22
+ canvas.width = width;
23
+ canvas.height = height;
24
+ const ctx = canvas.getContext('2d');
25
+ ctx.drawImage(bitmap, 0, 0, width, height);
26
+ bitmap.close();
27
+
28
+ // Keep PNG format to preserve lossless quality; convert others to lossy WebP
29
+ const isPng = file.type === 'image/png';
30
+ const outType = isPng ? 'image/png' : 'image/webp';
31
+
32
+ const blob = await new Promise((resolve) =>
33
+ canvas.toBlob((b) => resolve(b), outType, isPng ? undefined : quality),
34
+ );
35
+
36
+ if (!blob || blob.size >= file.size) return file;
37
+
38
+ const ext = isPng ? '.png' : '.webp';
39
+ const name = file.name.replace(/\.[^.]+$/, ext);
40
+ return new File([blob], name, { type: outType });
41
+ }
42
+
43
+ export function updateEmblaCarousel(img, newUrl) {
44
+ // Find the closest Embla container
45
+ const emblaContainer = img.closest('.embla');
46
+ if (!emblaContainer) return;
47
+
48
+ // Find the slide this image belongs to
49
+ const slide = img.closest('.embla__slide');
50
+ if (!slide) return;
51
+
52
+ const slideIndex = parseInt(slide.dataset.index, 10);
53
+ if (isNaN(slideIndex)) return;
54
+
55
+ // Update the data-full attribute for lightbox
56
+ slide.dataset.full = newUrl;
57
+
58
+ // Update thumbnail if it exists
59
+ const thumbBtn = document.querySelector(`.thumb-btn[data-index="${slideIndex}"]`);
60
+ if (thumbBtn) {
61
+ const thumbImg = thumbBtn.querySelector('img');
62
+ if (thumbImg) {
63
+ thumbImg.src = newUrl;
64
+ // Add a flash effect to show it updated
65
+ thumbBtn.classList.add('cms-thumb-updated');
66
+ setTimeout(() => thumbBtn.classList.remove('cms-thumb-updated'), 1000);
67
+ }
68
+ }
69
+
70
+ // Update lightbox thumbnails if visible
71
+ const lightboxThumb = document.querySelector(`.lightbox-thumb[data-index="${slideIndex}"]`);
72
+ if (lightboxThumb) {
73
+ lightboxThumb.dataset.src = newUrl;
74
+ const lbImg = lightboxThumb.querySelector('img');
75
+ if (lbImg) lbImg.src = newUrl;
76
+ }
77
+
78
+ // Dispatch custom event that the product page can listen to
79
+ emblaContainer.dispatchEvent(
80
+ new CustomEvent('cms:imageUpdated', {
81
+ detail: { index: slideIndex, url: newUrl },
82
+ }),
83
+ );
84
+ }
@@ -0,0 +1,133 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Image editing overlay
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-img-wrapper {
6
+ position: relative;
7
+ z-index: 10;
8
+ outline: 2px solid transparent;
9
+ outline-offset: 2px;
10
+ transition: outline-color 0.2s;
11
+ }
12
+
13
+ .cms-img-wrapper:hover {
14
+ outline-color: var(--cms-blue);
15
+ }
16
+
17
+ /* Corner edit badge — persistent editability indicator */
18
+ .cms-img-badge {
19
+ position: absolute;
20
+ top: 8px;
21
+ right: 8px;
22
+ width: 26px;
23
+ height: 26px;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ background: rgba(0, 0, 0, 0.55);
28
+ backdrop-filter: blur(4px);
29
+ color: white;
30
+ border-radius: 4px;
31
+ opacity: 0.6;
32
+ transition: opacity 0.2s, transform 0.2s;
33
+ z-index: 12;
34
+ pointer-events: none;
35
+ }
36
+
37
+ .cms-img-wrapper:hover .cms-img-badge {
38
+ opacity: 0;
39
+ transform: scale(0.8);
40
+ }
41
+
42
+ .cms-img-badge-hidden {
43
+ opacity: 0 !important;
44
+ }
45
+
46
+ /* Full overlay */
47
+ .cms-img-overlay {
48
+ position: absolute;
49
+ inset: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ background: rgba(0, 0, 0, 0.55);
54
+ color: white;
55
+ font-family: var(--cms-font);
56
+ font-size: 13px;
57
+ font-weight: 500;
58
+ letter-spacing: 0.05em;
59
+ text-transform: uppercase;
60
+ cursor: pointer;
61
+ opacity: 0;
62
+ transition: opacity 0.25s ease;
63
+ border: none;
64
+ padding: 0;
65
+ z-index: 11;
66
+ }
67
+
68
+ .cms-img-wrapper:hover .cms-img-overlay {
69
+ opacity: 1;
70
+ }
71
+
72
+ .cms-img-overlay-content {
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: center;
76
+ gap: 10px;
77
+ }
78
+
79
+ .cms-img-overlay-icon {
80
+ width: 28px;
81
+ height: 28px;
82
+ opacity: 0.9;
83
+ }
84
+
85
+ .cms-img-overlay-text {
86
+ font-size: 11px;
87
+ letter-spacing: 0.12em;
88
+ }
89
+
90
+ /* Upload state — always visible with spinner */
91
+ .cms-img-state-uploading {
92
+ display: none;
93
+ }
94
+
95
+ .cms-img-overlay.uploading {
96
+ opacity: 1;
97
+ cursor: wait;
98
+ background: rgba(0, 0, 0, 0.7);
99
+ }
100
+
101
+ .cms-img-overlay.uploading .cms-img-state-idle {
102
+ display: none;
103
+ }
104
+
105
+ .cms-img-overlay.uploading .cms-img-state-uploading {
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: center;
109
+ gap: 10px;
110
+ }
111
+
112
+ .cms-img-spinner {
113
+ width: 28px;
114
+ height: 28px;
115
+ border: 2.5px solid rgba(255, 255, 255, 0.2);
116
+ border-top-color: white;
117
+ border-radius: 50%;
118
+ animation: cms-spin 0.7s linear infinite;
119
+ }
120
+
121
+ @keyframes cms-spin {
122
+ to { transform: rotate(360deg); }
123
+ }
124
+
125
+ /* Thumbnail update flash */
126
+ .cms-thumb-updated {
127
+ animation: cms-thumb-flash 1s ease-out;
128
+ }
129
+
130
+ @keyframes cms-thumb-flash {
131
+ 0% { outline: 2px solid var(--cms-green); outline-offset: 2px; }
132
+ 100% { outline: 2px solid transparent; outline-offset: 2px; }
133
+ }
@@ -0,0 +1,143 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Link popover — Ctrl+K popup for hyperlinks
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-link-popover {
6
+ position: fixed;
7
+ z-index: 100001;
8
+ width: 340px;
9
+ background: var(--cms-navy-deep);
10
+ border: 1px solid rgba(212, 175, 55, 0.15);
11
+ border-radius: 4px;
12
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
13
+ padding: 12px;
14
+ font-family: var(--cms-font);
15
+ animation: cms-popover-in 0.15s ease-out;
16
+ }
17
+
18
+ @keyframes cms-popover-in {
19
+ from { opacity: 0; transform: translateY(4px); }
20
+ to { opacity: 1; transform: translateY(0); }
21
+ }
22
+
23
+ .cms-link-popover-row {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 8px;
27
+ }
28
+
29
+ .cms-link-popover-icon {
30
+ color: var(--cms-gold);
31
+ flex-shrink: 0;
32
+ }
33
+
34
+ .cms-link-popover-input {
35
+ flex: 1;
36
+ background: rgba(255, 255, 255, 0.06);
37
+ border: 1px solid rgba(255, 255, 255, 0.1);
38
+ border-radius: 3px;
39
+ padding: 7px 10px;
40
+ font-family: var(--cms-font);
41
+ font-size: 12px;
42
+ color: white;
43
+ outline: none;
44
+ transition: border-color 0.2s;
45
+ }
46
+
47
+ .cms-link-popover-input:focus {
48
+ border-color: var(--cms-gold);
49
+ }
50
+
51
+ .cms-link-popover-input::placeholder {
52
+ color: rgba(255, 255, 255, 0.3);
53
+ }
54
+
55
+ .cms-link-popover-actions {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: space-between;
59
+ margin-top: 8px;
60
+ }
61
+
62
+ .cms-link-popover-hint {
63
+ font-size: 10px;
64
+ color: rgba(255, 255, 255, 0.25);
65
+ letter-spacing: 0.05em;
66
+ }
67
+
68
+ .cms-link-popover-btns {
69
+ display: flex;
70
+ gap: 6px;
71
+ }
72
+
73
+ .cms-link-popover-cancel,
74
+ .cms-link-popover-apply {
75
+ padding: 4px 12px;
76
+ font-family: var(--cms-font);
77
+ font-size: 10px;
78
+ font-weight: 500;
79
+ letter-spacing: 0.1em;
80
+ text-transform: uppercase;
81
+ border-radius: 2px;
82
+ cursor: pointer;
83
+ transition: all 0.2s;
84
+ border: 1px solid transparent;
85
+ }
86
+
87
+ .cms-link-popover-cancel {
88
+ background: transparent;
89
+ color: rgba(255, 255, 255, 0.4);
90
+ border-color: rgba(255, 255, 255, 0.08);
91
+ }
92
+
93
+ .cms-link-popover-cancel:hover {
94
+ color: rgba(255, 255, 255, 0.7);
95
+ border-color: rgba(255, 255, 255, 0.2);
96
+ }
97
+
98
+ .cms-link-popover-apply {
99
+ background: var(--cms-gold-dim);
100
+ color: var(--cms-gold);
101
+ border-color: rgba(212, 175, 55, 0.2);
102
+ }
103
+
104
+ .cms-link-popover-apply:hover {
105
+ background: rgba(212, 175, 55, 0.25);
106
+ border-color: rgba(212, 175, 55, 0.4);
107
+ }
108
+
109
+ /* ---------------------------------------------------------------------------
110
+ Link hint badge — shows above focused linkable fields
111
+ --------------------------------------------------------------------------- */
112
+
113
+ .cms-link-hint {
114
+ position: fixed;
115
+ z-index: 100000;
116
+ background: var(--cms-navy-deep);
117
+ color: rgba(255, 255, 255, 0.4);
118
+ font-family: var(--cms-font);
119
+ font-size: 9px;
120
+ letter-spacing: 0.1em;
121
+ text-transform: uppercase;
122
+ padding: 3px 8px;
123
+ border-radius: 2px;
124
+ pointer-events: none;
125
+ animation: cms-hint-in 0.2s ease-out;
126
+ white-space: nowrap;
127
+ }
128
+
129
+ @keyframes cms-hint-in {
130
+ from { opacity: 0; transform: translateY(4px); }
131
+ to { opacity: 1; transform: translateY(0); }
132
+ }
133
+
134
+ /* Linkable field visual indicator */
135
+ [data-cms-raw].cms-editable:hover {
136
+ outline-color: var(--cms-gold-dim);
137
+ }
138
+
139
+ [data-cms-raw].cms-editable:focus {
140
+ outline-color: var(--cms-gold);
141
+ box-shadow: 0 0 0 4px rgba(212, 175, 55, 0.1);
142
+ }
143
+
@@ -0,0 +1,55 @@
1
+ export function escapeHtml(s) {
2
+ return s
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;');
7
+ }
8
+
9
+ export function clientLinkify(text) {
10
+ if (!text) return '';
11
+
12
+ const MD = /\[([^\]]+)\]\(([^)]+)\)/;
13
+ const EMAIL = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
14
+ const PHONE = /(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/;
15
+ const URL_PAT =
16
+ /\bhttps?:\/\/[^\s<>"')\]]+|\b[\w.-]+\.(?:com|org|net|io|co|dev|app|store|shop|jewelry|diamonds|gold)(?:\/[^\s<>"')\]]*)?/;
17
+
18
+ const combined = new RegExp(
19
+ `(${MD.source})|(${EMAIL.source})|(${PHONE.source})|(${URL_PAT.source})`,
20
+ 'gi',
21
+ );
22
+
23
+ let result = '';
24
+ let lastIndex = 0;
25
+
26
+ for (const m of text.matchAll(combined)) {
27
+ const start = m.index;
28
+ result += escapeHtml(text.slice(lastIndex, start));
29
+
30
+ const [full, mdFull, mdText, mdUrl, email, phone] = m;
31
+
32
+ if (mdFull) {
33
+ const href = /^https?:\/\//i.test(mdUrl)
34
+ ? mdUrl
35
+ : /^[/]|^mailto:|^tel:/.test(mdUrl)
36
+ ? mdUrl
37
+ : 'https://' + mdUrl;
38
+ const ext = /^https?:\/\//i.test(href);
39
+ result += `<a href="${escapeHtml(href)}"${ext ? ' target="_blank" rel="noopener noreferrer"' : ''} class="text-secondary hover:underline">${escapeHtml(mdText)}</a>`;
40
+ } else if (email) {
41
+ result += `<a href="mailto:${escapeHtml(email)}" class="text-secondary hover:underline">${escapeHtml(email)}</a>`;
42
+ } else if (phone) {
43
+ const digits = full.replace(/\D/g, '');
44
+ result += `<a href="tel:${digits}" class="text-secondary hover:underline">${escapeHtml(full)}</a>`;
45
+ } else {
46
+ const href = /^https?:\/\//i.test(full) ? full : 'https://' + full;
47
+ result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="text-secondary hover:underline">${escapeHtml(full)}</a>`;
48
+ }
49
+
50
+ lastIndex = start + full.length;
51
+ }
52
+
53
+ result += escapeHtml(text.slice(lastIndex));
54
+ return result;
55
+ }