@wazobiatech/wazui 0.0.3 → 0.0.4
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/package.json +2 -2
- package/src/assets/dashboard/book.svg +6 -0
- package/src/assets/dashboard/edit.svg +5 -0
- package/src/assets/dashboard/emerald.svg +9 -0
- package/src/assets/dashboard/eye.svg +4 -0
- package/src/assets/dashboard/gold.svg +9 -0
- package/src/assets/dashboard/percentage-square.svg +6 -0
- package/src/assets/dashboard/sapphire.svg +9 -0
- package/src/assets/dashboard/send-2.svg +4 -0
- package/src/assets/desktop.svg +7 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Light.otf +0 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Light.woff +0 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Light.woff2 +0 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Regular.otf +0 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Regular.woff +0 -0
- package/src/assets/fonts/UniversalFeature/FeatureJungle-Regular.woff2 +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-420.otf +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-420.woff +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-420.woff2 +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-515.otf +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-515.woff +0 -0
- package/src/assets/fonts/UniversalJungle/UniversalJungle-515.woff2 +0 -0
- package/src/assets/instagram.svg +9 -0
- package/src/assets/mobile.svg +4 -0
- package/src/assets/no-data-image.svg +41 -0
- package/src/assets/no-image-icon.svg +84 -0
- package/src/assets/pexels.svg +9 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/sounds/draft.mp3 +0 -0
- package/src/assets/sounds/error.mp3 +0 -0
- package/src/assets/sounds/publish.mp3 +0 -0
- package/src/assets/tablet.svg +4 -0
- package/src/assets/unsplash.svg +3 -0
- package/src/assets/upload-image.svg +43 -0
- package/src/assets/user.svg +4 -0
- package/src/components/shadcn/index.ts +31 -0
- package/src/config/env.ts +15 -0
- package/src/contexts/ThemeContext.tsx +11 -0
- package/src/data/users.json +6014 -0
- package/src/decorators/ActionModalDecorator.tsx +10 -0
- package/src/decorators/ApolloDecorator.tsx +19 -0
- package/src/decorators/DolosApolloDecorator.tsx +31 -0
- package/src/decorators/JestDecorators.tsx +34 -0
- package/src/decorators/ReactQueryDecorator.tsx +13 -0
- package/src/decorators/RouterDecorator.tsx +5 -0
- package/src/decorators/UnsafePropsDecorator.tsx +0 -0
- package/src/env.ts +99 -0
- package/src/hooks/use-autogrow.ts +28 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/useAuthors.ts +78 -0
- package/src/hooks/useDebounce.ts +18 -0
- package/src/hooks/useMercuryProjectToken.ts +112 -0
- package/src/hooks/useProjectToken.ts +21 -0
- package/src/hooks/useTheme.ts +12 -0
- package/src/index.css +397 -0
- package/src/index.ts +62 -0
- package/src/mocks/env.ts +12 -0
- package/src/providers/QueryProvider/index.tsx +10 -0
- package/src/providers/ThemeProvider/index.tsx +52 -0
- package/src/types/category.types.ts +13 -0
- package/src/types/image.types.ts +44 -0
- package/src/types/post.types.ts +121 -0
- package/src/types/theme.types.ts +10 -0
- package/src/types/users.types.ts +5 -0
- package/src/utils/apolloClient.ts +160 -0
- package/src/utils/articleMetadata.ts +92 -0
- package/src/utils/audio.ts +10 -0
- package/src/utils/imageProcessing.ts +399 -0
- package/src/utils/images.ts +279 -0
- package/src/utils/mercury.ts +0 -0
- package/src/utils/utils.ts +638 -0
- package/src/utils/videos.ts +29 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
import he from 'he';
|
|
4
|
+
import { Column } from '@tanstack/react-table';
|
|
5
|
+
export function cn(...inputs: ClassValue[]) {
|
|
6
|
+
return twMerge(clsx(inputs));
|
|
7
|
+
}
|
|
8
|
+
export function generateAutoGridColsClass<TData>(columns: Column<TData, unknown>[]) {
|
|
9
|
+
const colDefs = columns.map(col => {
|
|
10
|
+
const widthMeta = col.columnDef.meta?.width;
|
|
11
|
+
return widthMeta === 'grow' ? '1fr' : 'auto';
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const tailwindClass = `grid-cols-[${colDefs.join('_')}]`;
|
|
15
|
+
const cssValue = colDefs.join(' '); // e.g., "auto 1fr auto"
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
tailwindClass,
|
|
19
|
+
cssValue,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function cleanHTML(input: string = ''): string {
|
|
24
|
+
if (!input) return '';
|
|
25
|
+
const noTags = input?.replace?.(/<[^>]*>/g, '');
|
|
26
|
+
return he.decode(noTags);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const getNameInitials = (name: string): string => {
|
|
30
|
+
if (!name) return '';
|
|
31
|
+
|
|
32
|
+
return name
|
|
33
|
+
.split(' ')
|
|
34
|
+
.map(word => word[0]?.toUpperCase())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.slice(0, 2)
|
|
37
|
+
.join('');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function htmlToExcerpt(
|
|
41
|
+
html: string = '',
|
|
42
|
+
options: {
|
|
43
|
+
maxLength?: number; // Maximum excerpt length
|
|
44
|
+
minLength?: number; // Minimum length before truncating
|
|
45
|
+
stripImages?: boolean; // Remove image tags
|
|
46
|
+
stripLinks?: boolean; // Keep link text but remove href
|
|
47
|
+
preserveFormatting?: boolean; // Keep basic formatting like <strong>, <em>
|
|
48
|
+
ellipsis?: string; // What to add at the end when truncated
|
|
49
|
+
sentenceAware?: boolean; // Try to end at sentence boundaries
|
|
50
|
+
removeEmptyParagraphs?: boolean; // Remove empty <p> tags
|
|
51
|
+
} = {}
|
|
52
|
+
) {
|
|
53
|
+
const {
|
|
54
|
+
maxLength = 160,
|
|
55
|
+
minLength = 50,
|
|
56
|
+
stripImages = true,
|
|
57
|
+
stripLinks = false,
|
|
58
|
+
preserveFormatting = false,
|
|
59
|
+
ellipsis = '...',
|
|
60
|
+
sentenceAware = true,
|
|
61
|
+
removeEmptyParagraphs = true,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
if (!html || typeof html !== 'string') {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let cleanText = html;
|
|
69
|
+
|
|
70
|
+
// Remove script and style tags completely
|
|
71
|
+
cleanText = cleanText.replace(/<(script|style)[^>]*>[\s\S]*?<\/(script|style)>/gi, '');
|
|
72
|
+
|
|
73
|
+
// Remove comments
|
|
74
|
+
cleanText = cleanText.replace(/<!--[\s\S]*?-->/g, '');
|
|
75
|
+
|
|
76
|
+
// Remove images if specified
|
|
77
|
+
if (stripImages) {
|
|
78
|
+
cleanText = cleanText.replace(/<img[^>]*>/gi, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle links
|
|
82
|
+
if (stripLinks) {
|
|
83
|
+
// Remove links but keep the text content
|
|
84
|
+
cleanText = cleanText.replace(/<a[^>]*>(.*?)<\/a>/gi, '$1');
|
|
85
|
+
} else {
|
|
86
|
+
// Keep links as text (remove href but keep content)
|
|
87
|
+
cleanText = cleanText.replace(/<a[^>]*>(.*?)<\/a>/gi, '$1');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Remove empty paragraphs
|
|
91
|
+
if (removeEmptyParagraphs) {
|
|
92
|
+
cleanText = cleanText.replace(/<p[^>]*>\s*<\/p>/gi, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Preserve basic formatting if specified
|
|
96
|
+
if (preserveFormatting) {
|
|
97
|
+
// Convert basic formatting to plain text equivalents
|
|
98
|
+
cleanText = cleanText.replace(/<(strong|b)[^>]*>(.*?)<\/(strong|b)>/gi, '**$2**');
|
|
99
|
+
cleanText = cleanText.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/gi, '_$2_');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Convert block elements to spaces before removing tags
|
|
103
|
+
cleanText = cleanText.replace(
|
|
104
|
+
/<\/(p|div|h[1-6]|li|td|th|blockquote|section|article)[^>]*>/gi,
|
|
105
|
+
'</$1> '
|
|
106
|
+
);
|
|
107
|
+
cleanText = cleanText.replace(/<(br|hr)[^>]*\/?>/gi, ' ');
|
|
108
|
+
cleanText = cleanText.replace(/<\/(ul|ol|dl)[^>]*>/gi, '</$1> ');
|
|
109
|
+
|
|
110
|
+
// Remove all remaining HTML tags
|
|
111
|
+
cleanText = cleanText.replace(/<[^>]*>/g, '');
|
|
112
|
+
|
|
113
|
+
// Decode HTML entities
|
|
114
|
+
cleanText = cleanText
|
|
115
|
+
.replace(/ /g, ' ')
|
|
116
|
+
.replace(/&/g, '&')
|
|
117
|
+
.replace(/</g, '<')
|
|
118
|
+
.replace(/>/g, '>')
|
|
119
|
+
.replace(/"/g, '"')
|
|
120
|
+
// eslint-disable-next-line quotes
|
|
121
|
+
.replace(/'/g, "'")
|
|
122
|
+
// eslint-disable-next-line quotes
|
|
123
|
+
.replace(/'/g, "'");
|
|
124
|
+
|
|
125
|
+
// Clean up whitespace but preserve sentence spacing
|
|
126
|
+
cleanText = cleanText
|
|
127
|
+
.replace(/\s+/g, ' ') // Multiple spaces to single space
|
|
128
|
+
.replace(/\.\s+/g, '. ') // Ensure space after periods
|
|
129
|
+
.replace(/!\s+/g, '! ') // Ensure space after exclamation marks
|
|
130
|
+
.replace(/\?\s+/g, '? ') // Ensure space after question marks
|
|
131
|
+
.trim(); // Remove leading/trailing spaces
|
|
132
|
+
|
|
133
|
+
// If text is shorter than minLength, return as is
|
|
134
|
+
if (cleanText.length <= minLength) {
|
|
135
|
+
return cleanText;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If text is within maxLength, return as is
|
|
139
|
+
if (cleanText.length <= maxLength) {
|
|
140
|
+
return cleanText;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Truncate text
|
|
144
|
+
let truncated = cleanText.substring(0, maxLength);
|
|
145
|
+
|
|
146
|
+
// If sentence-aware truncation is enabled
|
|
147
|
+
if (sentenceAware) {
|
|
148
|
+
// Try to end at sentence boundary
|
|
149
|
+
const lastSentence = truncated.lastIndexOf('.');
|
|
150
|
+
const lastExclamation = truncated.lastIndexOf('!');
|
|
151
|
+
const lastQuestion = truncated.lastIndexOf('?');
|
|
152
|
+
|
|
153
|
+
const lastPunctuation = Math.max(lastSentence, lastExclamation, lastQuestion);
|
|
154
|
+
|
|
155
|
+
// If we found sentence-ending punctuation and it's not too early
|
|
156
|
+
if (lastPunctuation > minLength) {
|
|
157
|
+
truncated = truncated.substring(0, lastPunctuation + 1);
|
|
158
|
+
return truncated;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Otherwise, try to end at word boundary
|
|
162
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
163
|
+
if (lastSpace > minLength) {
|
|
164
|
+
truncated = truncated.substring(0, lastSpace);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Simple word boundary truncation
|
|
168
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
169
|
+
if (lastSpace > minLength) {
|
|
170
|
+
truncated = truncated.substring(0, lastSpace);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return truncated + ellipsis;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const shortenLargeNumber = (num: number): string => {
|
|
178
|
+
if (num >= 100000000) {
|
|
179
|
+
return (num / 1000000000).toFixed(1).replace(/\.0$/, '') + 'B';
|
|
180
|
+
}
|
|
181
|
+
if (num >= 1000000) {
|
|
182
|
+
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
183
|
+
}
|
|
184
|
+
if (num >= 1000) {
|
|
185
|
+
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
186
|
+
}
|
|
187
|
+
return num.toString();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// DATE UTILITIES
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
interface DateRange {
|
|
195
|
+
startDate: string; // Format: "YYYY-MM-DD HH:mm:ss"
|
|
196
|
+
endDate: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface FilterOptions {
|
|
200
|
+
search?: string;
|
|
201
|
+
categories?: string[];
|
|
202
|
+
dateRange?: DateRange;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get default date range (last month) in Muse datetime format
|
|
207
|
+
*/
|
|
208
|
+
export function getLastMonthDateRange(): DateRange {
|
|
209
|
+
const now = new Date();
|
|
210
|
+
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
211
|
+
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
startDate: formatDateForMuse(lastMonth),
|
|
215
|
+
endDate: formatDateForMuse(lastMonthEnd),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Format date for Muse API (YYYY-MM-DD HH:mm:ss)
|
|
221
|
+
*/
|
|
222
|
+
export function formatDateForMuse(date: Date): string {
|
|
223
|
+
const year = date.getFullYear();
|
|
224
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
225
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
226
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
227
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
228
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
229
|
+
|
|
230
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Convert FilterOptions dateRange to Muse format
|
|
235
|
+
*/
|
|
236
|
+
export function getEffectiveDateRange(filters?: FilterOptions): DateRange {
|
|
237
|
+
if (filters?.dateRange) {
|
|
238
|
+
// Ensure datetime format
|
|
239
|
+
return {
|
|
240
|
+
startDate: filters.dateRange.startDate.includes(' ')
|
|
241
|
+
? filters.dateRange.startDate
|
|
242
|
+
: `${filters.dateRange.startDate} 00:00:00`,
|
|
243
|
+
endDate: filters.dateRange.endDate.includes(' ')
|
|
244
|
+
? filters.dateRange.endDate
|
|
245
|
+
: `${filters.dateRange.endDate} 23:59:59`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return getLastMonthDateRange();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Convert date range with start/end Date objects to DateRange format
|
|
253
|
+
*/
|
|
254
|
+
export function toFilterDateRange(dateRange: { start: Date; end: Date }): DateRange {
|
|
255
|
+
return {
|
|
256
|
+
startDate: formatDateForMuse(dateRange.start),
|
|
257
|
+
endDate: formatDateForMuse(dateRange.end),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Convert DateRange format back to Date objects
|
|
263
|
+
*/
|
|
264
|
+
export function fromFilterDateRange(dateRange: DateRange): { start: Date; end: Date } {
|
|
265
|
+
return {
|
|
266
|
+
start: new Date(dateRange.startDate),
|
|
267
|
+
end: new Date(dateRange.endDate),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Format duration from seconds to readable format (e.g., "12.5 min" or "2.3 hrs")
|
|
273
|
+
*/
|
|
274
|
+
export function formatDuration(seconds: number): string {
|
|
275
|
+
if (seconds < 60) {
|
|
276
|
+
return `${Math.round(seconds)}s`;
|
|
277
|
+
} else if (seconds < 3600) {
|
|
278
|
+
return `${(seconds / 60).toFixed(1)} min`;
|
|
279
|
+
} else {
|
|
280
|
+
return `${(seconds / 3600).toFixed(1)} hrs`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Calculate time ago from date string
|
|
286
|
+
*/
|
|
287
|
+
export function calculateTimeAgo(dateString: string): string {
|
|
288
|
+
try {
|
|
289
|
+
const diffInDays = Math.floor(
|
|
290
|
+
(Date.now() - new Date(dateString).getTime()) / (1000 * 60 * 60 * 24)
|
|
291
|
+
);
|
|
292
|
+
if (diffInDays === 0) return 'Today';
|
|
293
|
+
if (diffInDays === 1) return '1 day ago';
|
|
294
|
+
if (diffInDays < 7) return `${diffInDays} days ago`;
|
|
295
|
+
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
|
|
296
|
+
if (diffInDays < 365) return `${Math.floor(diffInDays / 30)} months ago`;
|
|
297
|
+
return `${Math.floor(diffInDays / 365)} years ago`;
|
|
298
|
+
} catch {
|
|
299
|
+
return 'Recently';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// =============================================================================
|
|
304
|
+
// PARSING UTILITIES
|
|
305
|
+
// =============================================================================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Parse author ID from metadata JSON
|
|
309
|
+
*/
|
|
310
|
+
export function parseAuthorId(metadata: string | undefined): string | undefined {
|
|
311
|
+
if (!metadata) return undefined;
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(metadata);
|
|
314
|
+
return parsed.author_uuid || parsed.author_id;
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parse featured image from various formats
|
|
322
|
+
*/
|
|
323
|
+
export function parseFeaturedImage(img: string | { src?: string } | undefined): string {
|
|
324
|
+
const fallback =
|
|
325
|
+
'https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=400&h=300&fit=crop';
|
|
326
|
+
if (!img) return fallback;
|
|
327
|
+
try {
|
|
328
|
+
if (typeof img === 'string') {
|
|
329
|
+
const parsed = JSON.parse(img);
|
|
330
|
+
return parsed.src || fallback;
|
|
331
|
+
}
|
|
332
|
+
return img.src || fallback;
|
|
333
|
+
} catch {
|
|
334
|
+
return typeof img === 'string' ? img : fallback;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse user avatar from users.json format
|
|
340
|
+
* Avatar is stored as double-escaped JSON: "{\"src\":\"https://...\"}"
|
|
341
|
+
*/
|
|
342
|
+
export function parseUserAvatar(avatar: string | null | undefined): string | undefined {
|
|
343
|
+
if (!avatar) return undefined;
|
|
344
|
+
try {
|
|
345
|
+
// First parse removes outer quotes, second parse gets the object
|
|
346
|
+
let parsed = avatar;
|
|
347
|
+
if (typeof parsed === 'string') {
|
|
348
|
+
// Try to parse as JSON (might be double-encoded)
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(parsed);
|
|
351
|
+
} catch {
|
|
352
|
+
// Not JSON, might be plain URL
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (typeof parsed === 'string') {
|
|
356
|
+
// Try second parse for double-encoded JSON
|
|
357
|
+
try {
|
|
358
|
+
parsed = JSON.parse(parsed);
|
|
359
|
+
} catch {
|
|
360
|
+
// Still not JSON, return as-is if it looks like URL
|
|
361
|
+
return parsed.startsWith('http') ? parsed : undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Now parsed should be an object with src
|
|
365
|
+
if (typeof parsed === 'object' && parsed !== null && 'src' in parsed) {
|
|
366
|
+
return (parsed as { src: string }).src;
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
} catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Parse analytics from post metadata (if synced from Phoenix)
|
|
376
|
+
*/
|
|
377
|
+
export function parseAnalyticsFromMetadata(metadata: string | undefined): {
|
|
378
|
+
totalPageViews: number;
|
|
379
|
+
uniqueUsers: number;
|
|
380
|
+
averageSessionDuration: number | null;
|
|
381
|
+
} | null {
|
|
382
|
+
if (!metadata) return null;
|
|
383
|
+
try {
|
|
384
|
+
const parsed = JSON.parse(metadata);
|
|
385
|
+
if (parsed.analytics) {
|
|
386
|
+
return {
|
|
387
|
+
totalPageViews: parsed.analytics.totalPageViews || 0,
|
|
388
|
+
uniqueUsers: parsed.analytics.uniqueUsers || 0,
|
|
389
|
+
averageSessionDuration: parsed.analytics.averageSessionDuration,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// =============================================================================
|
|
399
|
+
// FILTER BUILDERS
|
|
400
|
+
// =============================================================================
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Build Muse filter for published posts in date range (org-wide)
|
|
404
|
+
* Note: We only filter by published_at - if a post has published_at, it's published
|
|
405
|
+
*/
|
|
406
|
+
export function buildPublishedPostsFilter(dateRange: DateRange) {
|
|
407
|
+
return {
|
|
408
|
+
published_at: {
|
|
409
|
+
gte: dateRange.startDate,
|
|
410
|
+
lte: dateRange.endDate,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build Muse filter for draft posts in date range (org-wide)
|
|
417
|
+
* Note: Drafts have created_at but no published_at
|
|
418
|
+
*/
|
|
419
|
+
export function buildDraftPostsFilter(dateRange: DateRange) {
|
|
420
|
+
return {
|
|
421
|
+
status: { eq: 1 }, // Draft - may need adjustment based on actual Muse status values
|
|
422
|
+
created_at: {
|
|
423
|
+
gte: dateRange.startDate,
|
|
424
|
+
lte: dateRange.endDate,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// =============================================================================
|
|
430
|
+
// ANALYTICS UTILITIES
|
|
431
|
+
// =============================================================================
|
|
432
|
+
|
|
433
|
+
interface PhoenixPageStats {
|
|
434
|
+
pageId: string;
|
|
435
|
+
pageUrl: string;
|
|
436
|
+
totalPageViews: number;
|
|
437
|
+
uniqueUsers: number;
|
|
438
|
+
averageSessionDuration?: number | null;
|
|
439
|
+
totalEvents: number;
|
|
440
|
+
lastActivity?: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Get engagement value from Phoenix stats (in seconds)
|
|
445
|
+
*/
|
|
446
|
+
export function getEngagementValue(stat: PhoenixPageStats): number {
|
|
447
|
+
return stat.averageSessionDuration || 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Strips invalid characters from a URL string
|
|
452
|
+
* @param url - The URL string to sanitize
|
|
453
|
+
* @param options - Configuration options for sanitization
|
|
454
|
+
* @returns Sanitized URL string
|
|
455
|
+
*/
|
|
456
|
+
export function stripInvalidArticleSlugChars(
|
|
457
|
+
url: string,
|
|
458
|
+
options: {
|
|
459
|
+
allowFragments?: boolean;
|
|
460
|
+
allowQueryParams?: boolean;
|
|
461
|
+
strictMode?: boolean;
|
|
462
|
+
} = {}
|
|
463
|
+
): string {
|
|
464
|
+
const { allowFragments = true, allowQueryParams = true, strictMode = false } = options;
|
|
465
|
+
|
|
466
|
+
if (!url || typeof url !== 'string') {
|
|
467
|
+
return '';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let sanitizedUrl = url.trim();
|
|
471
|
+
|
|
472
|
+
// Handle different sanitization levels
|
|
473
|
+
if (strictMode) {
|
|
474
|
+
// Strict mode: only allow basic URL characters
|
|
475
|
+
sanitizedUrl = sanitizedUrl.replace(/\s+/g, '-'); // Replace whitespace with hyphens
|
|
476
|
+
sanitizedUrl = sanitizedUrl.replace(/'s\b/g, ''); // Replace 's at word boundaries with just 's'
|
|
477
|
+
sanitizedUrl = sanitizedUrl.replace(/[^a-zA-Z0-9\-_~/#[\]()]/g, '');
|
|
478
|
+
} else {
|
|
479
|
+
// Standard mode: remove common problematic characters
|
|
480
|
+
sanitizedUrl = sanitizedUrl
|
|
481
|
+
.replace(/[\s]+/g, '-') // Replace spaces with hyphens
|
|
482
|
+
.replace(/[<>"{}|\\^`]/g, '') // Remove clearly invalid characters
|
|
483
|
+
.replace(/[^\x20-\x7E]/g, '') // Remove non-printable ASCII characters
|
|
484
|
+
.replace(/\s/g, ''); // Remove any remaining whitespace
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Handle fragments and query parameters based on options
|
|
488
|
+
if (!allowFragments) {
|
|
489
|
+
sanitizedUrl = sanitizedUrl.split('#')[0] as string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!allowQueryParams) {
|
|
493
|
+
sanitizedUrl = sanitizedUrl.split('?')[0] as string;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Clean up multiple consecutive special characters
|
|
497
|
+
sanitizedUrl = sanitizedUrl
|
|
498
|
+
.replace(/[-]{2,}/g, '-') // Multiple hyphens to single hyphen
|
|
499
|
+
.replace(/[.]{2,}/g, '.') // Multiple dots to single dot
|
|
500
|
+
.replace(/[/]{2,}/g, '//') // Keep double slashes for protocol, but remove more
|
|
501
|
+
.replace(/^[-.]|[-.]$/g, ''); // Remove leading/trailing hyphens or dots
|
|
502
|
+
|
|
503
|
+
return sanitizedUrl;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function extractArticleSlugComponents(input: string) {
|
|
507
|
+
if (!input) {
|
|
508
|
+
return { categorySlug: '', articleSlug: '' };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const lastSlashIndex = input.lastIndexOf('/');
|
|
512
|
+
|
|
513
|
+
if (lastSlashIndex === -1) {
|
|
514
|
+
return {
|
|
515
|
+
categorySlug: '',
|
|
516
|
+
articleSlug: input.replace('/', ''),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const categorySlug = input.substring(0, lastSlashIndex);
|
|
521
|
+
const articleSlug = input.substring(lastSlashIndex + 1);
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
categorySlug,
|
|
525
|
+
articleSlug,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
530
|
+
export function parseRecursiveHelper(data: string): any {
|
|
531
|
+
if (typeof data !== 'string' || data === '') return data;
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const parsedData = JSON.parse(data);
|
|
535
|
+
if (typeof parsedData === 'string') {
|
|
536
|
+
return parseRecursiveHelper(parsedData);
|
|
537
|
+
}
|
|
538
|
+
return parsedData;
|
|
539
|
+
} catch {
|
|
540
|
+
return { src: data };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export const parseRecursive = (data: string) => {
|
|
545
|
+
const parsedData = parseRecursiveHelper(data);
|
|
546
|
+
try {
|
|
547
|
+
const temp = JSON.parse(parsedData?.src);
|
|
548
|
+
return temp;
|
|
549
|
+
} catch {
|
|
550
|
+
return parsedData;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
interface DateRange {
|
|
555
|
+
startDate: string; // Format: "YYYY-MM-DD HH:mm:ss"
|
|
556
|
+
endDate: string;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
interface MetricChangeResult {
|
|
560
|
+
change: string;
|
|
561
|
+
changeType: 'positive' | 'negative';
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Calculate the previous period date range based on current period
|
|
566
|
+
*
|
|
567
|
+
* Example:
|
|
568
|
+
* - Current: Nov 1-30 → Previous: Oct 1-30
|
|
569
|
+
* - Current: Nov 15-30 (15 days) → Previous: Oct 31 - Nov 14 (15 days before)
|
|
570
|
+
*/
|
|
571
|
+
export function getPreviousPeriodDateRange(currentDateRange: DateRange): DateRange {
|
|
572
|
+
const start = new Date(currentDateRange.startDate);
|
|
573
|
+
const end = new Date(currentDateRange.endDate);
|
|
574
|
+
|
|
575
|
+
// Calculate the duration in milliseconds
|
|
576
|
+
const durationMs = end.getTime() - start.getTime();
|
|
577
|
+
|
|
578
|
+
// Shift back by the same duration
|
|
579
|
+
// Previous period ends 1ms before current period starts
|
|
580
|
+
const prevEnd = new Date(start.getTime() - 1);
|
|
581
|
+
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
|
582
|
+
|
|
583
|
+
// Use your existing formatDateForMuse function or format manually
|
|
584
|
+
const formatDate = (date: Date): string => {
|
|
585
|
+
const year = date.getFullYear();
|
|
586
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
587
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
588
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
589
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
590
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
591
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
startDate: formatDate(prevStart),
|
|
596
|
+
endDate: formatDate(prevEnd),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Format the change text for dashboard metrics
|
|
602
|
+
*
|
|
603
|
+
* Examples:
|
|
604
|
+
* - (45, 33) → { change: "+12 from last month", changeType: "positive" }
|
|
605
|
+
* - (20, 32) → { change: "-12 from last month", changeType: "negative" }
|
|
606
|
+
* - (0, 0) → { change: "No change from last month", changeType: "positive" }
|
|
607
|
+
* - (50, 0) → { change: "+50 from last month", changeType: "positive" }
|
|
608
|
+
*/
|
|
609
|
+
export function formatMetricChangeText(
|
|
610
|
+
current: number,
|
|
611
|
+
previous: number,
|
|
612
|
+
suffix: string = ''
|
|
613
|
+
): MetricChangeResult {
|
|
614
|
+
const diff = current - previous;
|
|
615
|
+
|
|
616
|
+
// Both zero - no change
|
|
617
|
+
if (previous === 0 && current === 0) {
|
|
618
|
+
return {
|
|
619
|
+
change: 'No change from last month',
|
|
620
|
+
changeType: 'positive',
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Previous was zero but now has value - all positive
|
|
625
|
+
if (previous === 0) {
|
|
626
|
+
return {
|
|
627
|
+
change: `+${current}${suffix} from last month`,
|
|
628
|
+
changeType: 'positive',
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Normal case - calculate difference
|
|
633
|
+
const sign = diff >= 0 ? '+' : '';
|
|
634
|
+
return {
|
|
635
|
+
change: `${sign}${diff}${suffix} from last month`,
|
|
636
|
+
changeType: diff >= 0 ? 'positive' : 'negative',
|
|
637
|
+
};
|
|
638
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const getYoutubeMatch = (url: string) => {
|
|
2
|
+
const match =
|
|
3
|
+
url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtube\.com\/watch.*v=([a-zA-Z0-9_-]+)/) ||
|
|
4
|
+
url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtu\.be\/([a-zA-Z0-9_-]+)/) ||
|
|
5
|
+
url.match(/^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/);
|
|
6
|
+
return match;
|
|
7
|
+
};
|
|
8
|
+
export const getVimeoMatch = (url: string) => {
|
|
9
|
+
const match = url.match(/^(?:(https?):\/\/)?(?:www\.)?vimeo\.com\/(\d+)/);
|
|
10
|
+
return match;
|
|
11
|
+
};
|
|
12
|
+
export const getVideoUrl = (url: string) => {
|
|
13
|
+
const youtubeMatch = getYoutubeMatch(url);
|
|
14
|
+
|
|
15
|
+
if (youtubeMatch && youtubeMatch[2]?.length === 11) {
|
|
16
|
+
return {
|
|
17
|
+
embed: true,
|
|
18
|
+
url: 'https' + '://www.youtube.com/embed/' + youtubeMatch[2] + '?showinfo=0',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const vimeoMatch = getVimeoMatch(url);
|
|
22
|
+
if (vimeoMatch) {
|
|
23
|
+
return {
|
|
24
|
+
embed: true,
|
|
25
|
+
url: (vimeoMatch[1] || 'https') + '://player.vimeo.com/video/' + vimeoMatch[2] + '/',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { url: url, embed: false };
|
|
29
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|