rimelight-components 1.1.3 → 1.1.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/dist/module.mjs CHANGED
@@ -39,9 +39,15 @@ const module = defineNuxtModule({
39
39
  ]
40
40
  }
41
41
  }
42
+ },
43
+ "motion-v/nuxt": {
44
+ version: ">=1.7.2",
45
+ optional: false,
46
+ overrides: {},
47
+ defaults: {}
42
48
  }
43
49
  },
44
- setup() {
50
+ setup(options, nuxt) {
45
51
  const resolver = createResolver(import.meta.url);
46
52
  addComponentsDir({
47
53
  path: resolver.resolve("./runtime/components/"),
@@ -50,6 +56,7 @@ const module = defineNuxtModule({
50
56
  global: true
51
57
  });
52
58
  addImportsDir(resolver.resolve("./runtime/composables"));
59
+ addImportsDir(resolver.resolve("./runtime/utils"));
53
60
  },
54
61
  onInstall() {
55
62
  console.log("Setting up rimelight-components for the first time!");
@@ -1,5 +1,8 @@
1
1
  type __VLS_Props = {
2
- to: string;
2
+ page: {
3
+ title: string;
4
+ stem: string;
5
+ };
3
6
  };
4
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
8
  declare const _default: typeof __VLS_export;
@@ -0,0 +1,207 @@
1
+ <script setup>
2
+ import { AnimatePresence, MotionConfig, motion } from "motion-v";
3
+ const {} = defineProps({
4
+ page: { type: Object, required: true }
5
+ });
6
+ const {
7
+ formState,
8
+ isExpanded,
9
+ isSubmitted,
10
+ isSubmitting,
11
+ handleRatingSelect,
12
+ submitFeedback
13
+ } = useFeedbackForm(props);
14
+ </script>
15
+
16
+ <template>
17
+ <MotionConfig
18
+ :transition="{ type: 'spring', visualDuration: 0.25, bounce: 0 }"
19
+ >
20
+ <motion.div layout class="max-w-md rounded-lg">
21
+ <AnimatePresence mode="wait">
22
+ <!-- Success State -->
23
+ <motion.div
24
+ v-if="isSubmitted"
25
+ key="success"
26
+ :initial="{ opacity: 0, scale: 0.95 }"
27
+ :animate="{ opacity: 1, scale: 1 }"
28
+ :transition="{ duration: 0.3 }"
29
+ class="flex items-center gap-3 py-2"
30
+ role="status"
31
+ aria-live="polite"
32
+ aria-label="Feedback submitted successfully"
33
+ >
34
+ <motion.div
35
+ :initial="{ scale: 0 }"
36
+ :animate="{ scale: 1 }"
37
+ :transition="{ delay: 0.1, type: 'spring', visualDuration: 0.4 }"
38
+ class="text-xl"
39
+ aria-hidden="true"
40
+ >
41
+
42
+ </motion.div>
43
+ <motion.div
44
+ :initial="{ opacity: 0, x: 10 }"
45
+ :animate="{ opacity: 1, x: 0 }"
46
+ :transition="{ delay: 0.2, duration: 0.3 }"
47
+ >
48
+ <div class="text-sm font-medium text-highlighted">
49
+ Thank you for your feedback!
50
+ </div>
51
+ <div class="mt-1 text-xs text-muted">
52
+ Your input helps us improve the documentation.
53
+ </div>
54
+ </motion.div>
55
+ </motion.div>
56
+
57
+ <motion.div v-else key="feedback">
58
+ <fieldset>
59
+ <motion.div layout class="flex items-center gap-3">
60
+ <motion.legend
61
+ id="feedback-legend"
62
+ layout
63
+ class="text-sm font-medium whitespace-nowrap text-highlighted"
64
+ >
65
+ Was this helpful?
66
+ </motion.legend>
67
+
68
+ <motion.div
69
+ layout
70
+ class="flex gap-2"
71
+ role="radiogroup"
72
+ aria-labelledby="feedback-legend"
73
+ >
74
+ <UButton
75
+ v-for="option in FEEDBACK_OPTIONS"
76
+ :key="option.value"
77
+ class="flex size-8 items-center justify-center rounded-lg border grayscale-80 transition-all duration-150 hover:grayscale-0 focus:outline-2 focus:outline-offset-2 focus:outline-primary"
78
+ :class="[
79
+ formState.rating === option.value ? 'border-primary bg-primary/20 grayscale-0 hover:bg-primary/30' : 'border-default bg-accented/20 hover:border-accented/70 hover:bg-accented/80'
80
+ ]"
81
+ :aria-label="`Rate as ${option.label}`"
82
+ :aria-pressed="formState.rating === option.value"
83
+ role="radio"
84
+ :aria-checked="formState.rating === option.value"
85
+ @click="handleRatingSelect(option.value)"
86
+ >
87
+ <span class="text-lg">{{ option.emoji }}</span>
88
+ </UButton>
89
+ </motion.div>
90
+ </motion.div>
91
+ </fieldset>
92
+
93
+ <AnimatePresence>
94
+ <motion.div
95
+ v-if="isExpanded"
96
+ key="expanded-form"
97
+ :initial="{ opacity: 0, height: 0, marginTop: 0 }"
98
+ :animate="{ opacity: 1, height: 'auto', marginTop: 8 }"
99
+ :exit="{ opacity: 0, height: 0, marginTop: 0 }"
100
+ :transition="{ duration: 0.3, ease: 'easeInOut' }"
101
+ class="overflow-hidden"
102
+ role="region"
103
+ aria-label="Additional feedback form"
104
+ >
105
+ <motion.div
106
+ :initial="{ opacity: 0 }"
107
+ :animate="{ opacity: 1 }"
108
+ :transition="{ delay: 0.15, duration: 0.2 }"
109
+ class="space-y-1"
110
+ >
111
+ <UForm
112
+ :state="formState"
113
+ :schema="feedbackFormSchema"
114
+ @submit="submitFeedback"
115
+ >
116
+ <UFormField name="feedback">
117
+ <label for="feedback-textarea" class="sr-only">
118
+ Additional feedback (optional)
119
+ </label>
120
+ <UTextarea
121
+ id="feedback-textarea"
122
+ ref="textareaRef"
123
+ v-model="formState.feedback"
124
+ class="resize-vertical w-full rounded-xl text-sm leading-relaxed"
125
+ placeholder="Share your thoughts... (optional)"
126
+ :rows="4"
127
+ autoresize
128
+ aria-describedby="feedback-help"
129
+ />
130
+ <div id="feedback-help" class="sr-only">
131
+ Provide additional details about your experience with this
132
+ page
133
+ </div>
134
+ </UFormField>
135
+ <div class="mt-2 flex items-center">
136
+ <div class="flex gap-2">
137
+ <UButton
138
+ size="sm"
139
+ :disabled="isSubmitting"
140
+ type="submit"
141
+ class="focus:outline-0"
142
+ :aria-label="
143
+ isSubmitting ? 'Sending feedback...' : 'Send feedback'
144
+ "
145
+ >
146
+ <motion.span
147
+ class="flex items-center"
148
+ :transition="{ duration: 0.2, ease: 'easeInOut' }"
149
+ >
150
+ <motion.div
151
+ :animate="{
152
+ width: isSubmitting ? '14px' : '0px',
153
+ marginRight: isSubmitting ? '6px' : '0px',
154
+ opacity: isSubmitting ? 1 : 0,
155
+ scale: isSubmitting ? 1 : 0,
156
+ rotate: isSubmitting ? 360 : 0
157
+ }"
158
+ :transition="{
159
+ width: { duration: 0.2, ease: 'easeInOut' },
160
+ marginRight: { duration: 0.2, ease: 'easeInOut' },
161
+ opacity: { duration: 0.2 },
162
+ scale: {
163
+ duration: 0.2,
164
+ type: 'spring',
165
+ bounce: 0.3
166
+ },
167
+ rotate: {
168
+ duration: 1,
169
+ ease: 'linear',
170
+ repeat: Infinity
171
+ }
172
+ }"
173
+ class="flex items-center justify-center overflow-hidden"
174
+ >
175
+ <Icon
176
+ name="mdi:loading"
177
+ class="size-3.5 shrink-0"
178
+ />
179
+ </motion.div>
180
+ <motion.span
181
+ :animate="{
182
+ opacity: 1
183
+ }"
184
+ :transition="{ duration: 0.2, ease: 'easeInOut' }"
185
+ >
186
+ {{ isSubmitting ? "Sending..." : "Send" }}
187
+ </motion.span>
188
+ </motion.span>
189
+ </UButton>
190
+ </div>
191
+ </div>
192
+ </UForm>
193
+ </motion.div>
194
+ </motion.div>
195
+ </AnimatePresence>
196
+ </motion.div>
197
+ </AnimatePresence>
198
+
199
+ <div aria-live="polite" class="sr-only">
200
+ <span v-if="isSubmitting">Sending your feedback...</span>
201
+ <span v-else-if="isExpanded && formState.rating">
202
+ Feedback form expanded. You can now add additional comments.
203
+ </span>
204
+ </div>
205
+ </motion.div>
206
+ </MotionConfig>
207
+ </template>
@@ -1,5 +1,8 @@
1
1
  type __VLS_Props = {
2
- to: string;
2
+ page: {
3
+ title: string;
4
+ stem: string;
5
+ };
3
6
  };
4
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
8
  declare const _default: typeof __VLS_export;
@@ -0,0 +1,18 @@
1
+ type PageAnalytic = {
2
+ path: string;
3
+ total: number;
4
+ positive: number;
5
+ negative: number;
6
+ averageScore: number;
7
+ positivePercentage: number;
8
+ feedback: any[];
9
+ lastFeedback: any;
10
+ createdAt: Date;
11
+ updatedAt: Date;
12
+ };
13
+ type __VLS_Props = {
14
+ pageAnalytics: PageAnalytic[];
15
+ };
16
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
@@ -0,0 +1,604 @@
1
+ <script setup>
2
+ const {} = defineProps({
3
+ pageAnalytics: { type: Array, required: true }
4
+ });
5
+ const { dateRange } = useDateRange();
6
+ const chartType = ref(`line`);
7
+ const selectedPagePaths = ref([]);
8
+ const showPageSelector = ref(false);
9
+ const pageSearchQuery = ref(``);
10
+ const hasValidData = computed(() => {
11
+ return props.pageAnalytics && props.pageAnalytics.length > 0 && props.pageAnalytics.some((p) => p && p.total > 0);
12
+ });
13
+ const availablePages = computed(() => {
14
+ if (!props.pageAnalytics) return [];
15
+ const pages = props.pageAnalytics.filter((p) => p && p.total > 0).sort((a, b) => b.total - a.total).map((page) => ({
16
+ path: page.path,
17
+ title: page.lastFeedback?.title || page.path,
18
+ total: page.total,
19
+ score: page.averageScore
20
+ }));
21
+ if (!pageSearchQuery.value.trim()) {
22
+ return pages;
23
+ }
24
+ const searchTerm = pageSearchQuery.value.toLowerCase().trim();
25
+ return pages.filter(
26
+ (page) => page.title.toLowerCase().includes(searchTerm) || page.path.toLowerCase().includes(searchTerm)
27
+ );
28
+ });
29
+ watch(
30
+ () => props.pageAnalytics,
31
+ (analytics) => {
32
+ if (selectedPagePaths.value.length === 0 && analytics && analytics.length > 0) {
33
+ const validAnalytics = analytics.filter((p) => p && p.total > 0);
34
+ if (validAnalytics.length > 0) {
35
+ const topPages = validAnalytics.sort((a, b) => b.total - a.total).slice(0, Math.min(5, validAnalytics.length));
36
+ selectedPagePaths.value = topPages.map((p) => p.path);
37
+ }
38
+ }
39
+ },
40
+ {
41
+ immediate: true
42
+ }
43
+ );
44
+ const overallChartData = computed(() => {
45
+ const data = [];
46
+ const endDate = dateRange.value.end;
47
+ const startDate = dateRange.value.start;
48
+ const daysDiff = Math.ceil(
49
+ (endDate.getTime() - startDate.getTime()) / (1e3 * 60 * 60 * 24)
50
+ );
51
+ const dailyScores = {};
52
+ if (hasValidData.value && props.pageAnalytics) {
53
+ props.pageAnalytics.forEach((page) => {
54
+ if (!page || !page.feedback) return;
55
+ page.feedback.forEach((feedback) => {
56
+ const feedbackDate = new Date(feedback.createdAt);
57
+ if (feedbackDate >= startDate && feedbackDate <= endDate) {
58
+ const dateStr = feedbackDate.toISOString().split(`T`)[0];
59
+ if (!dailyScores[dateStr]) {
60
+ dailyScores[dateStr] = [];
61
+ }
62
+ const ratingScore = FEEDBACK_OPTIONS.find((opt) => opt.value === feedback.rating)?.score || 0;
63
+ dailyScores[dateStr].push(ratingScore);
64
+ }
65
+ });
66
+ });
67
+ }
68
+ let lastKnownValue = 0;
69
+ for (let i = daysDiff - 1; i >= 0; i--) {
70
+ const date = new Date(endDate);
71
+ date.setDate(date.getDate() - i);
72
+ const dateStr = date.toISOString().split(`T`)[0];
73
+ const entry = {
74
+ date: dateStr,
75
+ day: date.toLocaleDateString(`en-US`, {
76
+ month: `short`,
77
+ day: `numeric`
78
+ })
79
+ };
80
+ if (hasValidData.value && dailyScores[dateStr] && dailyScores[dateStr].length > 0) {
81
+ const dayAverage = dailyScores[dateStr].reduce((sum, score) => sum + score, 0) / dailyScores[dateStr].length;
82
+ lastKnownValue = Math.min(4, Number(dayAverage.toFixed(2)));
83
+ }
84
+ entry.average = lastKnownValue;
85
+ data.push(entry);
86
+ }
87
+ return data;
88
+ });
89
+ const timeBasedChartData = computed(() => {
90
+ const data = [];
91
+ const endDate = dateRange.value.end;
92
+ const startDate = dateRange.value.start;
93
+ const daysDiff = Math.ceil(
94
+ (endDate.getTime() - startDate.getTime()) / (1e3 * 60 * 60 * 24)
95
+ );
96
+ if (!hasValidData.value || selectedPagePaths.value.length === 0 || !props.pageAnalytics) {
97
+ for (let i = daysDiff - 1; i >= 0; i--) {
98
+ const date = new Date(endDate);
99
+ date.setDate(date.getDate() - i);
100
+ data.push({
101
+ date: date.toISOString().split(`T`)[0],
102
+ day: date.toLocaleDateString(`en-US`, {
103
+ month: `short`,
104
+ day: `numeric`
105
+ }),
106
+ placeholder: 0
107
+ });
108
+ }
109
+ return data;
110
+ }
111
+ const dailyScores = {};
112
+ const selectedPages = props.pageAnalytics.filter(
113
+ (p) => p && selectedPagePaths.value.includes(p.path)
114
+ );
115
+ selectedPages.forEach((page) => {
116
+ if (!page || !page.feedback) return;
117
+ const pageKey = page.path.split(`/`).pop()?.replace(/[^a-z0-9]/gi, ``) || `page`;
118
+ page.feedback.forEach((feedback) => {
119
+ const feedbackDate = new Date(feedback.createdAt);
120
+ if (feedbackDate >= startDate && feedbackDate <= endDate) {
121
+ const dateStr = feedbackDate.toISOString().split(`T`)[0];
122
+ if (!dailyScores[dateStr]) {
123
+ dailyScores[dateStr] = {};
124
+ }
125
+ if (!dailyScores[dateStr][pageKey]) {
126
+ dailyScores[dateStr][pageKey] = [];
127
+ }
128
+ const ratingScore = FEEDBACK_OPTIONS.find((opt) => opt.value === feedback.rating)?.score || 0;
129
+ dailyScores[dateStr][pageKey].push(ratingScore);
130
+ }
131
+ });
132
+ });
133
+ const lastKnownValues = {};
134
+ for (let i = daysDiff - 1; i >= 0; i--) {
135
+ const date = new Date(endDate);
136
+ date.setDate(date.getDate() - i);
137
+ const dateStr = date.toISOString().split(`T`)[0];
138
+ const entry = {
139
+ date: dateStr,
140
+ day: date.toLocaleDateString(`en-US`, {
141
+ month: `short`,
142
+ day: `numeric`
143
+ })
144
+ };
145
+ selectedPages.forEach((page) => {
146
+ if (!page) return;
147
+ const pageKey = page.path.split(`/`).pop()?.replace(/[^a-z0-9]/gi, ``) || `page`;
148
+ if (dailyScores[dateStr] && dailyScores[dateStr][pageKey] && dailyScores[dateStr][pageKey].length > 0) {
149
+ const dayAverage = dailyScores[dateStr][pageKey].reduce((sum, score) => sum + score, 0) / dailyScores[dateStr][pageKey].length;
150
+ lastKnownValues[pageKey] = Math.min(4, Number(dayAverage.toFixed(2)));
151
+ }
152
+ entry[pageKey] = lastKnownValues[pageKey] || 0;
153
+ });
154
+ data.push(entry);
155
+ }
156
+ return data;
157
+ });
158
+ const comparisonChartData = computed(() => {
159
+ if (!hasValidData.value || selectedPagePaths.value.length === 0 || !props.pageAnalytics) {
160
+ return [
161
+ {
162
+ page: `No Data`,
163
+ positive: 0,
164
+ negative: 0
165
+ }
166
+ ];
167
+ }
168
+ return props.pageAnalytics.filter((p) => p && selectedPagePaths.value.includes(p.path)).map((page) => {
169
+ const title = page.lastFeedback?.title || page.path;
170
+ const shortTitle = title.length > 15 ? title.substring(0, 15) + `...` : title;
171
+ return {
172
+ page: shortTitle,
173
+ positive: page.positive,
174
+ negative: page.negative
175
+ };
176
+ });
177
+ });
178
+ const chartData = computed(() => {
179
+ if (chartType.value === `compare`) return comparisonChartData.value;
180
+ if (chartType.value === `overall`) return overallChartData.value;
181
+ return timeBasedChartData.value;
182
+ });
183
+ const chartCategories = computed(() => {
184
+ if (!hasValidData.value) {
185
+ return {
186
+ placeholder: {
187
+ name: `No Data Available`,
188
+ color: `#6b7280`
189
+ }
190
+ };
191
+ }
192
+ if (chartType.value === `compare`) {
193
+ return {
194
+ positive: {
195
+ name: `Positive`,
196
+ color: `var(--ui-success)`
197
+ },
198
+ negative: {
199
+ name: `Negative`,
200
+ color: `var(--ui-error)`
201
+ }
202
+ };
203
+ }
204
+ if (chartType.value === `overall`) {
205
+ return {
206
+ average: {
207
+ name: `Overall Rating`,
208
+ color: `#3b82f6`
209
+ }
210
+ };
211
+ }
212
+ if (selectedPagePaths.value.length === 0 || !props.pageAnalytics) {
213
+ return {
214
+ placeholder: {
215
+ name: `No Pages Selected`,
216
+ color: `#6b7280`
217
+ }
218
+ };
219
+ }
220
+ const selectedPages = props.pageAnalytics.filter(
221
+ (p) => p && selectedPagePaths.value.includes(p.path)
222
+ );
223
+ const colors = [
224
+ `#3b82f6`,
225
+ `#10b981`,
226
+ `#f59e0b`,
227
+ `#ef4444`,
228
+ `#8b5cf6`,
229
+ `#06b6d4`,
230
+ `#84cc16`,
231
+ `#f97316`
232
+ ];
233
+ return selectedPages.reduce(
234
+ (acc, page, index) => {
235
+ if (!page) return acc;
236
+ const key = page.path.split(`/`).pop()?.replace(/[^a-z0-9]/gi, ``) || `page`;
237
+ const title = page.lastFeedback?.title || page.path;
238
+ acc[key] = {
239
+ name: title.length > 25 ? title.substring(0, 25) + `...` : title,
240
+ color: colors[index % colors.length]
241
+ };
242
+ return acc;
243
+ },
244
+ {}
245
+ );
246
+ });
247
+ const xFormatter = (index) => {
248
+ if (chartType.value === `compare`) {
249
+ return comparisonChartData.value[index]?.page || ``;
250
+ }
251
+ if (chartType.value === `overall`) {
252
+ return overallChartData.value[index]?.day || ``;
253
+ }
254
+ return timeBasedChartData.value[index]?.day || ``;
255
+ };
256
+ const yFormatter = (value) => {
257
+ if (chartType.value === `compare`) {
258
+ return Math.round(value).toString();
259
+ }
260
+ return value === 0 ? `0` : `${Number(value).toFixed(1)}/4`;
261
+ };
262
+ const dateRangeLabel = computed(() => {
263
+ if (chartType.value === `compare`) {
264
+ return `Selected Pages (${selectedPagePaths.value.length})`;
265
+ }
266
+ const daysDiff = Math.ceil(
267
+ (dateRange.value.end.getTime() - dateRange.value.start.getTime()) / (1e3 * 60 * 60 * 24)
268
+ );
269
+ if (daysDiff <= 7) return `Last ${daysDiff} days`;
270
+ if (daysDiff <= 31) return `Last ${daysDiff} days`;
271
+ if (daysDiff <= 93) return `Last ${Math.round(daysDiff / 30)} months`;
272
+ if (daysDiff <= 186) return `Last ${Math.round(daysDiff / 30)} months`;
273
+ return `Last ${Math.round(daysDiff / 365)} year${daysDiff > 730 ? `s` : ``}`;
274
+ });
275
+ const chartTitle = computed(() => {
276
+ switch (chartType.value) {
277
+ case `line`:
278
+ return `Rating Evolution`;
279
+ case `compare`:
280
+ return `Page Comparison`;
281
+ case `overall`:
282
+ return `Overall Documentation Rating`;
283
+ default:
284
+ return `Rating Evolution`;
285
+ }
286
+ });
287
+ const chartDescription = computed(() => {
288
+ switch (chartType.value) {
289
+ case `line`:
290
+ return `Track selected pages satisfaction over time`;
291
+ case `compare`:
292
+ return `Compare feedback distribution across pages`;
293
+ case `overall`:
294
+ return `Global documentation satisfaction evolution`;
295
+ default:
296
+ return `Track selected pages satisfaction over time`;
297
+ }
298
+ });
299
+ const chartIcon = computed(() => {
300
+ switch (chartType.value) {
301
+ case `line`:
302
+ return `i-lucide-trending-up`;
303
+ case `compare`:
304
+ return `i-lucide-bar-chart-4`;
305
+ case `overall`:
306
+ return `i-lucide-activity`;
307
+ default:
308
+ return `i-lucide-trending-up`;
309
+ }
310
+ });
311
+ const availableChartTypes = [
312
+ {
313
+ value: `line`,
314
+ label: `Line`,
315
+ icon: `i-lucide-trending-up`
316
+ },
317
+ {
318
+ value: `compare`,
319
+ label: `Compare`,
320
+ icon: `i-lucide-bar-chart-4`
321
+ },
322
+ {
323
+ value: `overall`,
324
+ label: `Overall`,
325
+ icon: `i-lucide-activity`
326
+ }
327
+ ];
328
+ function togglePageSelection(pagePath) {
329
+ const index = selectedPagePaths.value.indexOf(pagePath);
330
+ if (index > -1) {
331
+ selectedPagePaths.value.splice(index, 1);
332
+ } else {
333
+ selectedPagePaths.value.push(pagePath);
334
+ }
335
+ }
336
+ function selectTopPages(count) {
337
+ if (!props.pageAnalytics) return;
338
+ const pages = props.pageAnalytics.filter((p) => p && p.total > 0).sort((a, b) => b.total - a.total).slice(0, count);
339
+ selectedPagePaths.value = pages.map((p) => p.path);
340
+ }
341
+ function selectBestRatedPages(count) {
342
+ if (!props.pageAnalytics) return;
343
+ const pages = props.pageAnalytics.filter((p) => p && p.total > 0).sort((a, b) => b.averageScore - a.averageScore).slice(0, count);
344
+ selectedPagePaths.value = pages.map((p) => p.path);
345
+ }
346
+ function selectWorstPages(count) {
347
+ if (!props.pageAnalytics) return;
348
+ const pages = props.pageAnalytics.filter((p) => p && p.total > 0).sort((a, b) => a.averageScore - b.averageScore).slice(0, count);
349
+ selectedPagePaths.value = pages.map((p) => p.path);
350
+ }
351
+ </script>
352
+
353
+ <template>
354
+ <div class="space-y-6">
355
+ <div
356
+ class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
357
+ >
358
+ <Motion
359
+ :key="`header-${chartType}`"
360
+ :initial="{ opacity: 0, y: 10 }"
361
+ :animate="{ opacity: 1, y: 0 }"
362
+ :transition="{ duration: 0.4, ease: 'easeInOut' }"
363
+ class="flex items-center gap-3"
364
+ >
365
+ <UIcon
366
+ :name="chartIcon"
367
+ class="size-6 shrink-0 text-primary sm:size-5"
368
+ />
369
+ <div class="min-w-0">
370
+ <h3 class="truncate text-lg font-semibold">
371
+ {{ chartTitle }}
372
+ </h3>
373
+ <p class="text-sm text-muted">
374
+ {{ chartDescription }}
375
+ </p>
376
+ </div>
377
+ </Motion>
378
+
379
+ <div
380
+ class="flex flex-wrap items-center gap-2 max-sm:flex-row-reverse max-sm:justify-end"
381
+ >
382
+ <AnimatePresence mode="wait">
383
+ <Motion
384
+ v-if="chartType !== 'overall'"
385
+ :initial="{ opacity: 0, scale: 0.9 }"
386
+ :animate="{ opacity: 1, scale: 1 }"
387
+ :exit="{ opacity: 0, scale: 0.9 }"
388
+ :transition="{ duration: 0.3 }"
389
+ >
390
+ <UChip :text="selectedPagePaths.length" size="3xl">
391
+ <UButton
392
+ color="neutral"
393
+ variant="outline"
394
+ icon="i-lucide-settings"
395
+ @click="showPageSelector = true"
396
+ />
397
+ </UChip>
398
+ </Motion>
399
+ </AnimatePresence>
400
+
401
+ <div
402
+ class="flex items-center gap-1 rounded-lg border border-default p-1"
403
+ >
404
+ <UButton
405
+ v-for="type in availableChartTypes"
406
+ :key="type.value"
407
+ :color="chartType === type.value ? 'primary' : 'neutral'"
408
+ :variant="chartType === type.value ? 'solid' : 'ghost'"
409
+ size="sm"
410
+ :icon="type.icon"
411
+ :label="type.label"
412
+ @click="chartType = type.value"
413
+ />
414
+ </div>
415
+ </div>
416
+ </div>
417
+
418
+ <div class="relative mb-8 overflow-hidden rounded-xl">
419
+ <div class="dot-pattern -top-5 right-0 left-0 h-[300px]" />
420
+
421
+ <LineChart
422
+ v-if="chartType === 'overall'"
423
+ :data="chartData"
424
+ :categories="chartCategories"
425
+ :x-formatter="xFormatter"
426
+ :y-formatter="yFormatter"
427
+ :x-label="dateRangeLabel"
428
+ y-label="Rating (out of 4)"
429
+ :show-tooltip="true"
430
+ />
431
+
432
+ <LineChart
433
+ v-else-if="chartType === 'line'"
434
+ :data="chartData"
435
+ :categories="chartCategories"
436
+ :x-formatter="xFormatter"
437
+ :y-formatter="yFormatter"
438
+ :x-label="dateRangeLabel"
439
+ y-label="Rating (out of 4)"
440
+ :show-tooltip="true"
441
+ class="min-h-[300px]"
442
+ />
443
+
444
+ <BarChart
445
+ v-else-if="chartType === 'compare'"
446
+ :data="chartData"
447
+ :categories="chartCategories"
448
+ :y-axis="['positive', 'negative']"
449
+ :stacked="true"
450
+ :x-formatter="xFormatter"
451
+ :y-formatter="yFormatter"
452
+ :x-label="dateRangeLabel"
453
+ y-label="Feedback Count"
454
+ :height="300"
455
+ :bar-padding="0.2"
456
+ :y-grid-line="false"
457
+ class="min-h-[300px]"
458
+ />
459
+ </div>
460
+
461
+ <div v-if="!hasValidData" class="py-4 text-center">
462
+ <p class="text-sm text-muted">
463
+ Chart shows no data - will display real trends once feedback is
464
+ collected
465
+ </p>
466
+ </div>
467
+
468
+ <UModal v-model:open="showPageSelector" :ui="{ content: 'max-w-2xl' }">
469
+ <template #content>
470
+ <UCard>
471
+ <template #header>
472
+ <UButton
473
+ size="sm"
474
+ variant="ghost"
475
+ color="neutral"
476
+ icon="i-lucide-x"
477
+ class="absolute top-2 right-2"
478
+ @click="showPageSelector = false"
479
+ />
480
+ <div class="space-y-3">
481
+ <h3 class="text-lg font-semibold">
482
+ Select Pages to {{ chartType === "line" ? "Track" : "Compare" }}
483
+ </h3>
484
+ <div class="flex flex-wrap items-center gap-2">
485
+ <span class="text-sm font-medium text-muted"
486
+ >Quick select:</span
487
+ >
488
+ <UButton
489
+ size="sm"
490
+ variant="soft"
491
+ color="neutral"
492
+ label="Best Rated 5"
493
+ @click="selectBestRatedPages(5)"
494
+ />
495
+ <UButton
496
+ size="sm"
497
+ variant="soft"
498
+ color="neutral"
499
+ label="Most Popular 5"
500
+ @click="selectTopPages(5)"
501
+ />
502
+ <UButton
503
+ size="sm"
504
+ variant="soft"
505
+ color="neutral"
506
+ label="Worst Rated 5"
507
+ @click="selectWorstPages(5)"
508
+ />
509
+ <UButton
510
+ size="sm"
511
+ variant="soft"
512
+ color="neutral"
513
+ label="Worst Rated 10"
514
+ @click="selectWorstPages(10)"
515
+ />
516
+ </div>
517
+ </div>
518
+ </template>
519
+
520
+ <div class="mb-4">
521
+ <UInput
522
+ v-model="pageSearchQuery"
523
+ placeholder="Search pages..."
524
+ icon="i-lucide-search"
525
+ class="w-full"
526
+ :ui="{ trailing: 'pe-1' }"
527
+ >
528
+ <template v-if="pageSearchQuery?.length" #trailing>
529
+ <UButton
530
+ color="neutral"
531
+ variant="link"
532
+ size="sm"
533
+ icon="i-lucide-circle-x"
534
+ aria-label="Clear input"
535
+ @click="pageSearchQuery = ''"
536
+ />
537
+ </template>
538
+ </UInput>
539
+ </div>
540
+
541
+ <div class="max-h-96 space-y-3 overflow-y-auto">
542
+ <div
543
+ v-for="page in availablePages"
544
+ :key="page.path"
545
+ class="flex cursor-pointer items-center justify-between rounded-lg border border-default p-3 transition-colors hover:bg-muted/50"
546
+ :class="{
547
+ 'border-primary/20 bg-primary/5 hover:bg-primary/10': selectedPagePaths.includes(page.path)
548
+ }"
549
+ @click="togglePageSelection(page.path)"
550
+ >
551
+ <div class="flex min-w-0 flex-1 items-center gap-3">
552
+ <UCheckbox
553
+ :model-value="selectedPagePaths.includes(page.path)"
554
+ @update:model-value="togglePageSelection(page.path)"
555
+ />
556
+ <div class="min-w-0 flex-1">
557
+ <div class="truncate text-sm font-medium">
558
+ {{ page.title }}
559
+ </div>
560
+ <code class="block truncate text-xs text-muted">{{
561
+ page.path
562
+ }}</code>
563
+ </div>
564
+ </div>
565
+ <div class="flex shrink-0 items-center gap-2 text-sm sm:gap-4">
566
+ <div class="text-center">
567
+ <div class="font-semibold">
568
+ {{ page.total }}
569
+ </div>
570
+ <div class="text-xs text-muted">resp.</div>
571
+ </div>
572
+ <div class="text-center">
573
+ <div
574
+ class="font-semibold"
575
+ :class="
576
+ page.score >= 3.5 ? 'text-success' : page.score >= 3 ? 'text-warning' : 'text-error'
577
+ "
578
+ >
579
+ {{ page.score.toFixed(1) }}/4
580
+ </div>
581
+ <div class="text-xs text-muted">score</div>
582
+ </div>
583
+ </div>
584
+ </div>
585
+
586
+ <div v-if="availablePages.length === 0" class="py-8 text-center">
587
+ <UIcon
588
+ name="i-lucide-search-x"
589
+ class="mx-auto mb-2 size-8 text-muted"
590
+ />
591
+ <p class="text-sm text-muted">
592
+ No pages found matching your search
593
+ </p>
594
+ </div>
595
+ </div>
596
+ </UCard>
597
+ </template>
598
+ </UModal>
599
+ </div>
600
+ </template>
601
+
602
+ <style>
603
+ :root{--vis-tooltip-background-color:hsla(0,0%,100%,.95)!important;--vis-tooltip-border-color:rgba(0,0,0,.1)!important;--vis-tooltip-text-color:rgba(0,0,0,.9)!important;--vis-tooltip-label-color:rgba(0,0,0,.7)!important;--vis-tooltip-value-color:#000!important;--vis-axis-grid-color:hsla(0,0%,100%,.1)!important;--vis-axis-tick-label-color:var(--ui-text-muted)!important;--vis-axis-label-color:var(--ui-text-toned)!important;--vis-legend-label-color:var(--ui-text-muted)!important;--dot-pattern-color:#111827}.dark{--vis-tooltip-background-color:rgba(15,23,42,.95)!important;--vis-tooltip-border-color:hsla(0,0%,100%,.1)!important;--vis-tooltip-text-color:hsla(0,0%,100%,.9)!important;--vis-tooltip-label-color:hsla(0,0%,100%,.7)!important;--vis-tooltip-value-color:#fff!important;--dot-pattern-color:#9ca3af}.dot-pattern{background-image:radial-gradient(var(--dot-pattern-color) 1px,transparent 1px);background-position:-8.5px -8.5px;background-size:7px 7px;-webkit-mask-image:radial-gradient(ellipse at center,#000,transparent 75%);mask-image:radial-gradient(ellipse at center,#000,transparent 75%);opacity:20%;position:absolute}
604
+ </style>
@@ -0,0 +1,18 @@
1
+ type PageAnalytic = {
2
+ path: string;
3
+ total: number;
4
+ positive: number;
5
+ negative: number;
6
+ averageScore: number;
7
+ positivePercentage: number;
8
+ feedback: any[];
9
+ lastFeedback: any;
10
+ createdAt: Date;
11
+ updatedAt: Date;
12
+ };
13
+ type __VLS_Props = {
14
+ pageAnalytics: PageAnalytic[];
15
+ };
16
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,149 @@
1
+ <script setup>
2
+ import { getLocalTimeZone, CalendarDate, today } from "@internationalized/date";
3
+ import { formatDateRange } from "little-date";
4
+ const { dateRange, setDateRange, setPresetRange } = useDateRange();
5
+ const formattedDateRange = computed(() => {
6
+ if (dateRange.value.start && dateRange.value.end) {
7
+ return formatDateRange(dateRange.value.start, dateRange.value.end, {
8
+ includeTime: false
9
+ });
10
+ }
11
+ return null;
12
+ });
13
+ const ranges = [
14
+ {
15
+ label: `Last 7 days`,
16
+ preset: `week`
17
+ },
18
+ {
19
+ label: `Last 30 days`,
20
+ preset: `month`
21
+ },
22
+ {
23
+ label: `Last 3 months`,
24
+ preset: `3months`
25
+ },
26
+ {
27
+ label: `Last 6 months`,
28
+ preset: `6months`
29
+ },
30
+ {
31
+ label: `Last year`,
32
+ preset: `year`
33
+ }
34
+ ];
35
+ const toCalendarDate = (date) => {
36
+ return new CalendarDate(
37
+ date.getFullYear(),
38
+ date.getMonth() + 1,
39
+ date.getDate()
40
+ );
41
+ };
42
+ const calendarRange = computed({
43
+ get: () => ({
44
+ start: dateRange.value.start ? toCalendarDate(dateRange.value.start) : void 0,
45
+ end: dateRange.value.end ? toCalendarDate(dateRange.value.end) : void 0
46
+ }),
47
+ set: (newValue) => {
48
+ if (newValue.start && newValue.end) {
49
+ setDateRange({
50
+ start: newValue.start.toDate(getLocalTimeZone()),
51
+ end: newValue.end.toDate(getLocalTimeZone())
52
+ });
53
+ }
54
+ }
55
+ });
56
+ const isRangeSelected = (preset) => {
57
+ if (!dateRange.value.start || !dateRange.value.end) return false;
58
+ const currentDate = today(getLocalTimeZone());
59
+ let startDate = currentDate.copy();
60
+ switch (preset) {
61
+ case `week`:
62
+ startDate = startDate.subtract({
63
+ days: 7
64
+ });
65
+ break;
66
+ case `month`:
67
+ startDate = startDate.subtract({
68
+ days: 30
69
+ });
70
+ break;
71
+ case `3months`:
72
+ startDate = startDate.subtract({
73
+ months: 3
74
+ });
75
+ break;
76
+ case `6months`:
77
+ startDate = startDate.subtract({
78
+ months: 6
79
+ });
80
+ break;
81
+ case `year`:
82
+ startDate = startDate.subtract({
83
+ years: 1
84
+ });
85
+ break;
86
+ }
87
+ const selectedStart = toCalendarDate(dateRange.value.start);
88
+ const selectedEnd = toCalendarDate(dateRange.value.end);
89
+ return Math.abs(
90
+ selectedStart.toDate(getLocalTimeZone()).getTime() - startDate.toDate(getLocalTimeZone()).getTime()
91
+ ) < 24 * 60 * 60 * 1e3 && Math.abs(
92
+ selectedEnd.toDate(getLocalTimeZone()).getTime() - currentDate.toDate(getLocalTimeZone()).getTime()
93
+ ) < 24 * 60 * 60 * 1e3;
94
+ };
95
+ </script>
96
+
97
+ <template>
98
+ <div class="mb-4 flex w-full items-center justify-center">
99
+ <UPopover :content="{ align: 'center' }" :modal="true">
100
+ <UButton
101
+ color="neutral"
102
+ variant="outline"
103
+ icon="i-lucide-calendar"
104
+ class="group min-w-fit data-[state=open]:bg-elevated"
105
+ >
106
+ <span class="truncate">
107
+ <template v-if="formattedDateRange">
108
+ {{ formattedDateRange }}
109
+ </template>
110
+ <template v-else> Pick a date range </template>
111
+ </span>
112
+
113
+ <template #trailing>
114
+ <UIcon
115
+ name="i-lucide-chevron-down"
116
+ class="size-5 shrink-0 text-dimmed transition-transform duration-200 group-data-[state=open]:rotate-180"
117
+ />
118
+ </template>
119
+ </UButton>
120
+
121
+ <template #content>
122
+ <div class="flex items-stretch divide-default sm:divide-x">
123
+ <div class="hidden min-w-[140px] flex-col justify-center sm:flex">
124
+ <UButton
125
+ v-for="(range, index) in ranges"
126
+ :key="index"
127
+ :label="range.label"
128
+ color="neutral"
129
+ variant="ghost"
130
+ class="justify-start rounded-none px-4"
131
+ :class="[
132
+ isRangeSelected(range.preset) ? 'bg-elevated' : 'hover:bg-elevated/50'
133
+ ]"
134
+ truncate
135
+ @click="setPresetRange(range.preset)"
136
+ />
137
+ </div>
138
+
139
+ <UCalendar
140
+ v-model="calendarRange"
141
+ class="p-2"
142
+ :number-of-months="2"
143
+ range
144
+ />
145
+ </div>
146
+ </template>
147
+ </UPopover>
148
+ </div>
149
+ </template>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,10 @@
1
+ type __VLS_Props = {
2
+ feedback: FeedbackItem;
3
+ };
4
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
+ delete: (id: number) => any;
6
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
7
+ onDelete?: ((id: number) => any) | undefined;
8
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
9
+ declare const _default: typeof __VLS_export;
10
+ export default _default;
@@ -0,0 +1,77 @@
1
+ <script setup>
2
+ const {} = defineProps({
3
+ feedback: { type: null, required: true }
4
+ });
5
+ const emit = defineEmits([]);
6
+ const { getRatingFromFeedback, getScoreColor } = useFeedbackRatings();
7
+ const rating = computed(() => getRatingFromFeedback(props.feedback));
8
+ const isDeleting = ref(false);
9
+ async function handleDelete() {
10
+ if (!confirm(`Are you sure you want to delete this feedback?`)) {
11
+ return;
12
+ }
13
+ isDeleting.value = true;
14
+ try {
15
+ emit(`delete`, props.feedback.id);
16
+ } finally {
17
+ isDeleting.value = false;
18
+ }
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <div class="rounded-lg border border-default p-4">
24
+ <div class="mb-3 flex items-start justify-between">
25
+ <div class="flex items-center gap-3">
26
+ <div class="flex flex-col items-center">
27
+ <span class="text-2xl">{{ rating.emoji }}</span>
28
+ <span class="text-xs font-bold" :class="getScoreColor(rating.score)">
29
+ {{ rating.score }}/4
30
+ </span>
31
+ </div>
32
+ <div>
33
+ <div class="mb-1 flex items-center gap-2">
34
+ <span class="text-sm font-medium">{{ rating.label }}</span>
35
+ </div>
36
+ <div class="flex items-center gap-3 text-xs text-muted">
37
+ <span class="flex items-center gap-1">
38
+ <UIcon name="i-lucide-calendar" class="size-3" />
39
+ {{
40
+ new Date(feedback.updatedAt).toLocaleDateString("en-US", {
41
+ month: "short",
42
+ day: "numeric",
43
+ year: "numeric",
44
+ hour: "2-digit",
45
+ minute: "2-digit"
46
+ })
47
+ }}
48
+ </span>
49
+ <span v-if="feedback.country" class="flex items-center gap-1">
50
+ <UIcon name="i-lucide-map-pin" class="size-3" />
51
+ {{ feedback.country }}
52
+ </span>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <UButton
57
+ color="error"
58
+ variant="ghost"
59
+ size="sm"
60
+ icon="i-lucide-trash-2"
61
+ :loading="isDeleting"
62
+ :disabled="isDeleting"
63
+ @click="handleDelete"
64
+ />
65
+ </div>
66
+
67
+ <div
68
+ v-if="feedback.feedback"
69
+ class="rounded bg-muted/30 p-3 text-sm leading-relaxed"
70
+ >
71
+ "{{ feedback.feedback }}"
72
+ </div>
73
+ <div v-else class="text-sm text-muted italic">
74
+ No additional comment provided
75
+ </div>
76
+ </div>
77
+ </template>
@@ -0,0 +1,10 @@
1
+ type __VLS_Props = {
2
+ feedback: FeedbackItem;
3
+ };
4
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
+ delete: (id: number) => any;
6
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
7
+ onDelete?: ((id: number) => any) | undefined;
8
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
9
+ declare const _default: typeof __VLS_export;
10
+ export default _default;
@@ -0,0 +1,17 @@
1
+ type __VLS_Props = {
2
+ icon: string;
3
+ iconColor?: string;
4
+ value: number | string;
5
+ label: string;
6
+ description?: string;
7
+ descriptionColor?: string;
8
+ popoverStats?: {
9
+ percentage?: string;
10
+ trend?: string;
11
+ lastPeriod?: string;
12
+ details?: string;
13
+ };
14
+ };
15
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
16
+ declare const _default: typeof __VLS_export;
17
+ export default _default;
@@ -0,0 +1,66 @@
1
+ <script setup>
2
+ const { iconColor = `text-primary`, descriptionColor = `text-muted` } = defineProps({
3
+ icon: { type: String, required: true },
4
+ iconColor: { type: String, required: false },
5
+ value: { type: [Number, String], required: true },
6
+ label: { type: String, required: true },
7
+ description: { type: String, required: false },
8
+ descriptionColor: { type: String, required: false },
9
+ popoverStats: { type: Object, required: false }
10
+ });
11
+ </script>
12
+
13
+ <template>
14
+ <UPopover mode="hover" arrow :open-delay="300" :close-delay="200">
15
+ <div
16
+ class="flex cursor-pointer items-center gap-3 rounded-lg border border-default bg-muted/20 px-4 py-3 transition-colors hover:bg-muted/30"
17
+ >
18
+ <UIcon :name="icon" :class="`size-6 ${iconColor} shrink-0`" />
19
+ <div>
20
+ <div class="text-xl font-bold">
21
+ {{ value }}
22
+ </div>
23
+ <div class="text-sm text-muted">
24
+ {{ label }}
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <template #content>
30
+ <div class="min-w-64 p-4">
31
+ <div class="mb-3 flex items-center gap-2">
32
+ <UIcon :name="icon" :class="`size-5 ${iconColor}`" />
33
+ <h3 class="font-semibold">{{ label }} Details</h3>
34
+ </div>
35
+
36
+ <div class="space-y-2 text-sm">
37
+ <div v-if="popoverStats?.percentage" class="flex justify-between">
38
+ <span class="text-muted">Percentage:</span>
39
+ <span class="font-medium">{{ popoverStats.percentage }}</span>
40
+ </div>
41
+
42
+ <div v-if="popoverStats?.trend" class="flex justify-between">
43
+ <span class="text-muted">Status:</span>
44
+ <span class="font-medium">
45
+ {{ popoverStats.trend }}
46
+ </span>
47
+ </div>
48
+
49
+ <div v-if="popoverStats?.lastPeriod" class="flex justify-between">
50
+ <span class="text-muted">Last 7 days:</span>
51
+ <span class="font-medium">{{ popoverStats.lastPeriod }}</span>
52
+ </div>
53
+
54
+ <div
55
+ v-if="popoverStats?.details"
56
+ class="border-t border-default pt-2"
57
+ >
58
+ <p class="text-xs text-muted">
59
+ {{ popoverStats.details }}
60
+ </p>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </template>
65
+ </UPopover>
66
+ </template>
@@ -0,0 +1,17 @@
1
+ type __VLS_Props = {
2
+ icon: string;
3
+ iconColor?: string;
4
+ value: number | string;
5
+ label: string;
6
+ description?: string;
7
+ descriptionColor?: string;
8
+ popoverStats?: {
9
+ percentage?: string;
10
+ trend?: string;
11
+ lastPeriod?: string;
12
+ details?: string;
13
+ };
14
+ };
15
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
16
+ declare const _default: typeof __VLS_export;
17
+ export default _default;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "rimelight-components",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "My new Nuxt module",
5
- "repository": "RimelightEntertainment/rimelight-components",
5
+ "repository": "Rimelight Entertainment/rimelight-components",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "exports": {
@@ -38,7 +38,9 @@
38
38
  "playground"
39
39
  ],
40
40
  "dependencies": {
41
+ "@nuxt/image": "^1.11.0",
41
42
  "@nuxt/kit": "^4.1.3",
43
+ "@nuxt/ui": "^4.0.1",
42
44
  "date-fns": "^4.1.0",
43
45
  "nuxt": "^4.1.3",
44
46
  "tailwind-variants": "^3.1.1",
@@ -49,14 +51,23 @@
49
51
  "@nuxt/module-builder": "^1.0.2",
50
52
  "@nuxt/schema": "^4.1.3",
51
53
  "@nuxt/test-utils": "^3.19.2",
54
+ "@prettier/plugin-oxc": "^0.0.4",
52
55
  "@types/node": "latest",
53
56
  "changelogen": "^0.6.2",
54
- "typescript": "~5.9.3",
55
- "vitest": "^3.2.4",
56
- "vue-tsc": "^3.1.0",
57
- "@prettier/plugin-oxc": "^0.0.4",
57
+ "class-variance-authority": "^0.7.1",
58
+ "clsx": "^2.1.1",
59
+ "motion-v": "^1.7.2",
58
60
  "oxlint": "^1.21.0",
59
61
  "prettier": "^3.6.2",
60
- "prettier-plugin-tailwindcss": "^0.6.14"
61
- }
62
+ "prettier-plugin-tailwindcss": "^0.6.14",
63
+ "tailwind-merge": "^3.3.1",
64
+ "tw-animate-css": "^1.4.0",
65
+ "typescript": "~5.9.3",
66
+ "vitest": "^3.2.4",
67
+ "vue-tsc": "^3.1.0"
68
+ },
69
+ "trustedDependencies": [
70
+ "@parcel/watcher",
71
+ "@tailwindcss/oxide"
72
+ ]
62
73
  }
@@ -1,23 +0,0 @@
1
- <script setup>
2
- import { ref } from "vue";
3
- const { to } = defineProps({
4
- to: { type: String, required: true }
5
- });
6
- const actions = ref([
7
- {
8
- label: "View on GitHub",
9
- trailingIcon: "mdi:github",
10
- to
11
- }
12
- ]);
13
- </script>
14
-
15
- <template>
16
- <UBanner
17
- color="primary"
18
- icon="lucide:construction"
19
- title="This website is currently under construction. Feel free to report any issues!"
20
- :actions="actions"
21
- close
22
- />
23
- </template>