@contentgrowth/content-widget 1.1.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.
- package/LICENSE +21 -0
- package/README.md +442 -0
- package/dist/astro/ContentList.astro +177 -0
- package/dist/astro/ContentViewer.astro +252 -0
- package/dist/astro/index.d.ts +9 -0
- package/dist/astro/index.d.ts.map +1 -0
- package/dist/astro/index.js +8 -0
- package/dist/core/client.d.ts +67 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +217 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/utils.d.ts +32 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +70 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/react/ContentList.d.ts +12 -0
- package/dist/react/ContentList.d.ts.map +1 -0
- package/dist/react/ContentList.js +106 -0
- package/dist/react/ContentViewer.d.ts +12 -0
- package/dist/react/ContentViewer.d.ts.map +1 -0
- package/dist/react/ContentViewer.js +97 -0
- package/dist/react/hooks.d.ts +63 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +140 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/styles.css +970 -0
- package/dist/types/index.d.ts +271 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +16 -0
- package/dist/vue/ContentList.vue +166 -0
- package/dist/vue/ContentViewer.vue +137 -0
- package/dist/vue/composables.d.ts +64 -0
- package/dist/vue/composables.d.ts.map +1 -0
- package/dist/vue/composables.js +165 -0
- package/dist/vue/index.d.ts +10 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +8 -0
- package/dist/widget/content-card.js +190 -0
- package/dist/widget/content-list.js +289 -0
- package/dist/widget/content-viewer.js +230 -0
- package/dist/widget/index.js +40 -0
- package/dist/widget/utils/api-client.js +154 -0
- package/dist/widget/utils/helpers.js +71 -0
- package/dist/widget/widget-js/content-card.js +190 -0
- package/dist/widget/widget-js/content-list.js +289 -0
- package/dist/widget/widget-js/content-viewer.js +230 -0
- package/dist/widget/widget-js/index.js +40 -0
- package/dist/widget/widget-js/utils/api-client.js +154 -0
- package/dist/widget/widget-js/utils/helpers.js +71 -0
- package/dist/widget/widget-js/widget.d.ts +24 -0
- package/dist/widget/widget-js/widget.js +240 -0
- package/dist/widget/widget.d.ts +24 -0
- package/dist/widget/widget.js +240 -0
- package/package.json +99 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Viewer Component
|
|
3
|
+
* Displays full content with markdown rendering
|
|
4
|
+
*/
|
|
5
|
+
import { formatDate, calculateReadingTime, escapeHtml } from '../utils/helpers.js';
|
|
6
|
+
import { marked } from 'marked';
|
|
7
|
+
|
|
8
|
+
export class ContentViewer {
|
|
9
|
+
constructor(container, api, options = {}) {
|
|
10
|
+
this.container = container;
|
|
11
|
+
this.api = api;
|
|
12
|
+
this.options = {
|
|
13
|
+
displayMode: options.displayMode || 'inline', // 'inline' or 'modal'
|
|
14
|
+
showBackButton: options.showBackButton !== false, // Default true, can be disabled
|
|
15
|
+
showSummary: options.showSummary !== false, // Default true, can be disabled
|
|
16
|
+
onBack: options.onBack || null
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
this.article = null;
|
|
20
|
+
this.loading = false;
|
|
21
|
+
this.summaryExpanded = true; // Summary visible by default
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load and display an article
|
|
26
|
+
*/
|
|
27
|
+
async loadArticle(uuid) {
|
|
28
|
+
if (this.loading) return;
|
|
29
|
+
|
|
30
|
+
this.loading = true;
|
|
31
|
+
this.showLoading();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
this.article = await this.api.fetchArticle(uuid);
|
|
35
|
+
this.render();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
this.showError('Failed to load article. Please try again.');
|
|
38
|
+
console.error(error);
|
|
39
|
+
} finally {
|
|
40
|
+
this.loading = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render the article
|
|
46
|
+
*/
|
|
47
|
+
render() {
|
|
48
|
+
if (!this.article) return;
|
|
49
|
+
|
|
50
|
+
const readingTime = calculateReadingTime(this.article.wordCount || this.article.content);
|
|
51
|
+
const content = this.renderMarkdown(this.article.content || '');
|
|
52
|
+
|
|
53
|
+
this.container.innerHTML = `
|
|
54
|
+
<div class="cg-content-viewer">
|
|
55
|
+
${this.options.showBackButton ? `
|
|
56
|
+
<div class="cg-viewer-header">
|
|
57
|
+
<button class="cg-back-btn">
|
|
58
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
59
|
+
<path d="M12 16L6 10L12 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
60
|
+
</svg>
|
|
61
|
+
Back to list
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
` : ''}
|
|
65
|
+
|
|
66
|
+
<article class="cg-viewer-content">
|
|
67
|
+
<header class="cg-post-title-section">
|
|
68
|
+
<h1 class="cg-post-title">${escapeHtml(this.article.title)}</h1>
|
|
69
|
+
|
|
70
|
+
${this.options.showSummary && this.article.summary && this.article.category !== 'announce' ? `
|
|
71
|
+
<div class="cg-ai-summary ${this.summaryExpanded ? 'expanded' : 'collapsed'}">
|
|
72
|
+
<div class="cg-ai-summary-header">
|
|
73
|
+
<div class="cg-ai-summary-label">
|
|
74
|
+
<svg class="cg-ai-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
75
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
76
|
+
</svg>
|
|
77
|
+
<span>AI Generated Summary</span>
|
|
78
|
+
</div>
|
|
79
|
+
<button class="cg-summary-toggle" aria-label="Toggle summary">
|
|
80
|
+
<svg class="cg-chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor">
|
|
81
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6l4 4 4-4" />
|
|
82
|
+
</svg>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="cg-ai-summary-content">
|
|
86
|
+
<p>${escapeHtml(this.article.summary)}</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
` : ''}
|
|
90
|
+
|
|
91
|
+
<div class="cg-post-meta">
|
|
92
|
+
<span class="cg-meta-item">
|
|
93
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
94
|
+
<path d="M8 8C9.65685 8 11 6.65685 11 5C11 3.34315 9.65685 2 8 2C6.34315 2 5 3.34315 5 5C5 6.65685 6.34315 8 8 8Z" stroke="currentColor" stroke-width="1.5"/>
|
|
95
|
+
<path d="M14 14C14 11.7909 11.3137 10 8 10C4.68629 10 2 11.7909 2 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
96
|
+
</svg>
|
|
97
|
+
${escapeHtml(this.article.authorName)}
|
|
98
|
+
</span>
|
|
99
|
+
<span class="cg-meta-item">
|
|
100
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
101
|
+
<rect x="2" y="3" width="12" height="11" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
|
102
|
+
<path d="M5 2V4M11 2V4M2 6H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
103
|
+
</svg>
|
|
104
|
+
${formatDate(this.article.publishedAt)}
|
|
105
|
+
</span>
|
|
106
|
+
<span class="cg-meta-item">
|
|
107
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
108
|
+
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
|
|
109
|
+
<path d="M8 4V8L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
110
|
+
</svg>
|
|
111
|
+
${readingTime}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
</header>
|
|
115
|
+
|
|
116
|
+
<div class="cg-post-body">
|
|
117
|
+
${content}
|
|
118
|
+
</div>
|
|
119
|
+
</article>
|
|
120
|
+
</div>
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
// Add back button handler if button exists
|
|
124
|
+
if (this.options.showBackButton) {
|
|
125
|
+
const backBtn = this.container.querySelector('.cg-back-btn');
|
|
126
|
+
if (backBtn) {
|
|
127
|
+
backBtn.addEventListener('click', () => this.handleBack());
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Add summary toggle handler
|
|
132
|
+
const summaryToggle = this.container.querySelector('.cg-summary-toggle');
|
|
133
|
+
if (summaryToggle) {
|
|
134
|
+
summaryToggle.addEventListener('click', () => this.toggleSummary());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Toggle AI summary visibility
|
|
140
|
+
*/
|
|
141
|
+
toggleSummary() {
|
|
142
|
+
this.summaryExpanded = !this.summaryExpanded;
|
|
143
|
+
const summaryEl = this.container.querySelector('.cg-ai-summary');
|
|
144
|
+
if (summaryEl) {
|
|
145
|
+
if (this.summaryExpanded) {
|
|
146
|
+
summaryEl.classList.add('expanded');
|
|
147
|
+
summaryEl.classList.remove('collapsed');
|
|
148
|
+
} else {
|
|
149
|
+
summaryEl.classList.add('collapsed');
|
|
150
|
+
summaryEl.classList.remove('expanded');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render markdown content to HTML
|
|
157
|
+
*/
|
|
158
|
+
renderMarkdown(markdown) {
|
|
159
|
+
// Configure marked
|
|
160
|
+
marked.setOptions({
|
|
161
|
+
breaks: true,
|
|
162
|
+
gfm: true,
|
|
163
|
+
headerIds: true,
|
|
164
|
+
mangle: false
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
return marked.parse(markdown);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Markdown parsing error:', error);
|
|
171
|
+
return `<p>${escapeHtml(markdown)}</p>`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Handle back button click
|
|
177
|
+
*/
|
|
178
|
+
handleBack() {
|
|
179
|
+
if (this.options.onBack) {
|
|
180
|
+
this.options.onBack();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Show loading state
|
|
186
|
+
*/
|
|
187
|
+
showLoading() {
|
|
188
|
+
this.container.innerHTML = `
|
|
189
|
+
<div class="cg-content-viewer">
|
|
190
|
+
<div class="cg-viewer-loading">
|
|
191
|
+
<div class="cg-spinner"></div>
|
|
192
|
+
<p>Loading article...</p>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Show error message
|
|
200
|
+
*/
|
|
201
|
+
showError(message) {
|
|
202
|
+
this.container.innerHTML = `
|
|
203
|
+
<div class="cg-content-viewer">
|
|
204
|
+
<div class="cg-viewer-error">
|
|
205
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
206
|
+
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="2"/>
|
|
207
|
+
<path d="M24 16V26M24 32V32.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
208
|
+
</svg>
|
|
209
|
+
<p>${message}</p>
|
|
210
|
+
${this.options.showBackButton ? '<button class="cg-back-btn">Back to articles</button>' : ''}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
if (this.options.showBackButton) {
|
|
216
|
+
const backBtn = this.container.querySelector('.cg-back-btn');
|
|
217
|
+
if (backBtn) {
|
|
218
|
+
backBtn.addEventListener('click', () => this.handleBack());
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Clear the post view
|
|
225
|
+
*/
|
|
226
|
+
clear() {
|
|
227
|
+
this.container.innerHTML = '';
|
|
228
|
+
this.article = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Growth Widget - Vanilla JavaScript
|
|
3
|
+
* Can be used in any framework or vanilla JS project
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ContentGrowthWidget } from './widget.js';
|
|
7
|
+
import { ContentGrowthAPI } from './utils/api-client.js';
|
|
8
|
+
|
|
9
|
+
// Export main widget class
|
|
10
|
+
export { ContentGrowthWidget };
|
|
11
|
+
|
|
12
|
+
// Export API client for advanced usage
|
|
13
|
+
export { ContentGrowthAPI };
|
|
14
|
+
|
|
15
|
+
// Export helper functions
|
|
16
|
+
export * from './utils/helpers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Auto-initialize widgets on page load
|
|
20
|
+
* Only runs if this is loaded as a script (not imported as module)
|
|
21
|
+
*/
|
|
22
|
+
if (typeof document !== 'undefined' && !import.meta.url.includes('node_modules')) {
|
|
23
|
+
if (document.readyState === 'loading') {
|
|
24
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
25
|
+
initializeWidgets();
|
|
26
|
+
});
|
|
27
|
+
} else {
|
|
28
|
+
initializeWidgets();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initializeWidgets() {
|
|
33
|
+
const containers = document.querySelectorAll('[data-cg-content]');
|
|
34
|
+
containers.forEach(container => {
|
|
35
|
+
new ContentGrowthWidget(container);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Default export for convenience
|
|
40
|
+
export default ContentGrowthWidget;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Growth API Client
|
|
3
|
+
* Handles fetching articles from the widget API (requires API key)
|
|
4
|
+
*/
|
|
5
|
+
export class ContentGrowthAPI {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
console.log('[ContentGrowthAPI] Constructor called with config:', config);
|
|
8
|
+
this.apiKey = config.apiKey;
|
|
9
|
+
this.baseUrl = config.baseUrl || 'https://api.content-growth.com';
|
|
10
|
+
this.cache = new Map();
|
|
11
|
+
this.cacheTTL = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
console.log('[ContentGrowthAPI] Initialized with baseUrl:', this.baseUrl, 'apiKey:', this.apiKey);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch list of articles
|
|
17
|
+
*/
|
|
18
|
+
async fetchArticles(options = {}) {
|
|
19
|
+
const { page = 1, limit = 12, tags = [] } = options;
|
|
20
|
+
console.log('[ContentGrowthAPI] fetchArticles called with options:', options);
|
|
21
|
+
|
|
22
|
+
const params = new URLSearchParams({
|
|
23
|
+
page: page.toString(),
|
|
24
|
+
limit: limit.toString()
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (tags.length > 0) {
|
|
28
|
+
params.set('tag', tags.join(','));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = `${this.baseUrl}/widget/articles?${params}`;
|
|
32
|
+
const cacheKey = url;
|
|
33
|
+
console.log('[ContentGrowthAPI] Request URL:', url);
|
|
34
|
+
|
|
35
|
+
// Check cache
|
|
36
|
+
const cached = this.getFromCache(cacheKey);
|
|
37
|
+
if (cached) {
|
|
38
|
+
console.log('[ContentGrowthAPI] Returning cached data');
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
console.log('[ContentGrowthAPI] Making fetch request with headers:', {
|
|
44
|
+
'X-API-Key': this.apiKey
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
headers: {
|
|
49
|
+
'X-API-Key': this.apiKey
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
console.log('[ContentGrowthAPI] Response status:', response.status, response.statusText);
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const errorText = await response.text();
|
|
57
|
+
console.error('[ContentGrowthAPI] Error response body:', errorText);
|
|
58
|
+
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
console.log('[ContentGrowthAPI] Response data:', data);
|
|
63
|
+
|
|
64
|
+
// Cache the result
|
|
65
|
+
this.setCache(cacheKey, data);
|
|
66
|
+
|
|
67
|
+
return data;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('[ContentGrowthAPI] Failed to fetch articles:', error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch single article by UUID
|
|
76
|
+
*/
|
|
77
|
+
async fetchArticle(uuid) {
|
|
78
|
+
const url = `${this.baseUrl}/widget/articles/${uuid}`;
|
|
79
|
+
const cacheKey = url;
|
|
80
|
+
console.log('[ContentGrowthAPI] fetchArticle called for uuid:', uuid);
|
|
81
|
+
console.log('[ContentGrowthAPI] Request URL:', url);
|
|
82
|
+
|
|
83
|
+
// Check cache
|
|
84
|
+
const cached = this.getFromCache(cacheKey);
|
|
85
|
+
if (cached) {
|
|
86
|
+
console.log('[ContentGrowthAPI] Returning cached article');
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
console.log('[ContentGrowthAPI] Making fetch request with headers:', {
|
|
92
|
+
'X-API-Key': this.apiKey
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
headers: {
|
|
97
|
+
'X-API-Key': this.apiKey
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log('[ContentGrowthAPI] Response status:', response.status, response.statusText);
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const errorText = await response.text();
|
|
105
|
+
console.error('[ContentGrowthAPI] Error response body:', errorText);
|
|
106
|
+
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = await response.json();
|
|
110
|
+
console.log('[ContentGrowthAPI] Response data:', data);
|
|
111
|
+
|
|
112
|
+
// Cache the result
|
|
113
|
+
this.setCache(cacheKey, data);
|
|
114
|
+
|
|
115
|
+
return data;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error('[ContentGrowthAPI] Failed to fetch article:', error);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get from cache if not expired
|
|
124
|
+
*/
|
|
125
|
+
getFromCache(key) {
|
|
126
|
+
const cached = this.cache.get(key);
|
|
127
|
+
if (!cached) return null;
|
|
128
|
+
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
if (now - cached.timestamp > this.cacheTTL) {
|
|
131
|
+
this.cache.delete(key);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return cached.data;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Set cache with timestamp
|
|
140
|
+
*/
|
|
141
|
+
setCache(key, data) {
|
|
142
|
+
this.cache.set(key, {
|
|
143
|
+
data,
|
|
144
|
+
timestamp: Date.now()
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Clear all cache
|
|
150
|
+
*/
|
|
151
|
+
clearCache() {
|
|
152
|
+
this.cache.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility helper functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format date to readable string
|
|
7
|
+
*/
|
|
8
|
+
export function formatDate(timestamp) {
|
|
9
|
+
const date = new Date(timestamp * 1000);
|
|
10
|
+
return date.toLocaleDateString('en-US', {
|
|
11
|
+
year: 'numeric',
|
|
12
|
+
month: 'short',
|
|
13
|
+
day: 'numeric'
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculate reading time from word count or content
|
|
19
|
+
* @param {number|string} wordCountOrContent - Word count (number) or content text (string)
|
|
20
|
+
* @returns {string} Reading time string (e.g., "5 min read")
|
|
21
|
+
*/
|
|
22
|
+
export function calculateReadingTime(wordCountOrContent) {
|
|
23
|
+
if (!wordCountOrContent) return 'Unknown';
|
|
24
|
+
|
|
25
|
+
const wordsPerMinute = 200;
|
|
26
|
+
let words;
|
|
27
|
+
|
|
28
|
+
// If it's a number, use it directly as word count
|
|
29
|
+
if (typeof wordCountOrContent === 'number') {
|
|
30
|
+
words = wordCountOrContent;
|
|
31
|
+
if (words === 0) return 'Unknown'; // No word count available
|
|
32
|
+
} else {
|
|
33
|
+
// Otherwise, calculate from content text (fallback)
|
|
34
|
+
words = wordCountOrContent.trim().split(/\s+/).filter(w => w.length > 0).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const minutes = Math.ceil(words / wordsPerMinute);
|
|
38
|
+
return `${minutes} min read`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Truncate text to specified length
|
|
43
|
+
*/
|
|
44
|
+
export function truncate(text, maxLength = 150) {
|
|
45
|
+
if (!text || text.length <= maxLength) return text;
|
|
46
|
+
return text.substring(0, maxLength).trim() + '...';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Escape HTML to prevent XSS
|
|
51
|
+
*/
|
|
52
|
+
export function escapeHtml(text) {
|
|
53
|
+
const div = document.createElement('div');
|
|
54
|
+
div.textContent = text;
|
|
55
|
+
return div.innerHTML;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Debounce function calls
|
|
60
|
+
*/
|
|
61
|
+
export function debounce(func, wait) {
|
|
62
|
+
let timeout;
|
|
63
|
+
return function executedFunction(...args) {
|
|
64
|
+
const later = () => {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
func(...args);
|
|
67
|
+
};
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
timeout = setTimeout(later, wait);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Card Component
|
|
3
|
+
* Displays a single content item in compact or expanded mode
|
|
4
|
+
*/
|
|
5
|
+
import { formatDate, calculateReadingTime, escapeHtml } from '../utils/helpers.js';
|
|
6
|
+
|
|
7
|
+
export class ContentCard {
|
|
8
|
+
constructor(article, options = {}) {
|
|
9
|
+
this.article = article;
|
|
10
|
+
this.displayMode = options.displayMode || 'compact';
|
|
11
|
+
this.viewerMode = options.viewerMode || 'inline';
|
|
12
|
+
this.externalUrlPattern = options.externalUrlPattern || '/article/{id}';
|
|
13
|
+
this.externalTarget = options.externalTarget || 'article-{id}';
|
|
14
|
+
this.onExpand = options.onExpand || null;
|
|
15
|
+
this.onClick = options.onClick || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render the content card
|
|
20
|
+
*/
|
|
21
|
+
render() {
|
|
22
|
+
// Map display modes: compact/comfortable/spacious all use compact layout
|
|
23
|
+
// expanded shows summary
|
|
24
|
+
const layoutMode = this.displayMode === 'expanded' ? 'expanded' : 'compact';
|
|
25
|
+
|
|
26
|
+
// For external mode, wrap in <a> tag
|
|
27
|
+
if (this.viewerMode === 'external') {
|
|
28
|
+
const link = document.createElement('a');
|
|
29
|
+
link.className = `cg-card cg-card--${this.displayMode}`;
|
|
30
|
+
link.dataset.contentId = this.article.uuid;
|
|
31
|
+
|
|
32
|
+
// Generate URL and target
|
|
33
|
+
const url = this.externalUrlPattern.replace('{id}', this.article.uuid);
|
|
34
|
+
const target = this.externalTarget.replace('{id}', this.article.uuid);
|
|
35
|
+
|
|
36
|
+
link.href = url;
|
|
37
|
+
link.target = target;
|
|
38
|
+
link.rel = 'noopener'; // Security best practice
|
|
39
|
+
|
|
40
|
+
if (layoutMode === 'compact') {
|
|
41
|
+
link.innerHTML = this.renderCompact();
|
|
42
|
+
} else {
|
|
43
|
+
link.innerHTML = this.renderExpanded();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Add expand button handler
|
|
47
|
+
const expandBtn = link.querySelector('.cg-expand-btn');
|
|
48
|
+
if (expandBtn && this.onExpand) {
|
|
49
|
+
expandBtn.addEventListener('click', (e) => {
|
|
50
|
+
e.preventDefault(); // Prevent link navigation
|
|
51
|
+
e.stopPropagation();
|
|
52
|
+
this.onExpand(this.article, link);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return link;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For inline/modal mode, use regular article element
|
|
60
|
+
const card = document.createElement('article');
|
|
61
|
+
card.className = `cg-card cg-card--${this.displayMode}`;
|
|
62
|
+
card.dataset.contentId = this.article.uuid;
|
|
63
|
+
|
|
64
|
+
if (layoutMode === 'compact') {
|
|
65
|
+
card.innerHTML = this.renderCompact();
|
|
66
|
+
} else {
|
|
67
|
+
card.innerHTML = this.renderExpanded();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Add click handler for the whole card
|
|
71
|
+
card.addEventListener('click', (e) => {
|
|
72
|
+
// Don't trigger if clicking expand button
|
|
73
|
+
if (e.target.closest('.cg-expand-btn')) return;
|
|
74
|
+
|
|
75
|
+
if (this.onClick) {
|
|
76
|
+
this.onClick(this.article);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Add expand button handler if in compact mode
|
|
81
|
+
const expandBtn = card.querySelector('.cg-expand-btn');
|
|
82
|
+
if (expandBtn && this.onExpand) {
|
|
83
|
+
expandBtn.addEventListener('click', (e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
this.onExpand(this.article, card);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return card;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render compact mode (title, meta only)
|
|
94
|
+
*/
|
|
95
|
+
renderCompact() {
|
|
96
|
+
const readingTime = calculateReadingTime(this.article.wordCount);
|
|
97
|
+
|
|
98
|
+
return `
|
|
99
|
+
<div class="cg-card-header">
|
|
100
|
+
<h3 class="cg-card-title">${escapeHtml(this.article.title)}</h3>
|
|
101
|
+
<button class="cg-expand-btn" aria-label="Show more" title="Show summary">
|
|
102
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
103
|
+
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
104
|
+
</svg>
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="cg-card-meta">
|
|
108
|
+
<span class="cg-meta-item cg-author">
|
|
109
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
110
|
+
<path d="M7 7C8.65685 7 10 5.65685 10 4C10 2.34315 8.65685 1 7 1C5.34315 1 4 2.34315 4 4C4 5.65685 5.34315 7 7 7Z" stroke="currentColor" stroke-width="1.5"/>
|
|
111
|
+
<path d="M13 13C13 10.7909 10.3137 9 7 9C3.68629 9 1 10.7909 1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
112
|
+
</svg>
|
|
113
|
+
${escapeHtml(this.article.authorName)}
|
|
114
|
+
</span>
|
|
115
|
+
<span class="cg-meta-item cg-date">
|
|
116
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
117
|
+
<rect x="1" y="2" width="12" height="11" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
|
118
|
+
<path d="M4 1V3M10 1V3M1 5H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
119
|
+
</svg>
|
|
120
|
+
${formatDate(this.article.publishedAt)}
|
|
121
|
+
</span>
|
|
122
|
+
<span class="cg-meta-item cg-reading-time">
|
|
123
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
124
|
+
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.5"/>
|
|
125
|
+
<path d="M7 3.5V7L9.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
126
|
+
</svg>
|
|
127
|
+
${readingTime}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render expanded mode (with summary and tags)
|
|
135
|
+
*/
|
|
136
|
+
renderExpanded() {
|
|
137
|
+
const readingTime = calculateReadingTime(this.article.wordCount);
|
|
138
|
+
const summary = this.article.summary || '';
|
|
139
|
+
const tags = this.article.tags || [];
|
|
140
|
+
|
|
141
|
+
return `
|
|
142
|
+
<div class="cg-card-header">
|
|
143
|
+
<h3 class="cg-card-title">${escapeHtml(this.article.title)}</h3>
|
|
144
|
+
<button class="cg-expand-btn cg-expand-btn--collapse" aria-label="Show less" title="Hide summary">
|
|
145
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
146
|
+
<path d="M12 10L8 6L4 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
147
|
+
</svg>
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
${summary ? `
|
|
152
|
+
<div class="cg-card-summary">
|
|
153
|
+
<p>${escapeHtml(summary)}</p>
|
|
154
|
+
</div>
|
|
155
|
+
` : ''}
|
|
156
|
+
|
|
157
|
+
${tags.length > 0 ? `
|
|
158
|
+
<div class="cg-card-tags">
|
|
159
|
+
${tags.map(tag => `
|
|
160
|
+
<span class="cg-tag">${escapeHtml(tag)}</span>
|
|
161
|
+
`).join('')}
|
|
162
|
+
</div>
|
|
163
|
+
` : ''}
|
|
164
|
+
|
|
165
|
+
<div class="cg-card-meta">
|
|
166
|
+
<span class="cg-meta-item cg-author">
|
|
167
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
168
|
+
<path d="M7 7C8.65685 7 10 5.65685 10 4C10 2.34315 8.65685 1 7 1C5.34315 1 4 2.34315 4 4C4 5.65685 5.34315 7 7 7Z" stroke="currentColor" stroke-width="1.5"/>
|
|
169
|
+
<path d="M13 13C13 10.7909 10.3137 9 7 9C3.68629 9 1 10.7909 1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
170
|
+
</svg>
|
|
171
|
+
${escapeHtml(this.article.authorName)}
|
|
172
|
+
</span>
|
|
173
|
+
<span class="cg-meta-item cg-date">
|
|
174
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
175
|
+
<rect x="1" y="2" width="12" height="11" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
|
176
|
+
<path d="M4 1V3M10 1V3M1 5H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
177
|
+
</svg>
|
|
178
|
+
${formatDate(this.article.publishedAt)}
|
|
179
|
+
</span>
|
|
180
|
+
<span class="cg-meta-item cg-reading-time">
|
|
181
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
182
|
+
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.5"/>
|
|
183
|
+
<path d="M7 3.5V7L9.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
184
|
+
</svg>
|
|
185
|
+
${readingTime}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
}
|