@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.
- package/dist/assets/index.css +1 -10
- package/dist/common-ui.es.js +1 -19268
- package/dist/common-ui.es.js.map +1 -1
- package/dist/common-ui.umd.js +1 -3
- package/dist/common-ui.umd.js.map +1 -1
- package/dist/components/CardBrowser.vue.d.ts.map +1 -1
- package/dist/components/CourseCardBrowser.vue.d.ts.map +1 -1
- package/dist/components/CourseInformation.vue.d.ts.map +1 -1
- package/dist/components/CourseTagFilterWidget.vue.d.ts +41 -0
- package/dist/components/CourseTagFilterWidget.vue.d.ts.map +1 -0
- package/dist/components/StudySession.vue.d.ts.map +1 -1
- package/dist/components/UserTagPreferences.vue.d.ts +128 -0
- package/dist/components/UserTagPreferences.vue.d.ts.map +1 -0
- package/dist/components/auth/UserRegistration.vue.d.ts +12 -0
- package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
- package/dist/components/cardRendering/AudioAutoPlayer.vue.d.ts.map +1 -1
- package/dist/components/cardRendering/CardLoader.vue.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/plugins/pinia.d.ts.map +1 -1
- package/dist/stores/useAuthStore.d.ts +12 -0
- package/dist/stores/useAuthStore.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/components/CourseInformation.vue +2 -2
- package/src/components/CourseTagFilterWidget.vue +241 -0
- package/src/components/UserTagPreferences.vue +514 -0
- package/src/index.ts +2 -0
|
@@ -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>
|