@vue-skuilder/common-ui 0.1.17 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ <template>
2
+ <div class="course-tag-filter-widget">
3
+ <v-autocomplete
4
+ v-model="localFilter.include"
5
+ :items="availableTags"
6
+ :loading="loading"
7
+ :disabled="loading"
8
+ label="Include tags"
9
+ placeholder="Type to search tags..."
10
+ hint="Cards must have at least one of these tags"
11
+ persistent-hint
12
+ multiple
13
+ chips
14
+ closable-chips
15
+ clearable
16
+ density="compact"
17
+ variant="outlined"
18
+ class="mb-2"
19
+ :custom-filter="fuzzyFilter"
20
+ no-data-text="No matching tags found"
21
+ >
22
+ <template #chip="{ props, item }">
23
+ <v-chip
24
+ v-bind="props"
25
+ color="primary"
26
+ variant="tonal"
27
+ size="small"
28
+ >
29
+ {{ item.raw }}
30
+ </v-chip>
31
+ </template>
32
+ <template #item="{ props, item }">
33
+ <v-list-item v-bind="props" :title="item.raw">
34
+ <template #subtitle v-if="tagSnippets[item.raw]">
35
+ {{ tagSnippets[item.raw] }}
36
+ </template>
37
+ </v-list-item>
38
+ </template>
39
+ </v-autocomplete>
40
+
41
+ <v-autocomplete
42
+ v-model="localFilter.exclude"
43
+ :items="availableTagsForExclude"
44
+ :loading="loading"
45
+ :disabled="loading"
46
+ label="Exclude tags"
47
+ placeholder="Type to search tags..."
48
+ hint="Cards with these tags will be excluded"
49
+ persistent-hint
50
+ multiple
51
+ chips
52
+ closable-chips
53
+ clearable
54
+ density="compact"
55
+ variant="outlined"
56
+ :custom-filter="fuzzyFilter"
57
+ no-data-text="No matching tags found"
58
+ >
59
+ <template #chip="{ props, item }">
60
+ <v-chip
61
+ v-bind="props"
62
+ color="error"
63
+ variant="tonal"
64
+ size="small"
65
+ >
66
+ {{ item.raw }}
67
+ </v-chip>
68
+ </template>
69
+ <template #item="{ props, item }">
70
+ <v-list-item v-bind="props" :title="item.raw">
71
+ <template #subtitle v-if="tagSnippets[item.raw]">
72
+ {{ tagSnippets[item.raw] }}
73
+ </template>
74
+ </v-list-item>
75
+ </template>
76
+ </v-autocomplete>
77
+
78
+ <div v-if="hasActiveFilter" class="filter-summary text-caption mt-2">
79
+ <v-icon size="small" color="info" class="mr-1">mdi-filter</v-icon>
80
+ <span v-if="localFilter.include.length">
81
+ Including: {{ localFilter.include.join(', ') }}
82
+ </span>
83
+ <span v-if="localFilter.include.length && localFilter.exclude.length"> · </span>
84
+ <span v-if="localFilter.exclude.length">
85
+ Excluding: {{ localFilter.exclude.join(', ') }}
86
+ </span>
87
+ </div>
88
+ </div>
89
+ </template>
90
+
91
+ <script lang="ts">
92
+ import { defineComponent, PropType, ref, computed, watch, onMounted } from 'vue';
93
+ import { TagFilter, emptyTagFilter, hasActiveFilter as checkActiveFilter } from '@vue-skuilder/common';
94
+ import { getDataLayer, Tag } from '@vue-skuilder/db';
95
+
96
+ export default defineComponent({
97
+ name: 'CourseTagFilterWidget',
98
+
99
+ props: {
100
+ courseId: {
101
+ type: String,
102
+ required: true,
103
+ },
104
+ modelValue: {
105
+ type: Object as PropType<TagFilter | undefined>,
106
+ default: undefined,
107
+ },
108
+ },
109
+
110
+ emits: ['update:modelValue'],
111
+
112
+ setup(props, { emit }) {
113
+ const loading = ref(true);
114
+ const availableTags = ref<string[]>([]);
115
+ const tagSnippets = ref<Record<string, string>>({});
116
+
117
+ // Local copy of the filter for editing
118
+ const localFilter = ref<TagFilter>(
119
+ props.modelValue ? { ...props.modelValue } : emptyTagFilter()
120
+ );
121
+
122
+ // Computed: tags available for exclude (not already in include)
123
+ const availableTagsForExclude = computed(() => {
124
+ return availableTags.value.filter(
125
+ (tag) => !localFilter.value.include.includes(tag)
126
+ );
127
+ });
128
+
129
+ // Computed: whether filter has any active constraints
130
+ const hasActiveFilter = computed(() => {
131
+ return checkActiveFilter(localFilter.value);
132
+ });
133
+
134
+ // Fuzzy filter function for autocomplete
135
+ const fuzzyFilter = (itemText: string, queryText: string): boolean => {
136
+ if (!queryText) return true;
137
+
138
+ const query = queryText.toLowerCase();
139
+ const text = itemText.toLowerCase();
140
+
141
+ // Simple fuzzy match: all query characters appear in order
142
+ let queryIndex = 0;
143
+ for (let i = 0; i < text.length && queryIndex < query.length; i++) {
144
+ if (text[i] === query[queryIndex]) {
145
+ queryIndex++;
146
+ }
147
+ }
148
+ return queryIndex === query.length;
149
+ };
150
+
151
+ // Watch for external changes to modelValue
152
+ watch(
153
+ () => props.modelValue,
154
+ (newValue) => {
155
+ if (newValue) {
156
+ localFilter.value = { ...newValue };
157
+ } else {
158
+ localFilter.value = emptyTagFilter();
159
+ }
160
+ },
161
+ { deep: true }
162
+ );
163
+
164
+ // Emit changes when local filter changes
165
+ watch(
166
+ localFilter,
167
+ (newFilter) => {
168
+ emit('update:modelValue', { ...newFilter });
169
+ },
170
+ { deep: true }
171
+ );
172
+
173
+ // Watch for courseId changes and reload tags
174
+ watch(
175
+ () => props.courseId,
176
+ async (newCourseId) => {
177
+ if (newCourseId) {
178
+ await loadTags();
179
+ }
180
+ }
181
+ );
182
+
183
+ // Load tags for the course
184
+ async function loadTags() {
185
+ loading.value = true;
186
+ try {
187
+ const courseDB = getDataLayer().getCourseDB(props.courseId);
188
+ const response = await courseDB.getCourseTagStubs();
189
+
190
+ availableTags.value = [];
191
+ tagSnippets.value = {};
192
+
193
+ for (const row of response.rows) {
194
+ if (row.doc) {
195
+ const tag = row.doc as Tag;
196
+ availableTags.value.push(tag.name);
197
+ if (tag.snippet) {
198
+ tagSnippets.value[tag.name] = tag.snippet;
199
+ }
200
+ }
201
+ }
202
+
203
+ // Sort alphabetically
204
+ availableTags.value.sort((a, b) => a.localeCompare(b));
205
+ } catch (error) {
206
+ console.error('[CourseTagFilterWidget] Failed to load tags:', error);
207
+ availableTags.value = [];
208
+ tagSnippets.value = {};
209
+ } finally {
210
+ loading.value = false;
211
+ }
212
+ }
213
+
214
+ onMounted(() => {
215
+ if (props.courseId) {
216
+ loadTags();
217
+ }
218
+ });
219
+
220
+ return {
221
+ loading,
222
+ availableTags,
223
+ availableTagsForExclude,
224
+ tagSnippets,
225
+ localFilter,
226
+ hasActiveFilter,
227
+ fuzzyFilter,
228
+ };
229
+ },
230
+ });
231
+ </script>
232
+
233
+ <style scoped>
234
+ .course-tag-filter-widget {
235
+ width: 100%;
236
+ }
237
+
238
+ .filter-summary {
239
+ color: rgba(var(--v-theme-on-surface), 0.7);
240
+ }
241
+ </style>