@brightsideoy/kama 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (125) hide show
  1. package/bin/cli.js +604 -364
  2. package/content/definitions/block-kama-forms.yaml +5 -0
  3. package/content/definitions/blocks/button.yaml +15 -5
  4. package/content/definitions/blocks/node-list.yaml +75 -0
  5. package/content/definitions/field-sets/channel-base.yaml +7 -0
  6. package/content/definitions/nodes/article.yaml +17 -1
  7. package/content/definitions/taxonomies/categories.yaml +5 -2
  8. package/content/definitions/taxonomies/tags.yaml +5 -2
  9. package/content/forms/contact.yaml +44 -0
  10. package/content/forms/subscription.yaml +32 -0
  11. package/dist/assets/AssetEditor-D5j7pwcK.css +1 -0
  12. package/dist/assets/AssetEditor-Dt1zCR7_.js +1 -0
  13. package/dist/assets/CmsBadge-BNsgxyyB.css +1 -0
  14. package/dist/assets/CmsBadge-D0Ob9qPU.js +1 -0
  15. package/dist/assets/CmsButton-BQ85-YXn.css +1 -0
  16. package/dist/assets/CmsButton-CbOfxvjr.js +1 -0
  17. package/dist/assets/CmsDataRow--H4iI_u_.js +1 -0
  18. package/dist/assets/CmsDataRow-CYNxfVaZ.css +1 -0
  19. package/dist/assets/CmsHelp-BO_vWABq.css +1 -0
  20. package/dist/assets/CmsHelp-DyPJLGGi.js +1 -0
  21. package/dist/assets/CmsImagePreview-CujwG8r_.css +1 -0
  22. package/dist/assets/CmsImagePreview-DuHPBzml.js +1 -0
  23. package/dist/assets/CmsPaneResizer-B62ZqsnE.js +1 -0
  24. package/dist/assets/CmsPaneResizer-Dkidd4pI.css +1 -0
  25. package/dist/assets/CmsPublisher-CugEqTD8.css +1 -0
  26. package/dist/assets/CmsPublisher-DNLwjirk.js +3 -0
  27. package/dist/assets/CmsUserCenter-Ac0GywlR.css +1 -0
  28. package/dist/assets/CmsUserCenter-D3XDzKDC.js +1 -0
  29. package/dist/assets/EditorSplitPreview-BzKh4wFB.css +1 -0
  30. package/dist/assets/EditorSplitPreview-Cp2x0O_P.js +1 -0
  31. package/dist/assets/NodeContextMenu-BYeWzmQ5.css +1 -0
  32. package/dist/assets/NodeContextMenu-C_HWBGeb.js +1 -0
  33. package/dist/assets/NodeEditor-CiXQKXK1.js +147 -0
  34. package/dist/assets/NodeEditor-pZI3gu4a.css +1 -0
  35. package/dist/assets/StatusPill-CbeW5SMT.js +1 -0
  36. package/dist/assets/StatusPill-Dlm6uvGe.css +1 -0
  37. package/dist/assets/TrashWorkspace-DgIg1v6t.js +1 -0
  38. package/dist/assets/TrashWorkspace-DtMgxg13.css +1 -0
  39. package/dist/assets/_plugin-vue_export-helper-CQxWhVtr.js +5 -0
  40. package/dist/assets/circle-check-A-QeFqt3.js +1 -0
  41. package/dist/assets/database-hg5SqKLG.js +1 -0
  42. package/dist/assets/folder-tree-BWYot72H.js +1 -0
  43. package/dist/assets/globe-BraoljaN.js +1 -0
  44. package/dist/assets/history-pqIbKK9u.js +1 -0
  45. package/dist/assets/index-DQ6_RmCI.js +14 -0
  46. package/dist/assets/index-DcKiaRjI.css +1 -0
  47. package/dist/assets/layers-B6RgfpPR.js +1 -0
  48. package/dist/assets/loader-circle-BoUkhDjq.js +1 -0
  49. package/dist/assets/monitor-CrpvInqG.js +1 -0
  50. package/dist/assets/panel-left-close-CVFakVaA.js +1 -0
  51. package/dist/assets/plus-FQRsZBoJ.js +1 -0
  52. package/dist/assets/rotate-ccw-CWE4pOjs.js +1 -0
  53. package/dist/assets/save-Ctv8vW9H.js +1 -0
  54. package/dist/assets/schema-helpers-DvficKko.js +1 -0
  55. package/dist/assets/send-F-mJeL54.js +1 -0
  56. package/dist/assets/smartphone-EPce6VdP.js +1 -0
  57. package/dist/assets/trash-2-CAUF1KtI.js +1 -0
  58. package/dist/assets/useLocalStorage-C805uplI.js +1 -0
  59. package/dist/assets/useNodes-D2-VxsxR.js +1 -0
  60. package/dist/assets/user-IFlT6mYq.js +1 -0
  61. package/dist/build-seed.js +495 -0
  62. package/dist/{dist-Dy7-HUEG.js → dist-D_Q1y4Kh.js} +2314 -1180
  63. package/dist/index.html +34 -2
  64. package/dist/index.js +2298 -1158
  65. package/dist/schema-compiler.js +5 -5
  66. package/documentation/EXTENSIONS.md +204 -0
  67. package/extension-registry.js +5 -0
  68. package/package.json +9 -2
  69. package/schema.sql +34 -1
  70. package/templates/astro-starter/old_src/components/blocks/BlockRenderer.astro +143 -0
  71. package/templates/astro-starter/old_src/components/blocks/ColumnLayout.astro +67 -0
  72. package/templates/astro-starter/old_src/components/blocks/HeaderImage.astro +126 -0
  73. package/templates/astro-starter/old_src/components/blocks/Hero.astro +218 -0
  74. package/templates/astro-starter/old_src/components/blocks/HtmlBlock.astro +25 -0
  75. package/templates/astro-starter/old_src/components/blocks/ImageBlock.astro +91 -0
  76. package/templates/astro-starter/old_src/components/blocks/NodeList.astro +274 -0
  77. package/templates/astro-starter/old_src/components/blocks/TextBlock.astro +15 -0
  78. package/templates/astro-starter/old_src/components/global/Breadcrumb.astro +108 -0
  79. package/templates/astro-starter/old_src/components/global/Footer.astro +50 -0
  80. package/templates/astro-starter/old_src/components/global/Header.astro +171 -0
  81. package/templates/astro-starter/old_src/components/global/MetaHead.astro +266 -0
  82. package/templates/astro-starter/old_src/components/global/NavigationMenu.astro +413 -0
  83. package/templates/astro-starter/old_src/components/ui/KamaImage.astro +180 -0
  84. package/templates/astro-starter/old_src/layouts/SkeletonLayout.astro +56 -0
  85. package/templates/astro-starter/old_src/layouts/StandardLayout.astro +63 -0
  86. package/templates/astro-starter/old_src/lib/cms.ts +219 -0
  87. package/templates/astro-starter/old_src/lib/media/kama-image-service.ts +67 -0
  88. package/templates/astro-starter/old_src/lib/media/media-proxy.ts +148 -0
  89. package/templates/astro-starter/old_src/lib/media/media-url.ts +112 -0
  90. package/templates/astro-starter/old_src/middleware.ts +536 -0
  91. package/templates/astro-starter/old_src/pages/404.astro +46 -0
  92. package/templates/astro-starter/old_src/pages/[...slug].astro +311 -0
  93. package/templates/astro-starter/old_src/pages/api/load-nodes.ts +68 -0
  94. package/templates/astro-starter/old_src/pages/api/partials/node-cards.astro +101 -0
  95. package/templates/astro-starter/old_src/pages/llms-full.txt.ts +171 -0
  96. package/templates/astro-starter/old_src/pages/llms.txt.ts +113 -0
  97. package/templates/astro-starter/old_src/pages/media/[...path].ts +14 -0
  98. package/templates/astro-starter/old_src/pages/partials/footer.astro +69 -0
  99. package/templates/astro-starter/old_src/pages/partials/nav.astro +141 -0
  100. package/templates/astro-starter/old_src/styles/standard-theme.css +157 -0
  101. package/templates/astro-starter/old_src/utils/tiptap.ts +113 -0
  102. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +9 -0
  103. package/templates/astro-starter/src/components/blocks/Form.astro +143 -0
  104. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +6 -12
  105. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +5 -6
  106. package/templates/astro-starter/src/components/blocks/NodeList.astro +274 -0
  107. package/templates/astro-starter/src/components/global/Breadcrumb.astro +50 -9
  108. package/templates/astro-starter/src/components/global/Footer.astro +7 -46
  109. package/templates/astro-starter/src/components/global/Header.astro +10 -16
  110. package/templates/astro-starter/src/components/global/NavigationMenu.astro +57 -46
  111. package/templates/astro-starter/src/components/ui/KamaImage.astro +13 -5
  112. package/templates/astro-starter/src/layouts/StandardLayout.astro +4 -3
  113. package/templates/astro-starter/src/lib/cms.ts +33 -11
  114. package/templates/astro-starter/src/lib/media/kama-image-service.ts +15 -4
  115. package/templates/astro-starter/src/lib/media/media-proxy.ts +22 -3
  116. package/templates/astro-starter/src/lib/media/media-url.ts +17 -3
  117. package/templates/astro-starter/src/middleware.ts +158 -100
  118. package/templates/astro-starter/src/pages/[...slug].astro +77 -3
  119. package/templates/astro-starter/src/pages/api/load-nodes.ts +68 -0
  120. package/templates/astro-starter/src/pages/api/partials/node-cards.astro +101 -0
  121. package/templates/kama-worker/index.js +38 -15
  122. package/templates/kama-worker/server.js +13 -5
  123. package/templates/kama-worker/setup-local-db.mjs +17 -0
  124. package/dist/assets/index-F2rfUymo.css +0 -1
  125. package/dist/assets/index-lXxH3VFK.js +0 -164
@@ -0,0 +1,143 @@
1
+ ---
2
+ // src/components/blocks/Form.astro
3
+ interface Props {
4
+ data: {
5
+ formId: string;
6
+ };
7
+ }
8
+
9
+ const { data } = Astro.props;
10
+ const formId = data?.formId;
11
+
12
+ const kamaApiUrl = import.meta.env.KAMA_API_URL || 'http://localhost:8787';
13
+ const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
14
+
15
+ // Fetch form YAML schema at render time
16
+ let schema = null;
17
+ if (formId) {
18
+ try {
19
+ const res = await fetch(`${kamaApiUrl}/api/ext/kama-forms/render/${formId}`);
20
+ if (res.ok) schema = await res.json();
21
+ } catch (err) {
22
+ console.error(`Failed to fetch schema for ${formId}`);
23
+ }
24
+ }
25
+
26
+ const requiresTurnstile = schema?.settings?.requireTurnstile === true;
27
+ ---
28
+
29
+ {schema ? (
30
+ <div class="kama-form-wrapper" data-form-id={formId} data-api-url={kamaApiUrl}>
31
+
32
+ <!-- 🎯 Conditionally load Cloudflare Turnstile Script -->
33
+ {requiresTurnstile && (
34
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
35
+ )}
36
+
37
+ <div class="form-header">
38
+ <h3>{schema.title}</h3>
39
+ {schema.description && <p>{schema.description}</p>}
40
+ </div>
41
+
42
+ <div class="form-alert success" style="display: none;"></div>
43
+ <div class="form-alert error" style="display: none;"></div>
44
+
45
+ <form class="kama-public-form">
46
+ {schema.fields?.map((field: any) => (
47
+ <div class="form-group">
48
+ <label for={`field-${field.name}`}>
49
+ {field.label}
50
+ {field.required && <span class="required">*</span>}
51
+ </label>
52
+
53
+ {field.type === 'textarea' ? (
54
+ <textarea id={`field-${field.name}`} name={field.name} placeholder={field.placeholder || ''} required={field.required} rows="4" />
55
+ ) : field.type === 'select' ? (
56
+ <select id={`field-${field.name}`} name={field.name} required={field.required}>
57
+ <option value="" disabled selected>{field.placeholder || 'Select...'}</option>
58
+ {field.options?.map((opt: any) => (
59
+ <option value={opt.value}>{opt.label}</option>
60
+ ))}
61
+ </select>
62
+ ) : (
63
+ <input type={field.type || 'text'} id={`field-${field.name}`} name={field.name} placeholder={field.placeholder || ''} required={field.required} />
64
+ )}
65
+ </div>
66
+ ))}
67
+
68
+ <!-- 🎯 Turnstile Widget renders here -->
69
+ {requiresTurnstile && turnstileSiteKey && (
70
+ <div class="form-group turnstile-container">
71
+ <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
72
+ </div>
73
+ )}
74
+
75
+ <button type="submit" class="submit-btn">Submit</button>
76
+ </form>
77
+ </div>
78
+ ) : (
79
+ <p class="form-error">Form definition "{formId}" could not be loaded.</p>
80
+ )}
81
+
82
+ <script>
83
+ document.querySelectorAll('.kama-form-wrapper').forEach((container) => {
84
+ const form = container.querySelector<HTMLFormElement>('.kama-public-form');
85
+ const formId = container.getAttribute('data-form-id');
86
+ const apiUrl = container.getAttribute('data-api-url');
87
+ const successBox = container.querySelector<HTMLDivElement>('.form-alert.success');
88
+ const errorBox = container.querySelector<HTMLDivElement>('.form-alert.error');
89
+ const submitBtn = container.querySelector<HTMLButtonElement>('.submit-btn');
90
+
91
+ if (!form || !formId || !apiUrl) return;
92
+
93
+ form.addEventListener('submit', async (e) => {
94
+ e.preventDefault();
95
+ if (submitBtn) submitBtn.disabled = true;
96
+ if (successBox) successBox.style.display = 'none';
97
+ if (errorBox) errorBox.style.display = 'none';
98
+
99
+ // FormData automatically picks up the hidden "cf-turnstile-response" input
100
+ const formData = new FormData(form);
101
+ const payload = Object.fromEntries(formData.entries());
102
+
103
+ try {
104
+ const res = await fetch(`${apiUrl}/api/ext/kama-forms/submit/${formId}`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify(payload)
108
+ });
109
+
110
+ const result = await res.json();
111
+
112
+ if (res.ok && result.success) {
113
+ if (successBox) {
114
+ successBox.textContent = result.message || 'Form submitted successfully!';
115
+ successBox.style.display = 'block';
116
+ }
117
+ form.reset();
118
+
119
+ // 🎯 Reset Turnstile after successful submission if the user wants to submit again
120
+ if (window.turnstile) {
121
+ window.turnstile.reset();
122
+ }
123
+ } else {
124
+ if (errorBox) {
125
+ errorBox.textContent = result.error || 'Form submission failed.';
126
+ errorBox.style.display = 'block';
127
+ }
128
+ // 🎯 Reset Turnstile on failure so they get a fresh token
129
+ if (window.turnstile) {
130
+ window.turnstile.reset();
131
+ }
132
+ }
133
+ } catch (err) {
134
+ if (errorBox) {
135
+ errorBox.textContent = 'Server connection error.';
136
+ errorBox.style.display = 'block';
137
+ }
138
+ } finally {
139
+ if (submitBtn) submitBtn.disabled = false;
140
+ }
141
+ });
142
+ });
143
+ </script>
@@ -45,13 +45,13 @@ if (region.includes("_")) {
45
45
  }
46
46
 
47
47
  /**
48
- * Default single-column layout.
48
+ * Default single-column layout (aligned to backend CDN buckets).
49
49
  */
50
50
  let maxWidth = "1352";
51
51
  let targetColumnWidth = 1352;
52
52
 
53
- // 1-col: Base mobile (~400), 2x mobile/tablet (~800), 1x desktop (1352), 2x desktop (2704)
54
- let optimizedWidths = [400, 800, 1352, 2048, 2704];
53
+ // 1-col: 1x/2x desktop and retina buckets
54
+ let optimizedWidths = [768, 1352, 1920, 2560];
55
55
  let sizes = "(max-width: 1399px) calc(100vw - 48px), 1352px";
56
56
 
57
57
  /**
@@ -61,8 +61,7 @@ if (columnCount === 2) {
61
61
  maxWidth = "660";
62
62
  targetColumnWidth = 660;
63
63
 
64
- // 2-col: 1x desktop (660), 2x mobile stacked (~800), 2x desktop (1320)
65
- optimizedWidths = [400, 660, 800, 1320];
64
+ optimizedWidths = [640, 768, 1024, 1352];
66
65
  sizes =
67
66
  "(max-width: 767px) calc(100vw - 48px), " +
68
67
  "(max-width: 1399px) calc((100vw - 80px) / 2), " +
@@ -76,8 +75,7 @@ else if (columnCount === 3) {
76
75
  maxWidth = "429";
77
76
  targetColumnWidth = 429;
78
77
 
79
- // 3-col: 1x desktop (429), 2x desktop/tablet (~858), 2x/3x mobile stacked (~1000)
80
- optimizedWidths = [300, 429, 858, 1024];
78
+ optimizedWidths = [320, 640, 768, 1024];
81
79
  sizes =
82
80
  "(max-width: 767px) calc(100vw - 48px), " +
83
81
  "(max-width: 1399px) calc((100vw - 112px) / 3), " +
@@ -91,8 +89,7 @@ else if (columnCount === 4) {
91
89
  maxWidth = "314";
92
90
  targetColumnWidth = 314;
93
91
 
94
- // 4-col: 1x desktop (314), 2x desktop (628), 2x/3x mobile stacked (~1000)
95
- optimizedWidths = [264, 314, 628, 1024];
92
+ optimizedWidths = [320, 640, 768];
96
93
  sizes =
97
94
  "(max-width: 767px) calc(100vw - 48px), " +
98
95
  "(max-width: 1399px) calc((100vw - 144px) / 4), " +
@@ -106,12 +103,9 @@ else if (columnCount === 4) {
106
103
  sizes={sizes}
107
104
  desktopWidths={optimizedWidths}
108
105
  idealWidth={targetColumnWidth}
109
- width={data.width || data.image_meta?.width}
110
- height={data.height || data.image_meta?.height}
111
106
  class="responsive-header-image"
112
107
  loading={isLcp ? "eager" : "lazy"}
113
108
  fetchpriority={isLcp ? "high" : "auto"}
114
- decoding={isLcp ? "sync" : "async"}
115
109
  />
116
110
  </div>
117
111
 
@@ -26,31 +26,31 @@ if (region.includes("_")) {
26
26
  if (region.includes("_col4")) columnCount = 4;
27
27
  }
28
28
 
29
- // 🎯 Column Config Lookup (Max width, breakpoint widths, sizes)
29
+ // 🎯 Column Config Lookup (Target width, aligned CDN breakpoint widths, sizes)
30
30
  const COLUMN_CONFIGS: Record<
31
31
  number,
32
32
  { targetWidth: number; widths: number[]; sizes: string }
33
33
  > = {
34
34
  1: {
35
35
  targetWidth: 1352,
36
- widths: [768, 1352],
36
+ widths: [768, 1352, 1920, 2560],
37
37
  sizes: "(max-width: 1399px) calc(100vw - 48px), 1352px",
38
38
  },
39
39
  2: {
40
40
  targetWidth: 660,
41
- widths: [400, 660],
41
+ widths: [640, 768, 1024, 1352],
42
42
  sizes:
43
43
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 80px) / 2), 660px",
44
44
  },
45
45
  3: {
46
46
  targetWidth: 429,
47
- widths: [300, 429],
47
+ widths: [320, 640, 768, 1024],
48
48
  sizes:
49
49
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 112px) / 3), 429px",
50
50
  },
51
51
  4: {
52
52
  targetWidth: 314,
53
- widths: [264, 314],
53
+ widths: [320, 640, 768],
54
54
  sizes:
55
55
  "(max-width: 767px) calc(100vw - 48px), (max-width: 1399px) calc((100vw - 144px) / 4), 314px",
56
56
  },
@@ -67,7 +67,6 @@ const layout = COLUMN_CONFIGS[columnCount] || COLUMN_CONFIGS[1];
67
67
  sizes={layout.sizes}
68
68
  desktopWidths={layout.widths}
69
69
  idealWidth={layout.targetWidth}
70
- maxWidth={layout.targetWidth}
71
70
  class="free-crop-image"
72
71
  loading={isLcp ? "eager" : "lazy"}
73
72
  fetchpriority={isLcp ? "high" : "auto"}
@@ -0,0 +1,274 @@
1
+ ---
2
+ // src/components/blocks/NodeList.astro
3
+ import KamaImage from "@/components/ui/KamaImage.astro";
4
+
5
+ interface Props {
6
+ data: Record<string, any>;
7
+ }
8
+
9
+ const { data } = Astro.props;
10
+ const items = data.initialItems || [];
11
+ const listTitle = data.listTitle || "";
12
+ ---
13
+
14
+ <div class="cms-block node-list-wrapper">
15
+ {listTitle && <h2 class="node-list-title">{listTitle}</h2>}
16
+
17
+ {
18
+ items.length === 0 ? (
19
+ <p class="empty-state">No items found.</p>
20
+ ) : (
21
+ <div class="node-grid" id={`grid-${data.blockId}`}>
22
+ {items.map((item: any) => (
23
+ <article class="node-card">
24
+ <a href={`/${item.slug}`} class="node-card-link">
25
+ {/* 🎯 ARCHITECTURAL FIX: Use KamaImage for initial load optimization */}
26
+ {item.image ? (
27
+ <KamaImage
28
+ image={item.image}
29
+ sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
30
+ desktopWidths={[320, 640, 768, 1024]}
31
+ idealWidth={429}
32
+ loading="lazy"
33
+ />
34
+ ) : (
35
+ <div class="image-placeholder" />
36
+ )}
37
+ <div class="node-card-content">
38
+ <h3 class="node-card-title">{item.title}</h3>
39
+ {item.createdAt && (
40
+ <time datetime={item.createdAt} class="node-card-date">
41
+ {new Date(item.createdAt).toLocaleDateString("en-GB", {
42
+ day: "numeric",
43
+ month: "short",
44
+ year: "numeric",
45
+ })}
46
+ </time>
47
+ )}
48
+ {item.excerpt && (
49
+ <p class="node-card-excerpt">{item.excerpt}</p>
50
+ )}
51
+ </div>
52
+ </a>
53
+ </article>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ {
60
+ data.loadMoreUrl && (
61
+ <div class="load-more-container">
62
+ <button
63
+ class="load-more-btn"
64
+ data-url={data.loadMoreUrl}
65
+ data-target={`grid-${data.blockId}`}
66
+ >
67
+ Load More
68
+ </button>
69
+ </div>
70
+ )
71
+ }
72
+ </div>
73
+
74
+ <style is:global>
75
+ .node-list-wrapper {
76
+ margin: 2rem 0;
77
+ }
78
+
79
+ .node-list-title {
80
+ font-size: 1.5rem;
81
+ font-weight: 700;
82
+ margin-bottom: 1.5rem;
83
+ color: #1e293b;
84
+ }
85
+
86
+ .node-list-wrapper .empty-state {
87
+ color: #64748b;
88
+ font-style: italic;
89
+ }
90
+
91
+ .node-grid {
92
+ display: grid;
93
+ grid-template-columns: repeat(1, 1fr);
94
+ gap: 1.5rem;
95
+ }
96
+
97
+ @media (min-width: 640px) {
98
+ .node-grid {
99
+ grid-template-columns: repeat(2, 1fr);
100
+ }
101
+ }
102
+
103
+ @media (min-width: 1024px) {
104
+ .node-grid {
105
+ grid-template-columns: repeat(3, 1fr);
106
+ }
107
+ }
108
+
109
+ .node-card {
110
+ border: 1px solid #e2e8f0;
111
+ border-radius: 8px;
112
+ overflow: hidden;
113
+ background: #fff;
114
+ transition:
115
+ transform 0.2s ease,
116
+ box-shadow 0.2s ease;
117
+ }
118
+
119
+ .node-card:hover {
120
+ transform: translateY(-2px);
121
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
122
+ }
123
+
124
+ .node-card-link {
125
+ text-decoration: none;
126
+ color: inherit;
127
+ display: flex;
128
+ flex-direction: column;
129
+ height: 100%;
130
+ }
131
+
132
+ /* 🎯 Because of is:global, this styling will automatically apply to <KamaImage> outputs! */
133
+ .node-card img,
134
+ .node-card .image-placeholder {
135
+ width: 100%;
136
+ height: auto; /* 🎯 FIX: Allows tall images to display their natural height */
137
+ object-fit: cover;
138
+ background: #f1f5f9;
139
+ }
140
+
141
+ .node-card .image-placeholder {
142
+ aspect-ratio: 16 / 9; /* Keep the placeholder a rectangle if there is no image at all */
143
+ }
144
+ .node-card-content {
145
+ padding: 1.25rem;
146
+ display: flex;
147
+ flex-direction: column;
148
+ flex-grow: 1;
149
+ }
150
+
151
+ .node-card-title {
152
+ font-size: 1.125rem;
153
+ font-weight: 600;
154
+ margin: 0 0 0.5rem 0;
155
+ color: #0f172a;
156
+ line-height: 1.4;
157
+ }
158
+
159
+ .node-card-date {
160
+ font-size: 0.875rem;
161
+ color: #64748b;
162
+ margin-bottom: 0.75rem;
163
+ }
164
+
165
+ .node-card-excerpt {
166
+ font-size: 0.9375rem;
167
+ color: #475569;
168
+ margin: 0;
169
+ line-height: 1.5;
170
+ display: -webkit-box;
171
+ -webkit-line-clamp: 3;
172
+ -webkit-box-orient: vertical;
173
+ overflow: hidden;
174
+ }
175
+
176
+ .load-more-container {
177
+ margin-top: 2rem;
178
+ text-align: center;
179
+ }
180
+
181
+ .load-more-btn {
182
+ padding: 0.75rem 1.5rem;
183
+ background: #0f172a;
184
+ color: #fff;
185
+ border: none;
186
+ border-radius: 6px;
187
+ font-weight: 600;
188
+ cursor: pointer;
189
+ transition: background 0.2s;
190
+ }
191
+
192
+ .load-more-btn:hover {
193
+ background: #334155;
194
+ }
195
+ </style>
196
+
197
+ <script>
198
+ function initLoadMoreButtons() {
199
+ const buttons = document.querySelectorAll(".load-more-btn:not(.is-bound)");
200
+
201
+ buttons.forEach(btn => {
202
+ btn.classList.add("is-bound");
203
+
204
+ btn.addEventListener("click", async e => {
205
+ const targetBtn = e.currentTarget as HTMLButtonElement;
206
+ const urlString = targetBtn.getAttribute("data-url");
207
+ const targetId = targetBtn.getAttribute("data-target");
208
+
209
+ if (!urlString || !targetId) return;
210
+
211
+ const grid = document.getElementById(targetId);
212
+ if (!grid) return;
213
+
214
+ const urlObj = new URL(urlString, window.location.origin);
215
+ // 🎯 ARCHITECTURAL FIX: Route to the new HTML Partial Endpoint
216
+ const proxyUrl = `/api/partials/node-cards${urlObj.search}`;
217
+
218
+ targetBtn.textContent = "Loading...";
219
+ targetBtn.disabled = true;
220
+
221
+ try {
222
+ const res = await fetch(proxyUrl);
223
+
224
+ if (!res.ok) {
225
+ throw new Error(`HTTP error! status: ${res.status}`);
226
+ }
227
+
228
+ // 1. Read metadata from headers sent by our partial endpoint
229
+ const rawCountStr = res.headers.get("X-Consumed-Rows");
230
+ const limitStr = res.headers.get("X-Limit");
231
+ const consumedRows = parseInt(rawCountStr || "0", 10);
232
+ const limit = parseInt(limitStr || "12", 10);
233
+
234
+ // 2. Extract and render the raw HTML
235
+ const htmlSnippet = await res.text();
236
+
237
+ if (htmlSnippet.trim().length > 0) {
238
+ grid.insertAdjacentHTML("beforeend", htmlSnippet);
239
+ }
240
+
241
+ // 3. Handle Pagination
242
+ if (consumedRows >= limit && limit > 0) {
243
+ const currentOffset = parseInt(
244
+ urlObj.searchParams.get("offset") || "0",
245
+ 10,
246
+ );
247
+ urlObj.searchParams.set(
248
+ "offset",
249
+ (currentOffset + limit).toString(),
250
+ );
251
+
252
+ targetBtn.setAttribute("data-url", urlObj.pathname + urlObj.search);
253
+ targetBtn.textContent = "Load More";
254
+ targetBtn.disabled = false;
255
+ } else {
256
+ if (targetBtn.parentElement)
257
+ targetBtn.parentElement.style.display = "none";
258
+ }
259
+ } catch (err) {
260
+ console.error("Failed to load more items:", err);
261
+ targetBtn.textContent = "Error. Try again";
262
+ targetBtn.disabled = false;
263
+ }
264
+ });
265
+ });
266
+ }
267
+
268
+ if (document.readyState === "loading") {
269
+ document.addEventListener("DOMContentLoaded", initLoadMoreButtons);
270
+ } else {
271
+ initLoadMoreButtons();
272
+ }
273
+ document.addEventListener("astro:page-load", initLoadMoreButtons);
274
+ </script>
@@ -7,24 +7,60 @@ interface Props {
7
7
  isHomePage?: boolean;
8
8
  }
9
9
 
10
- const { title, slug, isHomePage } = Astro.props;
10
+ const { title, isHomePage } = Astro.props;
11
11
 
12
- // 🎯 Detect home page via URL pathname or explicit flag
13
- const isHome = isHomePage || Astro.url.pathname === "/" || !slug || slug === "";
12
+ const currentPath = Astro.url.pathname;
13
+
14
+ // Extract non-empty path segments
15
+ const rawSegments = currentPath.split("/").filter(Boolean);
16
+
17
+ // Detect homepage
18
+ const isHome = isHomePage || currentPath === "/" || rawSegments.length === 0;
19
+
20
+ function formatSegmentTitle(segment: string): string {
21
+ return segment
22
+ .split("-")
23
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
24
+ .join(" ");
25
+ }
26
+
27
+ // Build progressive paths: /some-page, /some-page/child-page, etc.
28
+ let runningPath = "";
29
+ const breadcrumbItems = rawSegments.map((segment, index) => {
30
+ runningPath += `/${segment}`;
31
+ const isLast = index === rawSegments.length - 1;
32
+
33
+ return {
34
+ label: isLast ? title : formatSegmentTitle(segment),
35
+ href: runningPath,
36
+ isCurrent: isLast,
37
+ };
38
+ });
14
39
  ---
15
40
 
16
41
  <nav class="breadcrumb-nav" aria-label="Breadcrumb">
17
42
  <ol class="breadcrumb-list">
43
+ <!-- Home Link -->
18
44
  <li class="breadcrumb-item">
19
45
  <a href="/">Home</a>
20
46
  </li>
47
+
48
+ <!-- Subpage Breadcrumbs -->
21
49
  {
22
- !isHome && (
23
- <li class="breadcrumb-item active" aria-current="page">
24
- <span class="separator">/</span>
25
- <span>{title}</span>
26
- </li>
27
- )
50
+ !isHome &&
51
+ breadcrumbItems.map(item => (
52
+ <li
53
+ class={`breadcrumb-item ${item.isCurrent ? "active" : ""}`}
54
+ aria-current={item.isCurrent ? "page" : undefined}
55
+ >
56
+ <span class="separator">/</span>
57
+ {item.isCurrent ? (
58
+ <span>{item.label}</span>
59
+ ) : (
60
+ <a href={item.href}>{item.label}</a>
61
+ )}
62
+ </li>
63
+ ))
28
64
  }
29
65
  </ol>
30
66
  </nav>
@@ -45,6 +81,11 @@ const isHome = isHomePage || Astro.url.pathname === "/" || !slug || slug === "";
45
81
  color: #64748b;
46
82
  }
47
83
 
84
+ .breadcrumb-item {
85
+ display: flex;
86
+ align-items: center;
87
+ }
88
+
48
89
  .breadcrumb-item a {
49
90
  color: #334155;
50
91
  text-decoration: none;
@@ -2,60 +2,21 @@
2
2
  // src/components/global/Footer.astro
3
3
 
4
4
  import BlockRenderer from "@/components/blocks/BlockRenderer.astro";
5
- import { getChannelId } from "@/lib/cms";
6
5
 
7
- // 🎯 Fetch data natively during the server request
8
- const safeEnv = (Astro.locals as any).safeEnv || {};
9
- const cmsWorker = safeEnv.CMS_WORKER;
10
- const channelId = getChannelId() || "website";
11
-
12
- const customFetch = (input: RequestInfo | URL, init?: RequestInit) => {
13
- const reqInit = { ...init };
14
- delete reqInit.cache;
15
-
16
- let urlStr = input instanceof URL ? input.toString() : String(input);
17
- if (urlStr.startsWith("/")) {
18
- const baseUrl =
19
- import.meta.env.PUBLIC_API_URL ||
20
- safeEnv.PUBLIC_API_URL ||
21
- "http://127.0.0.1:8787";
22
- urlStr = `${baseUrl}${urlStr}`;
23
- }
24
-
25
- const req = new Request(urlStr, reqInit);
26
- if (cmsWorker && typeof cmsWorker.fetch === "function") {
27
- return cmsWorker.fetch(req);
28
- }
29
- return fetch(req);
30
- };
31
-
32
- // 🎯 Resolve the Home Page slug
33
- const homeSlug = String(import.meta.env.PUBLIC_HOME_SLUG || "home").trim();
34
-
35
- let footerBlocks: any[] = [];
36
- let contentMap: Record<string, any[]> = {};
37
-
38
- try {
39
- const res = await customFetch(
40
- `/api/v1/node/${homeSlug}?channelId=${channelId}`,
41
- );
42
- if (res.ok) {
43
- const data = await res.json();
44
- const allContent = data.content || {};
45
- footerBlocks = allContent.footer || [];
46
- contentMap = allContent;
47
- }
48
- } catch (e) {
49
- console.error("[Footer] Failed to fetch home page blocks:", e);
6
+ interface Props {
7
+ blocks?: any[];
8
+ contentMap?: Record<string, any[]>;
50
9
  }
10
+
11
+ const { blocks = [], contentMap = {} } = Astro.props;
51
12
  ---
52
13
 
53
14
  <footer class="site-footer">
54
15
  <div class="footer-container">
55
16
  {
56
- footerBlocks && footerBlocks.length > 0 ? (
17
+ blocks && blocks.length > 0 ? (
57
18
  <BlockRenderer
58
- blocks={footerBlocks}
19
+ blocks={blocks}
59
20
  contentMap={contentMap}
60
21
  region="footer"
61
22
  />