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 +8 -1
- package/dist/runtime/components/{app/ConstructionBanner.d.vue.ts → feedback/Feedback.d.vue.ts} +4 -1
- package/dist/runtime/components/feedback/Feedback.vue +207 -0
- package/dist/runtime/components/{app/ConstructionBanner.vue.d.ts → feedback/Feedback.vue.d.ts} +4 -1
- package/dist/runtime/components/feedback/FeedbackChart.d.vue.ts +18 -0
- package/dist/runtime/components/feedback/FeedbackChart.vue +604 -0
- package/dist/runtime/components/feedback/FeedbackChart.vue.d.ts +18 -0
- package/dist/runtime/components/feedback/FeedbackDatePicker.d.vue.ts +3 -0
- package/dist/runtime/components/feedback/FeedbackDatePicker.vue +149 -0
- package/dist/runtime/components/feedback/FeedbackDatePicker.vue.d.ts +3 -0
- package/dist/runtime/components/feedback/FeedbackItem.d.vue.ts +10 -0
- package/dist/runtime/components/feedback/FeedbackItem.vue +77 -0
- package/dist/runtime/components/feedback/FeedbackItem.vue.d.ts +10 -0
- package/dist/runtime/components/feedback/FeedbackStatCard.d.vue.ts +17 -0
- package/dist/runtime/components/feedback/FeedbackStatCard.vue +66 -0
- package/dist/runtime/components/feedback/FeedbackStatCard.vue.d.ts +17 -0
- package/package.json +19 -8
- package/dist/runtime/components/app/ConstructionBanner.vue +0 -23
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!");
|
package/dist/runtime/components/{app/ConstructionBanner.d.vue.ts → feedback/Feedback.d.vue.ts}
RENAMED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
type __VLS_Props = {
|
|
2
|
-
|
|
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>
|
package/dist/runtime/components/{app/ConstructionBanner.vue.d.ts → feedback/Feedback.vue.d.ts}
RENAMED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
type __VLS_Props = {
|
|
2
|
-
|
|
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
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "My new Nuxt module",
|
|
5
|
-
"repository": "
|
|
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
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
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>
|